Skip to content
Merged
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
24 changes: 24 additions & 0 deletions core/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -768,6 +768,29 @@ html[data-codiff-platform='darwin'] .workspace-top-bar {
white-space: nowrap;
}

.review-top-bar-repository-path {
align-items: center;
display: flex;
flex: 0 1 auto;
}

.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;
max-width: 100%;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

.review-top-bar-branch {
align-items: center;
border-radius: 14px;
Expand Down Expand Up @@ -3816,6 +3839,7 @@ diffs-container .review-comment-thread {

.empty-panel code {
color: var(--text);
overflow-wrap: anywhere;
}

.empty-panel-menu-path {
Expand Down
31 changes: 16 additions & 15 deletions core/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -1496,6 +1496,10 @@ export default function App() {
setOpenReviewSourceKind(kind);
}, []);

const openRepositoryFolder = useCallback(() => {
void window.codiff.openRepositoryFolder().catch(() => {});
}, []);

useEffect(
() => window.codiff.onOpenReviewSource(showOpenReviewSourceDialog),
[showOpenReviewSourceDialog],
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -1805,22 +1809,19 @@ export default function App() {
onModeChange={changeSidebarMode}
onToggleSidebar={toggleSidebar}
repository={
pullRequestUrl ? (
<a
className="review-top-bar-repository"
href={pullRequestUrl}
rel="noreferrer"
target="_blank"
>
{sidebarLabel}
</a>
) : (
<span className="review-top-bar-repository">{sidebarLabel}</span>
)
<span className="review-top-bar-repository review-top-bar-repository-path">
<span className="review-top-bar-repository-head">{repositoryPathParts.head}</span>
<span className="review-top-bar-repository-tail">{repositoryPathParts.tail}</span>
</span>
}
repositoryTooltip={state.root}
sidebarCollapsed={sidebarCollapsed}
sourceMenu={<OpenReviewSourceMenu onOpen={showOpenReviewSourceDialog} />}
sourceMenu={
<OpenReviewSourceMenu
onOpen={showOpenReviewSourceDialog}
onOpenFolder={openRepositoryFolder}
/>
}
toggleTitle={`${sidebarCollapsed ? 'Expand' : 'Collapse'} sidebar (${getShortcutLabel(
codiffConfig.keymap,
'toggleSidebar',
Expand Down
7 changes: 7 additions & 0 deletions core/Desktop.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
4 changes: 2 additions & 2 deletions core/SharedWalkthroughApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ import {
getTotalDiffLineCount,
isMarkdownFilePath,
} from './lib/diff.ts';
import { compactPath, fuzzyMatches, sortFiles } from './lib/files.ts';
import { abbreviateHomePath, fuzzyMatches, sortFiles } from './lib/files.ts';
import { isNativeInputTarget } from './lib/keyboard.ts';
import { isGeneratedWalkthroughFile } from './lib/narrative-walkthrough-diff.js';
import {
Expand Down Expand Up @@ -886,7 +886,7 @@ export function ReviewSurface({
: getSourceLabel(snapshot.repository.source);
const rootLabel = repositoryUrl
? snapshot.repository.root
: compactPath(snapshot.repository.root);
: abbreviateHomePath(snapshot.repository.root);
const sourceExternalUrl =
snapshot.repository.source.type === 'pull-request'
? (externalUrl ?? snapshot.repository.source.url)
Expand Down
31 changes: 27 additions & 4 deletions core/__tests__/App-render.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,7 @@ const createCodiffMock = (overrides: Partial<Window['codiff']> = {}): 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) => ({
Expand Down Expand Up @@ -510,6 +511,30 @@ test('top bar source menu opens the review source dialog', async () => {
expect(document.querySelector('[role="menu"]')).toBeNull();
});

test('repository label is plain text and clicking it does nothing', async () => {
const openRepositoryFolder = vi.fn(async () => {});
window.codiff = createCodiffMock({ openRepositoryFolder });

await using app = await renderReact(<App />);
await waitFor(() => expect(app.container.querySelector('.app-shell')).not.toBeNull());

const label = app.container.querySelector<HTMLElement>('.review-top-bar-repository');
if (!label) {
throw new Error('Expected the repository label in the top bar.');
}
expect(label.tagName).toBe('SPAN');
expect(label.textContent).toBe('/repo');
expect(label.closest('.review-top-bar-repository-slot')?.getAttribute('title')).toBe('/repo');
expect(app.container.querySelector('a.review-top-bar-repository')).toBeNull();
expect(app.container.querySelector('button.review-top-bar-repository')).toBeNull();

await act(async () => {
label.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});

expect(openRepositoryFolder).not.toHaveBeenCalled();
});

test('empty repository state fills the review pane for centered layout', async () => {
window.codiff = createCodiffMock();

Expand Down Expand Up @@ -1455,11 +1480,9 @@ 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<HTMLAnchorElement>(
'.review-top-bar-repository',
);
const repositoryLabel = app.container.querySelector<HTMLElement>('.review-top-bar-repository');
const sourceLink = app.container.querySelector<HTMLAnchorElement>('.review-top-bar-source');
expect(repositoryLink?.href).toBe(source.url);
expect(repositoryLabel).not.toBeNull();
expect(sourceLink?.href).toBe(source.url);
expect(sourceLink?.textContent).toBe('PR #12');
expect(sourceLink?.querySelector('svg')).not.toBeNull();
Expand Down
64 changes: 53 additions & 11 deletions core/__tests__/OpenReviewSourceMenu.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,23 +8,34 @@ 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(<OpenReviewSourceMenu onOpen={() => {}} />);
await using view = await renderReact(
<OpenReviewSourceMenu onOpen={() => {}} onOpenFolder={() => {}} />,
);
const trigger = getTrigger(view.container);

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);

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(<OpenReviewSourceMenu onOpen={() => {}} />);
await using view = await renderReact(
<OpenReviewSourceMenu onOpen={() => {}} onOpenFolder={() => {}} />,
);

await click(getTrigger(view.container));

Expand All @@ -36,7 +47,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(<OpenReviewSourceMenu onOpen={onOpen} />);
await using view = await renderReact(
<OpenReviewSourceMenu onOpen={onOpen} onOpenFolder={() => {}} />,
);

await click(getTrigger(view.container));
const commitItem = getMenuItems().find((item) => item.textContent === 'Open Commit');
Expand All @@ -51,27 +64,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(<OpenReviewSourceMenu onOpen={() => {}} />);
await using view = await renderReact(
<OpenReviewSourceMenu onOpen={() => {}} 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(<OpenReviewSourceMenu onOpen={() => {}} />);
await using view = await renderReact(
<OpenReviewSourceMenu onOpen={() => {}} onOpenFolder={() => {}} />,
);
const trigger = getTrigger(view.container);

await click(trigger);
Expand All @@ -81,8 +100,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(
<OpenReviewSourceMenu onOpen={() => {}} 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(<OpenReviewSourceMenu onOpen={() => {}} />);
await using view = await renderReact(
<OpenReviewSourceMenu onOpen={() => {}} onOpenFolder={() => {}} />,
);
const trigger = getTrigger(view.container);

await click(trigger);
Expand All @@ -100,7 +140,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(<OpenReviewSourceMenu onOpen={onOpen} />);
await using view = await renderReact(
<OpenReviewSourceMenu onOpen={onOpen} onOpenFolder={() => {}} />,
);

await click(getTrigger(view.container));
await act(async () => {
Expand Down
57 changes: 57 additions & 0 deletions core/__tests__/SharedWalkthroughApp.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,63 @@ test('review top bar renders its leading control at the far left', async () => {
container.remove();
});

test('share viewer shows the complete repository path when there is no repository link', async () => {
const file = createChangedFile('src/app.ts');
const source = { type: 'working-tree' } as const;
const snapshot = {
branch: 'main',
codiffVersion: '1.4.1',
exportedAt: '2026-06-19T00:00:00.000Z',
files: [file],
kind: 'codiff-walkthrough-share',
preferences: {
codeFontFamily: 'Fira Code',
codeFontSize: 13,
diffStyle: 'split',
showWhitespace: false,
theme: 'system',
wordWrap: false,
},
repository: { root: '/Users/ada/dev/codiff-web', source },
version: 1,
walkthrough: {
agent: 'codex',
chapters: [],
focus: 'Focus on the implementation.',
generatedAt: '2026-06-19T00:00:00.000Z',
kind: 'narrative',
repo: { branch: 'main', root: '/Users/ada/dev/codiff-web' },
source,
support: [],
title: 'Shared walkthrough',
version: 4,
},
} satisfies SharedWalkthroughSnapshot;

const container = document.createElement('div');
document.body.append(container);
let root: Root | null = null;
await using _resource = {
async [Symbol.asyncDispose]() {
if (root) {
await act(async () => root?.unmount());
}
container.remove();
},
};
await act(async () => {
root = createRoot(container);
root.render(<ReviewSurface snapshot={snapshot} title="Review shared walkthrough" />);
});

await waitFor(() => {
expect(container.querySelector('.review-top-bar-repository')).not.toBeNull();
});
expect(container.querySelector('.review-top-bar-repository')?.textContent).toBe(
'~/dev/codiff-web',
);
});

test('shared walkthroughs switch between walkthrough and tree review modes', async () => {
const onDeleteShare = vi.fn();
const confirmDelete = vi.spyOn(window, 'confirm').mockReturnValue(false);
Expand Down
1 change: 1 addition & 0 deletions core/__tests__/app-command-hooks.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading
Loading