From c6175c56c702e3861ba543c329908972746e95a7 Mon Sep 17 00:00:00 2001 From: Hafez Date: Tue, 28 Jul 2026 09:50:24 +0200 Subject: [PATCH 1/9] test(core): expect Open Folder entry points and a full-path repository label --- core/__tests__/App-render.test.tsx | 28 ++++++++- core/__tests__/OpenReviewSourceMenu.test.tsx | 62 ++++++++++++++++---- core/__tests__/app-command-hooks.test.tsx | 1 + core/__tests__/files.test.ts | 26 ++++++++ 4 files changed, 103 insertions(+), 14 deletions(-) create mode 100644 core/__tests__/files.test.ts diff --git a/core/__tests__/App-render.test.tsx b/core/__tests__/App-render.test.tsx index 787286b8..beeb60e5 100644 --- a/core/__tests__/App-render.test.tsx +++ b/core/__tests__/App-render.test.tsx @@ -238,6 +238,7 @@ const createCodiffMock = (overrides: Partial = {}): Window['co onWindowFullScreenChanged: vi.fn(() => () => {}), openConfigFile: vi.fn(async () => {}), openFile: vi.fn(async () => {}), + openRepositoryFolder: vi.fn(async () => {}), resetCodeFontSize: vi.fn(async () => {}), resolvePullRequestUrl: vi.fn(async () => 'https://github.com/owner/repo/pull/1'), saveMarkdownDocument: vi.fn(async (request) => ({ @@ -510,6 +511,27 @@ test('top bar source menu opens the review source dialog', async () => { expect(document.querySelector('[role="menu"]')).toBeNull(); }); +test('repository label opens the folder picker instead of linking to the pull request', async () => { + const openRepositoryFolder = vi.fn(async () => {}); + window.codiff = createCodiffMock({ openRepositoryFolder }); + + await using app = await renderReact(); + await waitFor(() => expect(app.container.querySelector('.app-shell')).not.toBeNull()); + + const label = app.container.querySelector('button.review-top-bar-repository'); + if (!label) { + throw new Error('Expected the repository label button in the top bar.'); + } + expect(label.textContent).toBe('/repo'); + expect(app.container.querySelector('a.review-top-bar-repository')).toBeNull(); + + await act(async () => { + label.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + expect(openRepositoryFolder).toHaveBeenCalledOnce(); +}); + test('empty repository state fills the review pane for centered layout', async () => { window.codiff = createCodiffMock(); @@ -1455,11 +1477,11 @@ test('pull request description collapse button toggles the markdown body', async await waitFor(() => { expect(app.container.querySelector('.source-description-markdown')).not.toBeNull(); }); - const repositoryLink = app.container.querySelector( - '.review-top-bar-repository', + const repositoryButton = app.container.querySelector( + 'button.review-top-bar-repository', ); const sourceLink = app.container.querySelector('.review-top-bar-source'); - expect(repositoryLink?.href).toBe(source.url); + expect(repositoryButton).not.toBeNull(); expect(sourceLink?.href).toBe(source.url); expect(sourceLink?.textContent).toBe('PR #12'); expect(sourceLink?.querySelector('svg')).not.toBeNull(); diff --git a/core/__tests__/OpenReviewSourceMenu.test.tsx b/core/__tests__/OpenReviewSourceMenu.test.tsx index 5c2adf72..d035bc18 100644 --- a/core/__tests__/OpenReviewSourceMenu.test.tsx +++ b/core/__tests__/OpenReviewSourceMenu.test.tsx @@ -8,7 +8,9 @@ import { OpenReviewSourceMenu } from '../app/components/OpenReviewSourceMenu.tsx import { renderReact } from './helpers/react.tsx'; test('opens the menu from the trigger and moves focus to the first action', async () => { - await using view = await renderReact( {}} />); + await using view = await renderReact( + {}} onOpenFolder={() => {}} />, + ); const trigger = getTrigger(view.container); expect(trigger.getAttribute('aria-haspopup')).toBe('menu'); @@ -19,12 +21,19 @@ test('opens the menu from the trigger and moves focus to the first action', asyn expect(trigger.getAttribute('aria-expanded')).toBe('true'); const items = getMenuItems(); - expect(items.map((item) => item.textContent)).toEqual(['Open PR', 'Open Branch', 'Open Commit']); + expect(items.map((item) => item.textContent)).toEqual([ + 'Open PR', + 'Open Branch', + 'Open Commit', + 'Open Folder', + ]); expect(document.activeElement).toBe(items[0]); }); test('renders the open menu outside the top bar so stacking contexts cannot trap it', async () => { - await using view = await renderReact( {}} />); + await using view = await renderReact( + {}} onOpenFolder={() => {}} />, + ); await click(getTrigger(view.container)); @@ -36,7 +45,9 @@ test('renders the open menu outside the top bar so stacking contexts cannot trap test('selecting an action reports its kind and closes the menu', async () => { const onOpen = vi.fn(); - await using view = await renderReact(); + await using view = await renderReact( + {}} />, + ); await click(getTrigger(view.container)); const commitItem = getMenuItems().find((item) => item.textContent === 'Open Commit'); @@ -51,27 +62,33 @@ test('selecting an action reports its kind and closes the menu', async () => { }); test('arrow keys move through the actions and wrap around', async () => { - await using view = await renderReact( {}} />); + await using view = await renderReact( + {}} onOpenFolder={() => {}} />, + ); await click(getTrigger(view.container)); - const [pullRequest, branch, commit] = getMenuItems(); + const [pullRequest, branch, commit, folder] = getMenuItems(); await pressKey('ArrowDown'); expect(document.activeElement).toBe(branch); await pressKey('ArrowDown'); expect(document.activeElement).toBe(commit); await pressKey('ArrowDown'); + expect(document.activeElement).toBe(folder); + await pressKey('ArrowDown'); expect(document.activeElement).toBe(pullRequest); await pressKey('ArrowUp'); - expect(document.activeElement).toBe(commit); + expect(document.activeElement).toBe(folder); await pressKey('Home'); expect(document.activeElement).toBe(pullRequest); await pressKey('End'); - expect(document.activeElement).toBe(commit); + expect(document.activeElement).toBe(folder); }); test('Escape closes the menu and returns focus to the trigger', async () => { - await using view = await renderReact( {}} />); + await using view = await renderReact( + {}} onOpenFolder={() => {}} />, + ); const trigger = getTrigger(view.container); await click(trigger); @@ -81,8 +98,29 @@ test('Escape closes the menu and returns focus to the trigger', async () => { expect(document.activeElement).toBe(trigger); }); +test('opens the folder picker from a separated menu action', async () => { + const onOpenFolder = vi.fn(); + await using view = await renderReact( + {}} onOpenFolder={onOpenFolder} />, + ); + + await click(getTrigger(view.container)); + expect(document.querySelector('[role="menu"] [role="separator"]')).not.toBeNull(); + + const folderItem = getMenuItems().find((item) => item.textContent === 'Open Folder'); + if (!folderItem) { + throw new Error('Expected an Open Folder menu item.'); + } + await click(folderItem); + + expect(onOpenFolder).toHaveBeenCalledOnce(); + expect(queryMenu()).toBeNull(); +}); + test('Tab hands focus back to the trigger so traversal continues from the top bar', async () => { - await using view = await renderReact( {}} />); + await using view = await renderReact( + {}} onOpenFolder={() => {}} />, + ); const trigger = getTrigger(view.container); await click(trigger); @@ -100,7 +138,9 @@ test('Tab hands focus back to the trigger so traversal continues from the top ba test('clicking outside dismisses the menu without opening anything', async () => { const onOpen = vi.fn(); - await using view = await renderReact(); + await using view = await renderReact( + {}} />, + ); await click(getTrigger(view.container)); await act(async () => { diff --git a/core/__tests__/app-command-hooks.test.tsx b/core/__tests__/app-command-hooks.test.tsx index 3e523aab..49e20ccb 100644 --- a/core/__tests__/app-command-hooks.test.tsx +++ b/core/__tests__/app-command-hooks.test.tsx @@ -103,6 +103,7 @@ test('app commands register the complete command set and delegate dynamic action 'open-pull-request', 'open-commit', 'open-branch', + 'open-folder', 'sidebar-tree', 'sidebar-history', 'sidebar-walkthrough', diff --git a/core/__tests__/files.test.ts b/core/__tests__/files.test.ts new file mode 100644 index 00000000..174610fc --- /dev/null +++ b/core/__tests__/files.test.ts @@ -0,0 +1,26 @@ +import { expect, test } from 'vite-plus/test'; +import { splitRepositoryPath } from '../lib/files.ts'; + +test('splits a home repository path into a head and a repository tail', () => { + expect(splitRepositoryPath('/Users/hafez/dev/websites/blog')).toEqual({ + head: '~/dev/websites', + tail: '/blog', + }); + expect(splitRepositoryPath('/home/ada/projects/analytical-engine')).toEqual({ + head: '~/projects', + tail: '/analytical-engine', + }); +}); + +test('keeps paths outside the home directory absolute', () => { + expect(splitRepositoryPath('/srv/git/deploy-tools')).toEqual({ + head: '/srv/git', + tail: '/deploy-tools', + }); + expect(splitRepositoryPath('/repo')).toEqual({ head: '', tail: '/repo' }); +}); + +test('handles the home directory itself and short paths', () => { + expect(splitRepositoryPath('/Users/hafez')).toEqual({ head: '', tail: '~' }); + expect(splitRepositoryPath('~/blog')).toEqual({ head: '~', tail: '/blog' }); +}); From a1b6ff69e5ccd33dc21e9ecab952c337c7378a9c Mon Sep 17 00:00:00 2001 From: Hafez Date: Tue, 28 Jul 2026 09:56:23 +0200 Subject: [PATCH 2/9] feat(core): open folder entry points and a full-path repository label --- core/App.css | 32 ++++++++++++++++-- core/App.tsx | 35 +++++++++++--------- core/Desktop.css | 7 ++++ core/app/components/OpenReviewSourceMenu.tsx | 26 ++++++++++++++- core/app/hooks/useAppCommands.ts | 7 ++++ core/global.d.ts | 1 + core/lib/files.ts | 20 +++++++++-- electron/main.cjs | 4 +++ electron/preload.cjs | 1 + 9 files changed, 112 insertions(+), 21 deletions(-) diff --git a/core/App.css b/core/App.css index 192e4bb8..2e11ab2a 100644 --- a/core/App.css +++ b/core/App.css @@ -768,6 +768,32 @@ html[data-codiff-platform='darwin'] .workspace-top-bar { white-space: nowrap; } +button.review-top-bar-repository { + align-items: center; + background: transparent; + border: 0; + color: inherit; + cursor: pointer; + display: flex; + flex: 0 1 auto; + font: inherit; + padding: 0; + text-align: left; +} + +.review-top-bar-repository-head { + flex: 0 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.review-top-bar-repository-tail { + flex: none; + white-space: nowrap; +} + .review-top-bar-branch { align-items: center; border-radius: 14px; @@ -790,12 +816,14 @@ a.review-top-bar-source { } a.review-top-bar-repository:hover, -a.review-top-bar-source:hover { +a.review-top-bar-source:hover, +button.review-top-bar-repository:hover { color: var(--text); } a.review-top-bar-repository:focus-visible, -a.review-top-bar-source:focus-visible { +a.review-top-bar-source:focus-visible, +button.review-top-bar-repository:focus-visible { border-radius: 3px; outline: 1px solid var(--tree-selection-focus); outline-offset: 2px; diff --git a/core/App.tsx b/core/App.tsx index 201131fc..5308001c 100644 --- a/core/App.tsx +++ b/core/App.tsx @@ -67,7 +67,7 @@ import { shouldLoadDiffSectionContents, shouldPreloadSectionContentsForSearch, } from './lib/diff.ts'; -import { compactPath, sortFiles } from './lib/files.ts'; +import { sortFiles, splitRepositoryPath } from './lib/files.ts'; import { consumeReloadSelection, getChangedPaths, @@ -1496,6 +1496,10 @@ export default function App() { setOpenReviewSourceKind(kind); }, []); + const openRepositoryFolder = useCallback(() => { + void window.codiff.openRepositoryFolder().catch(() => {}); + }, []); + useEffect( () => window.codiff.onOpenReviewSource(showOpenReviewSourceDialog), [showOpenReviewSourceDialog], @@ -1649,7 +1653,7 @@ export default function App() { walkthroughError?.code === 'OPENCODE_NOT_FOUND' || walkthroughError?.code === 'PI_NOT_FOUND'); - const sidebarLabel = compactPath(state.root); + const repositoryPathParts = splitRepositoryPath(state.root); const sidebarSourceLabel = state.source.type !== 'working-tree' ? getSourceLabel(state.source) : null; const pullRequestUrl = state.source.type === 'pull-request' ? state.source.url : null; @@ -1805,22 +1809,23 @@ export default function App() { onModeChange={changeSidebarMode} onToggleSidebar={toggleSidebar} repository={ - pullRequestUrl ? ( - - {sidebarLabel} - - ) : ( - {sidebarLabel} - ) + } repositoryTooltip={state.root} sidebarCollapsed={sidebarCollapsed} - sourceMenu={} + sourceMenu={ + + } toggleTitle={`${sidebarCollapsed ? 'Expand' : 'Collapse'} sidebar (${getShortcutLabel( codiffConfig.keymap, 'toggleSidebar', diff --git a/core/Desktop.css b/core/Desktop.css index fc2f4cd6..37796b76 100644 --- a/core/Desktop.css +++ b/core/Desktop.css @@ -54,3 +54,10 @@ color: var(--muted); flex: none; } + +.open-review-source-menu-separator { + background: var(--file-border); + flex: none; + height: 1px; + margin: 3px 6px; +} diff --git a/core/app/components/OpenReviewSourceMenu.tsx b/core/app/components/OpenReviewSourceMenu.tsx index 9b1f1a61..e3703360 100644 --- a/core/app/components/OpenReviewSourceMenu.tsx +++ b/core/app/components/OpenReviewSourceMenu.tsx @@ -1,3 +1,4 @@ +import { FolderOpenIcon as FolderOpen } from '@phosphor-icons/react/FolderOpen'; import { GitBranchIcon as GitBranch } from '@phosphor-icons/react/GitBranch'; import { GitCommitIcon as GitCommit } from '@phosphor-icons/react/GitCommit'; import { GitPullRequestIcon as GitPullRequest } from '@phosphor-icons/react/GitPullRequest'; @@ -35,7 +36,13 @@ const menuActions: ReadonlyArray<{ }, ]; -export function OpenReviewSourceMenu({ onOpen }: { onOpen: (kind: OpenReviewSourceKind) => void }) { +export function OpenReviewSourceMenu({ + onOpen, + onOpenFolder, +}: { + onOpen: (kind: OpenReviewSourceKind) => void; + onOpenFolder: () => void; +}) { // The top bar creates a stacking context (backdrop-filter) that later // sibling rows paint over on non-macOS platforms, so the menu is portaled to // the document body and positioned from the trigger's viewport rect. @@ -178,6 +185,23 @@ export function OpenReviewSourceMenu({ onOpen }: { onOpen: (kind: OpenReviewSour {action.label} ))} +
+
, document.body, ) diff --git a/core/app/hooks/useAppCommands.ts b/core/app/hooks/useAppCommands.ts index bf6d059b..d6173bb8 100644 --- a/core/app/hooks/useAppCommands.ts +++ b/core/app/hooks/useAppCommands.ts @@ -77,6 +77,13 @@ export function useAppCommands({ id: 'open-branch', title: 'Open Branch', }), + registry.register({ + execute: () => { + void window.codiff.openRepositoryFolder().catch(() => {}); + }, + id: 'open-folder', + title: 'Open Folder', + }), registry.register({ execute: () => changeSidebarMode('tree'), id: 'sidebar-tree', diff --git a/core/global.d.ts b/core/global.d.ts index a36949d8..368661b0 100644 --- a/core/global.d.ts +++ b/core/global.d.ts @@ -94,6 +94,7 @@ declare global { onWindowFullScreenChanged: (callback: (isFullScreen: boolean) => void) => () => void; openConfigFile: () => Promise; openFile: (path: string) => Promise; + openRepositoryFolder: () => Promise; resetCodeFontSize: () => Promise; resolvePullRequestUrl: (value: string) => Promise; saveMarkdownDocument: ( diff --git a/core/lib/files.ts b/core/lib/files.ts index 14f2118a..b8449700 100644 --- a/core/lib/files.ts +++ b/core/lib/files.ts @@ -16,10 +16,11 @@ export const fileTreeSort = ( right: { isDirectory: boolean; path: string; segments?: ReadonlyArray }, ) => compareTreePaths(left.path, right.path); +const abbreviateHome = (path: string) => + path.replace(/^\/Users\/[^/]+(?=\/|$)/, '~').replace(/^\/home\/[^/]+(?=\/|$)/, '~'); + export const compactPath = (path: string) => { - const homePath = path - .replace(/^\/Users\/[^/]+(?=\/|$)/, '~') - .replace(/^\/home\/[^/]+(?=\/|$)/, '~'); + const homePath = abbreviateHome(path); const parts = homePath.split('/').filter(Boolean); if (parts.length <= 2) { @@ -34,6 +35,19 @@ export const compactPath = (path: string) => { return `${prefix}${first}/${middle ? `${middle}/` : ''}${last}`; }; +/** + * Splits a repository path into a shrinkable head and the repository's own + * directory, so the top bar can keep the repository name visible and put the + * ellipsis in the middle when the full path overflows. + */ +export const splitRepositoryPath = (path: string) => { + const homePath = abbreviateHome(path); + const separator = homePath.lastIndexOf('/'); + return separator > 0 + ? { head: homePath.slice(0, separator), tail: homePath.slice(separator) } + : { head: '', tail: homePath }; +}; + function compareTreePaths(leftPath: string, rightPath: string) { const leftParts = leftPath.split('/'); const rightParts = rightPath.split('/'); diff --git a/electron/main.cjs b/electron/main.cjs index 2041149c..277fce7e 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -1743,6 +1743,10 @@ ipcMain.handle('codiff:resetCodeFontSize', () => { ipcMain.handle('codiff:openConfigFile', () => openConfigFile()); +ipcMain.handle('codiff:openRepositoryFolder', (event) => + openRepositoryFolder(BrowserWindow.fromWebContents(event.sender) ?? undefined), +); + ipcMain.handle('codiff:openFile', async (event, filePath) => { const repositoryRoot = getWindowRepositoryRoot(event.sender.id); const repositoryFilePath = validateRepositoryPath(filePath); diff --git a/electron/preload.cjs b/electron/preload.cjs index 83a3a4eb..e4288cf4 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -126,6 +126,7 @@ const codiff = { }, openConfigFile: () => ipcRenderer.invoke('codiff:openConfigFile'), openFile: (path) => ipcRenderer.invoke('codiff:openFile', path), + openRepositoryFolder: () => ipcRenderer.invoke('codiff:openRepositoryFolder'), resolvePullRequestUrl: (value) => ipcRenderer.invoke('codiff:resolvePullRequestUrl', value), setDiffStyle: (value) => ipcRenderer.invoke('codiff:setDiffStyle', value), setShowOutdated: (value) => ipcRenderer.invoke('codiff:setShowOutdated', value), From a9f4fe53936139d6b5f7c7cdb1517d1182b555cf Mon Sep 17 00:00:00 2001 From: Hafez Date: Tue, 28 Jul 2026 10:05:47 +0200 Subject: [PATCH 3/9] test(core): expect Windows path handling and action-oriented control names --- core/__tests__/App-render.test.tsx | 1 + core/__tests__/OpenReviewSourceMenu.test.tsx | 2 ++ core/__tests__/files.test.ts | 15 +++++++++++++++ 3 files changed, 18 insertions(+) diff --git a/core/__tests__/App-render.test.tsx b/core/__tests__/App-render.test.tsx index beeb60e5..a267dc6d 100644 --- a/core/__tests__/App-render.test.tsx +++ b/core/__tests__/App-render.test.tsx @@ -523,6 +523,7 @@ test('repository label opens the folder picker instead of linking to the pull re throw new Error('Expected the repository label button in the top bar.'); } expect(label.textContent).toBe('/repo'); + expect(label.getAttribute('aria-label')).toBe('Open a different repository (current: /repo)'); expect(app.container.querySelector('a.review-top-bar-repository')).toBeNull(); await act(async () => { diff --git a/core/__tests__/OpenReviewSourceMenu.test.tsx b/core/__tests__/OpenReviewSourceMenu.test.tsx index d035bc18..a8bfa9f0 100644 --- a/core/__tests__/OpenReviewSourceMenu.test.tsx +++ b/core/__tests__/OpenReviewSourceMenu.test.tsx @@ -15,6 +15,8 @@ test('opens the menu from the trigger and moves focus to the first action', asyn expect(trigger.getAttribute('aria-haspopup')).toBe('menu'); expect(trigger.getAttribute('aria-expanded')).toBe('false'); + expect(trigger.getAttribute('aria-label')).toBe('Open a PR, branch, commit, or folder'); + expect(trigger.title).toBe('Open a PR, branch, commit, or folder'); expect(queryMenu()).toBeNull(); await click(trigger); diff --git a/core/__tests__/files.test.ts b/core/__tests__/files.test.ts index 174610fc..c864f5d8 100644 --- a/core/__tests__/files.test.ts +++ b/core/__tests__/files.test.ts @@ -20,6 +20,21 @@ test('keeps paths outside the home directory absolute', () => { expect(splitRepositoryPath('/repo')).toEqual({ head: '', tail: '/repo' }); }); +test('splits and abbreviates Windows repository paths', () => { + expect(splitRepositoryPath(String.raw`C:\Users\Ada\dev\analytical-engine`)).toEqual({ + head: String.raw`~\dev`, + tail: String.raw`\analytical-engine`, + }); + expect(splitRepositoryPath('C:/Users/Ada/dev/analytical-engine')).toEqual({ + head: '~/dev', + tail: '/analytical-engine', + }); + expect(splitRepositoryPath(String.raw`D:\srv\deploy-tools`)).toEqual({ + head: String.raw`D:\srv`, + tail: String.raw`\deploy-tools`, + }); +}); + test('handles the home directory itself and short paths', () => { expect(splitRepositoryPath('/Users/hafez')).toEqual({ head: '', tail: '~' }); expect(splitRepositoryPath('~/blog')).toEqual({ head: '~', tail: '/blog' }); From 462ec83d247e1ea00ffdf557610ca2b7d90350f2 Mon Sep 17 00:00:00 2001 From: Hafez Date: Tue, 28 Jul 2026 10:07:57 +0200 Subject: [PATCH 4/9] fix(core): handle Windows paths and name the folder entry points --- core/App.tsx | 1 + core/app/components/OpenReviewSourceMenu.tsx | 4 ++-- core/lib/files.ts | 7 +++++-- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/core/App.tsx b/core/App.tsx index 5308001c..d044afeb 100644 --- a/core/App.tsx +++ b/core/App.tsx @@ -1810,6 +1810,7 @@ export default function App() { onToggleSidebar={toggleSidebar} repository={ + } repositoryTooltip={state.root} sidebarCollapsed={sidebarCollapsed} From c333d0c077af73a6b23cbb5def773fdd43365d86 Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 29 Jul 2026 01:49:12 +0200 Subject: [PATCH 9/9] fix(core): ellipsize the repository name when it alone overflows the top bar --- core/App.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/core/App.css b/core/App.css index 0fa3a372..22945690 100644 --- a/core/App.css +++ b/core/App.css @@ -784,6 +784,10 @@ html[data-codiff-platform='darwin'] .workspace-top-bar { .review-top-bar-repository-tail { flex: none; + max-width: 100%; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; white-space: nowrap; }