Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,8 @@
"@fastify/rate-limit": "^9.1.0",
"@fastify/static": "^7.0.4",
"@fastify/websocket": "^9.0.0",
"@gitgraph/core": "^1.5.0",
"@gitgraph/react": "^1.6.0",
"@lezer/highlight": "^1.2.3",
"@sentry/electron": "^7.13.0",
"@tanstack/react-virtual": "^3.13.13",
Expand Down
69 changes: 66 additions & 3 deletions src/__tests__/renderer/components/MainPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ import {

// Mock child components to simplify testing - must be before MainPanel import

// useModalLayer is no-op in tests — MainPanel does not provide LayerStackProvider,
// and the BranchSwitcherDropdown registers a layer when rendered.
vi.mock('../../../renderer/hooks/ui/useModalLayer', () => ({
useModalLayer: () => {},
}));

// TerminalView: forwardRef stub that records render calls per session so we can
// assert persistence (kept mounted) vs destruction (unmounted) across sessions.
const terminalViewSessions: string[] = [];
Expand Down Expand Up @@ -198,6 +204,9 @@ vi.mock('../../../renderer/components/InlineWizard', () => ({
vi.mock('../../../renderer/services/git', () => ({
gitService: {
getDiff: vi.fn().mockResolvedValue({ diff: 'mock diff content' }),
getBranches: vi.fn().mockResolvedValue([]),
getGraph: vi.fn().mockResolvedValue([]),
switchBranch: vi.fn().mockResolvedValue({ success: true, stderr: '' }),
},
}));

Expand Down Expand Up @@ -1750,8 +1759,7 @@ describe('MainPanel', () => {
expect(writeText).toHaveBeenCalledWith('main');
});

it('should open git log when clicking on SSH remote git badge', async () => {
const setGitLogOpen = vi.fn();
it('should open branch switcher when double-clicking on SSH remote git badge', async () => {
const session = createSession({
isGitRepo: true,
sessionSshRemoteConfig: { enabled: true, remoteId: 'ssh-remote-123' },
Expand All @@ -1764,6 +1772,60 @@ describe('MainPanel', () => {
});
vi.mocked(window.maestro.sshRemote.getConfigs).mockImplementation(mockGetConfigs);

render(<MainPanel {...defaultProps} activeSession={session} />);

await waitFor(() => {
expect(screen.getByText('my-ssh-remote')).toBeInTheDocument();
});

fireEvent.doubleClick(screen.getByText('my-ssh-remote'));

// Branch switcher dropdown opens (revealed by its filter input).
expect(await screen.findByPlaceholderText(/Filter branches/)).toBeInTheDocument();
expect(gitService.getBranches).toHaveBeenCalled();
});

it('should open branch switcher on Shift+Enter on SSH remote git badge (keyboard a11y)', async () => {
const session = createSession({
isGitRepo: true,
sessionSshRemoteConfig: { enabled: true, remoteId: 'ssh-remote-123' },
});

const mockGetConfigs = vi.fn().mockResolvedValue({
success: true,
configs: [{ id: 'ssh-remote-123', name: 'my-ssh-remote' }],
});
vi.mocked(window.maestro.sshRemote.getConfigs).mockImplementation(mockGetConfigs);

render(<MainPanel {...defaultProps} activeSession={session} />);

await waitFor(() => {
expect(screen.getByText('my-ssh-remote')).toBeInTheDocument();
});

// The chip's button is the parent of the SSH remote name span.
const chipButton = screen.getByText('my-ssh-remote').closest('button');
expect(chipButton).not.toBeNull();
fireEvent.keyDown(chipButton!, { key: 'Enter', shiftKey: true });

// Branch switcher dropdown opens via the keyboard path.
expect(await screen.findByPlaceholderText(/Filter branches/)).toBeInTheDocument();
expect(gitService.getBranches).toHaveBeenCalled();
});

it('should open git log when single-clicking on SSH remote git badge', async () => {
const setGitLogOpen = vi.fn();
const session = createSession({
isGitRepo: true,
sessionSshRemoteConfig: { enabled: true, remoteId: 'ssh-remote-123' },
});

const mockGetConfigs = vi.fn().mockResolvedValue({
success: true,
configs: [{ id: 'ssh-remote-123', name: 'my-ssh-remote' }],
});
vi.mocked(window.maestro.sshRemote.getConfigs).mockImplementation(mockGetConfigs);

render(<MainPanel {...defaultProps} activeSession={session} setGitLogOpen={setGitLogOpen} />);

await waitFor(() => {
Expand All @@ -1772,7 +1834,8 @@ describe('MainPanel', () => {

fireEvent.click(screen.getByText('my-ssh-remote'));

expect(setGitLogOpen).toHaveBeenCalledWith(true);
// Single click is debounced (~220ms) before opening git log.
await waitFor(() => expect(setGitLogOpen).toHaveBeenCalledWith(true), { timeout: 1000 });
});

it('should call gitService.getDiff with SSH remote ID when session has SSH remote config enabled', async () => {
Expand Down
86 changes: 86 additions & 0 deletions src/main/ipc/handlers/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,92 @@ export function registerGitHandlers(_deps: GitHandlerDependencies): void {
)
);

// Topology data for graph view: includes parent hashes for lane rendering.
ipcMain.handle(
'git:graph',
withIpcErrorLogging(
handlerOpts('graph'),
async (
cwd: string,
options?: { limit?: number },
sshRemoteId?: string,
remoteCwd?: string
) => {
const sshRemote = sshRemoteId ? getSshRemoteById(sshRemoteId) : undefined;
const effectiveRemoteCwd = sshRemote ? remoteCwd || cwd : undefined;
const limit = options?.limit || 200;
// Use ASCII Unit Separator (U+001F, written as %x1f in git's pretty-format)
// between fields. `|` was tempting but author names and subjects can legally
// contain it, which silently corrupts every field after the offending one.
// US is a non-printing control character that never appears in real text.
const args = [
'log',
'--all',
`--max-count=${limit}`,
'--pretty=format:GRAPH_START%H%x1f%P%x1f%an%x1f%ad%x1f%D%x1f%s',
'--date=iso-strict',
];
const result = await execGit(args, cwd, sshRemote, effectiveRemoteCwd);
if (result.exitCode !== 0) {
return { nodes: [], error: result.stderr };
}
const nodes = result.stdout
.split('GRAPH_START')
.filter((c) => c.trim())
.map((block) => {
const trimmed = block.trim();
const [hash = '', parents = '', author = '', date = '', refs = '', ...subj] =
trimmed.split('\x1f');
return {
hash,
shortHash: hash.slice(0, 7),
parents: parents ? parents.split(' ').filter(Boolean) : [],
author,
date,
refs: refs ? refs.split(', ').filter((r) => r.trim()) : [],
// Re-join with the same separator in case the subject itself contained one
// (extremely unlikely for a control character, but cheap to be correct).
subject: subj.join('\x1f'),
};
});
return { nodes, error: null };
}
)
);

// Switch to an existing branch in the current working tree.
// Returns success=false with stderr text on failure (e.g., dirty working tree).
ipcMain.handle(
'git:switch',
withIpcErrorLogging(
handlerOpts('switch'),
async (cwd: string, branchName: string, sshRemoteId?: string, remoteCwd?: string) => {
// Reject flag-like names so a caller can't pass e.g. "-c new-branch" or "-C"
// and have git interpret it as a switch flag. execFile blocks shell
// injection but not flag injection.
if (
typeof branchName !== 'string' ||
branchName.length === 0 ||
branchName.startsWith('-')
) {
return {
success: false,
stdout: '',
stderr: `Invalid branch name: ${branchName}`,
};
}
const sshRemote = sshRemoteId ? getSshRemoteById(sshRemoteId) : undefined;
const effectiveRemoteCwd = sshRemote ? remoteCwd || cwd : undefined;
const result = await execGit(['switch', branchName], cwd, sshRemote, effectiveRemoteCwd);
return {
success: result.exitCode === 0,
stdout: result.stdout,
stderr: result.stderr,
};
}
)
);

ipcMain.handle(
'git:commitCount',
withIpcErrorLogging(
Expand Down
36 changes: 36 additions & 0 deletions src/main/preload/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,19 @@ export interface GitLogEntry {
subject: string;
}

/**
* Git graph node — like GitLogEntry but with parent hashes for topology rendering.
*/
export interface GitGraphNode {
hash: string;
shortHash: string;
parents: string[];
author: string;
date: string;
refs: string[];
subject: string;
}

/**
* Discovered worktree event data
*/
Expand Down Expand Up @@ -207,6 +220,29 @@ export function createGitApi() {
error: string | null;
}> => ipcRenderer.invoke('git:log', cwd, options, sshRemoteId),

/**
* Get topology graph data (commits with parent hashes) for graph rendering
*/
graph: (
cwd: string,
options?: { limit?: number },
sshRemoteId?: string,
remoteCwd?: string
): Promise<{ nodes: GitGraphNode[]; error: string | null }> =>
ipcRenderer.invoke('git:graph', cwd, options, sshRemoteId, remoteCwd),

/**
* Switch the current working tree to an existing branch.
* Returns success=false on dirty working tree (stderr contains git's message).
*/
switchBranch: (
cwd: string,
branchName: string,
sshRemoteId?: string,
remoteCwd?: string
): Promise<{ success: boolean; stdout: string; stderr: string }> =>
ipcRenderer.invoke('git:switch', cwd, branchName, sshRemoteId, remoteCwd),

/**
* Get commit count
*/
Expand Down
Loading