diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 56b71523..6abd1135 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -125,6 +125,17 @@ export function App({ const selectedHunkIndex = review.selectedHunkIndex; const moveToAnnotatedFile = review.moveToAnnotatedFile; const moveToAnnotatedHunk = review.moveToAnnotatedHunk; + const toggleMarkedFile = review.toggleMarkedFile; + const clearMarkedFiles = review.clearMarkedFiles; + const hiddenByMarkCount = review.hiddenByMarkCount; + + /** Toggle the focused file's mark via the global `m` shortcut. */ + const toggleSelectedFileMark = useCallback(() => { + if (!selectedFile) { + return; + } + toggleMarkedFile(selectedFile.id); + }, [selectedFile, toggleMarkedFile]); const jumpToFile = useCallback( (fileId: string, nextHunkIndex = 0, options?: { alignFileHeaderTop?: boolean }) => { @@ -472,6 +483,7 @@ export function App({ buildAppMenus({ activeThemeId: activeTheme.id, canRefreshCurrentInput, + clearMarkedFiles, focusFilter, layoutMode, moveToAnnotatedFile, @@ -492,12 +504,14 @@ export function App({ toggleHunkHeaders, toggleLineNumbers, toggleLineWrap, + toggleMarkedFile: toggleSelectedFileMark, toggleSidebar, wrapLines, }), [ activeTheme.id, canRefreshCurrentInput, + clearMarkedFiles, focusFilter, layoutMode, moveToAnnotatedFile, @@ -517,6 +531,7 @@ export function App({ toggleHunkHeaders, toggleLineNumbers, toggleLineWrap, + toggleSelectedFileMark, toggleSidebar, wrapLines, ], @@ -542,6 +557,7 @@ export function App({ activeMenuId, activateCurrentMenuItem, canRefreshCurrentInput, + clearMarkedFiles, closeHelp, closeMenu, cycleTheme, @@ -564,6 +580,7 @@ export function App({ toggleHunkHeaders, toggleLineNumbers, toggleLineWrap, + toggleMarkedFile: toggleSelectedFileMark, toggleSidebar, triggerRefreshCurrentInput, }); @@ -673,6 +690,7 @@ export function App({ <> { + focusFiles(); + toggleMarkedFile(fileId); + }} /> { expect(frame).toContain("- export const alpha = 1;"); await act(async () => { - await setup.mockInput.typeText("m"); + await setup.mockInput.typeText("H"); }); await flush(setup); diff --git a/src/ui/components/chrome/HelpDialog.tsx b/src/ui/components/chrome/HelpDialog.tsx index 1ed1f595..7b60cabe 100644 --- a/src/ui/components/chrome/HelpDialog.tsx +++ b/src/ui/components/chrome/HelpDialog.tsx @@ -44,13 +44,14 @@ export function HelpDialog({ ["1 / 2 / 0", "split / stack / auto"], ["s / t", "sidebar / theme"], ["a", "toggle AI notes"], - ["l / w / m", "lines / wrap / metadata"], + ["l / w / H", "lines / wrap / metadata"], ], }, { title: "Review", items: [ ["/", "focus file filter"], + ["m / M", "mark file / unmark all"], ["Tab", "toggle files/filter focus"], ["F10", "open menus"], [canRefresh ? "r / q" : "q", canRefresh ? "reload / quit" : "quit"], diff --git a/src/ui/components/panes/FileListItem.tsx b/src/ui/components/panes/FileListItem.tsx index f01f409f..0f18d186 100644 --- a/src/ui/components/panes/FileListItem.tsx +++ b/src/ui/components/panes/FileListItem.tsx @@ -1,3 +1,4 @@ +import { TextAttributes } from "@opentui/core"; import { fileRowId } from "../../lib/ids"; import { sidebarEntryStats, type FileGroupEntry, type FileListEntry } from "../../lib/files"; import { fitText, padText } from "../../lib/text"; @@ -70,6 +71,11 @@ export function FileListItem({ const iconWidth = icon ? 2 : 0; // icon + space const statsSectionWidth = statsWidth > 0 ? statsWidth + 1 : 0; const nameWidth = Math.max(1, textWidth - 1 - iconWidth - statsSectionWidth); + // Marked rows render dimmed and crossed out so the user can scan past them but still + // pick one to unmark. + const nameAttributes = entry.marked ? TextAttributes.STRIKETHROUGH : TextAttributes.NONE; + const nameColor = entry.marked ? theme.muted : theme.text; + const iconColor = entry.marked ? theme.muted : color; return ( - {icon && {icon} } - {padText(fitText(entry.name, nameWidth), nameWidth)} + {icon && ( + + {icon}{" "} + + )} + + {padText(fitText(entry.name, nameWidth), nameWidth)} + {statsSectionWidth > 0 && ( 0, render a footer hint reminding the user how many files are hidden by marks. */ + hiddenByMarkCount?: number; scrollRef: RefObject; selectedFileId?: string; textWidth: number; theme: AppTheme; width: number; onSelectFile: (fileId: string) => void; + /** Called when the user clicks a marked sidebar row, so it can be unmarked instead of selected. */ + onUnmarkFile?: (fileId: string) => void; }) { const fileEntries = entries.filter((entry) => entry.kind === "file"); const statsWidth = Math.max(0, ...fileEntries.map((entry) => sidebarEntryStatsWidth(entry))); + const showHiddenFooter = hiddenByMarkCount > 0; + const hiddenFooterText = `${hiddenByMarkCount} hidden`; return ( onSelectFile(entry.id)} + onSelect={() => { + // Clicking a marked row should bring the file back rather than re-select a + // hidden file in the diff stream. + if (entry.marked && onUnmarkFile) { + onUnmarkFile(entry.id); + return; + } + onSelectFile(entry.id); + }} /> ), )} + {showHiddenFooter ? ( + + {fitText(hiddenFooterText, Math.max(1, textWidth))} + + ) : null} ); } diff --git a/src/ui/components/ui-components.test.tsx b/src/ui/components/ui-components.test.tsx index 668a3f15..722cf7b5 100644 --- a/src/ui/components/ui-components.test.tsx +++ b/src/ui/components/ui-components.test.tsx @@ -1615,9 +1615,10 @@ describe("UI components", () => { "1 / 2 / 0 split / stack / auto", "s / t sidebar / theme", "a toggle AI notes", - "l / w / m lines / wrap / metadata", + "l / w / H lines / wrap / metadata", "Review", "/ focus file filter", + "m / M mark file / unmark all", "Tab toggle files/filter focus", "F10 open menus", "r / q reload / quit", diff --git a/src/ui/hooks/useAppKeyboardShortcuts.ts b/src/ui/hooks/useAppKeyboardShortcuts.ts index f2349d9e..f552bc12 100644 --- a/src/ui/hooks/useAppKeyboardShortcuts.ts +++ b/src/ui/hooks/useAppKeyboardShortcuts.ts @@ -23,6 +23,7 @@ export interface UseAppKeyboardShortcutsOptions { activeMenuId: MenuId | null; activateCurrentMenuItem: () => void; canRefreshCurrentInput: boolean; + clearMarkedFiles: () => void; closeHelp: () => void; closeMenu: () => void; cycleTheme: () => void; @@ -45,6 +46,7 @@ export interface UseAppKeyboardShortcutsOptions { toggleHunkHeaders: () => void; toggleLineNumbers: () => void; toggleLineWrap: () => void; + toggleMarkedFile: () => void; toggleSidebar: () => void; triggerRefreshCurrentInput: () => void; } @@ -54,6 +56,7 @@ export function useAppKeyboardShortcuts({ activeMenuId, activateCurrentMenuItem, canRefreshCurrentInput, + clearMarkedFiles, closeHelp, closeMenu, cycleTheme, @@ -76,6 +79,7 @@ export function useAppKeyboardShortcuts({ toggleHunkHeaders, toggleLineNumbers, toggleLineWrap, + toggleMarkedFile, toggleSidebar, triggerRefreshCurrentInput, }: UseAppKeyboardShortcutsOptions) { @@ -361,7 +365,19 @@ export function useAppKeyboardShortcuts({ return; } - if (key.name === "m" || key.sequence === "m") { + // `m` marks the focused file; shift+m clears every active mark. The legacy hunk-metadata + // toggle moved to `H` so the mark binding can stay in the most reachable spot. + if (key.sequence === "M" || (key.name === "m" && key.shift)) { + runAndCloseMenu(clearMarkedFiles); + return; + } + + if (key.name === "m" && !key.shift) { + runAndCloseMenu(toggleMarkedFile); + return; + } + + if (key.sequence === "H" || (key.name === "h" && key.shift)) { runAndCloseMenu(toggleHunkHeaders); return; } diff --git a/src/ui/hooks/useReviewController.test.tsx b/src/ui/hooks/useReviewController.test.tsx index d0885f59..a8d1a798 100644 --- a/src/ui/hooks/useReviewController.test.tsx +++ b/src/ui/hooks/useReviewController.test.tsx @@ -319,6 +319,107 @@ describe("useReviewController", () => { } }); + test("toggleMarkedFile hides the file and clearMarkedFiles restores everything", async () => { + const controllerRef: { current: ReviewController | null } = { current: null }; + const setup = await testRender( + { + controllerRef.current = nextController; + }} + />, + { width: 80, height: 4 }, + ); + + try { + await flush(setup); + expect(expectValue(controllerRef.current).visibleFiles.map((file) => file.path)).toEqual([ + "alpha.ts", + "beta.ts", + ]); + expect(expectValue(controllerRef.current).hiddenByMarkCount).toBe(0); + + await act(async () => { + expectValue(controllerRef.current).toggleMarkedFile("alpha"); + }); + await flush(setup); + + expect(expectValue(controllerRef.current).visibleFiles.map((file) => file.path)).toEqual([ + "beta.ts", + ]); + expect(expectValue(controllerRef.current).hiddenByMarkCount).toBe(1); + expect(expectValue(controllerRef.current).markedFileIds.has("alpha")).toBe(true); + // The sidebar still shows the marked file so the user can unmark it. + expect( + expectValue(controllerRef.current) + .sidebarEntries.filter((entry) => entry.kind === "file") + .map((entry) => entry.id), + ).toEqual(["alpha", "beta"]); + + await act(async () => { + expectValue(controllerRef.current).clearMarkedFiles(); + }); + await flush(setup); + + expect(expectValue(controllerRef.current).visibleFiles.map((file) => file.path)).toEqual([ + "alpha.ts", + "beta.ts", + ]); + expect(expectValue(controllerRef.current).hiddenByMarkCount).toBe(0); + expect(expectValue(controllerRef.current).markedFileIds.size).toBe(0); + } finally { + await act(async () => { + setup.renderer.destroy(); + }); + } + }); + + test("marking the selected file moves selection to the next visible file", async () => { + const controllerRef: { current: ReviewController | null } = { current: null }; + const setup = await testRender( + { + controllerRef.current = nextController; + }} + />, + { width: 80, height: 4 }, + ); + + try { + await flush(setup); + expect(expectValue(controllerRef.current).selectedFile?.path).toBe("alpha.ts"); + + await act(async () => { + expectValue(controllerRef.current).toggleMarkedFile("alpha"); + }); + await flush(setup); + + expect(expectValue(controllerRef.current).selectedFile?.path).toBe("beta.ts"); + expect(expectValue(controllerRef.current).selectedFileId).toBe("beta"); + } finally { + await act(async () => { + setup.renderer.destroy(); + }); + } + }); + test("batch live comments do not mutate state when any target is invalid", async () => { const controllerRef: { current: ReviewController | null } = { current: null }; const setup = await testRender( diff --git a/src/ui/hooks/useReviewController.ts b/src/ui/hooks/useReviewController.ts index cd2508dc..51616c8e 100644 --- a/src/ui/hooks/useReviewController.ts +++ b/src/ui/hooks/useReviewController.ts @@ -55,9 +55,13 @@ export interface ReviewSelectionOptions { export interface ReviewController { allFiles: DiffFile[]; filter: string; + /** How many files the active mark set is hiding from the review stream. */ + hiddenByMarkCount: number; liveCommentCount: number; liveCommentSummaries: SessionLiveCommentSummary[]; liveCommentsByFileId: Record; + /** File ids the user has marked to hide from the review stream. */ + markedFileIds: ReadonlySet; moveToAnnotatedFile: (delta: number) => void; moveToAnnotatedHunk: (delta: number) => void; moveToHunk: (delta: number) => void; @@ -69,6 +73,8 @@ export interface ReviewController { selectedHunk: DiffFile["metadata"]["hunks"][number] | undefined; selectedHunkIndex: number; sidebarEntries: ReviewState["sidebarEntries"]; + /** All non-marked files in review order, before any filter narrowing. */ + unmarkedFiles: DiffFile[]; visibleFiles: DiffFile[]; addLiveComment: ( input: CommentToolInput, @@ -81,17 +87,24 @@ export interface ReviewController { options?: { revealMode?: "none" | "first" }, ) => AppliedCommentBatchResult; clearFilter: () => void; + /** Drop every file mark, restoring all files to the review stream. */ + clearMarkedFiles: () => void; clearLiveComments: (filePath?: string) => ClearedCommentsResult; navigateToLocation: (input: NavigateToHunkToolInput) => NavigatedSelectionResult; removeLiveComment: (commentId: string) => RemovedCommentResult; selectFile: (fileId: string, nextHunkIndex?: number, options?: ReviewSelectionOptions) => void; selectHunk: (fileId: string, hunkIndex: number, options?: ReviewSelectionOptions) => void; setFilter: (value: string) => void; + /** Toggle whether one file is marked as hidden from the review stream. */ + toggleMarkedFile: (fileId: string) => void; } /** Own the shared review stream state used by both the UI and session bridge. */ export function useReviewController({ files }: { files: DiffFile[] }): ReviewController { const [filter, setFilter] = useState(""); + // Marked files are kept in the sidebar but hidden from the review stream so the user can + // narrow what they read without losing the ability to bring a file back. + const [markedFileIds, setMarkedFileIds] = useState>(() => new Set()); const [selectedFileId, setSelectedFileId] = useState(files[0]?.id ?? ""); const [selectedHunkIndex, setSelectedHunkIndex] = useState(0); const [selectedFileTopAlignRequestId, setSelectedFileTopAlignRequestId] = useState(0); @@ -104,7 +117,9 @@ export function useReviewController({ files }: { files: DiffFile[] }): ReviewCon const { allFiles, + unmarkedFiles, visibleFiles, + hiddenByMarkCount, sidebarEntries, selectedFile, selectedHunk, @@ -116,10 +131,11 @@ export function useReviewController({ files }: { files: DiffFile[] }): ReviewCon files, liveCommentsByFileId, filterQuery: deferredFilter, + markedFileIds, selectedFileId, selectedHunkIndex, }), - [deferredFilter, files, liveCommentsByFileId, selectedFileId, selectedHunkIndex], + [deferredFilter, files, liveCommentsByFileId, markedFileIds, selectedFileId, selectedHunkIndex], ); /** Update the selection and reveal intent together so diff scrolling stays explicit. */ @@ -252,6 +268,24 @@ export function useReviewController({ files }: { files: DiffFile[] }): ReviewCon setFilter(""); }, []); + /** Toggle whether one file is marked as hidden from the review stream. */ + const toggleMarkedFile = useCallback((fileId: string) => { + setMarkedFileIds((current) => { + const next = new Set(current); + if (next.has(fileId)) { + next.delete(fileId); + } else { + next.add(fileId); + } + return next; + }); + }, []); + + /** Drop every file mark, restoring all files to the review stream. */ + const clearMarkedFiles = useCallback(() => { + setMarkedFileIds((current) => (current.size === 0 ? current : new Set())); + }, []); + /** Resolve one session-daemon navigation request against the current review state and select it. */ const navigateToLocation = useCallback( (input: NavigateToHunkToolInput): NavigatedSelectionResult => { @@ -486,9 +520,11 @@ export function useReviewController({ files }: { files: DiffFile[] }): ReviewCon return { allFiles, filter, + hiddenByMarkCount, liveCommentCount, liveCommentSummaries, liveCommentsByFileId, + markedFileIds, scrollToNote, selectedFile, selectedFileId, @@ -497,10 +533,12 @@ export function useReviewController({ files }: { files: DiffFile[] }): ReviewCon selectedHunk, selectedHunkIndex, sidebarEntries, + unmarkedFiles, visibleFiles, addLiveComment, addLiveCommentBatch, clearFilter, + clearMarkedFiles, clearLiveComments, moveToAnnotatedFile, moveToAnnotatedHunk, @@ -510,5 +548,6 @@ export function useReviewController({ files }: { files: DiffFile[] }): ReviewCon selectFile, selectHunk, setFilter, + toggleMarkedFile, }; } diff --git a/src/ui/lib/appMenus.ts b/src/ui/lib/appMenus.ts index 058bb33c..f01d16e2 100644 --- a/src/ui/lib/appMenus.ts +++ b/src/ui/lib/appMenus.ts @@ -5,6 +5,7 @@ import { THEMES } from "../themes"; export interface BuildAppMenusOptions { activeThemeId: string; canRefreshCurrentInput: boolean; + clearMarkedFiles: () => void; focusFilter: () => void; layoutMode: LayoutMode; moveToAnnotatedFile: (delta: number) => void; @@ -25,6 +26,7 @@ export interface BuildAppMenusOptions { toggleHunkHeaders: () => void; toggleLineNumbers: () => void; toggleLineWrap: () => void; + toggleMarkedFile: () => void; toggleSidebar: () => void; wrapLines: boolean; } @@ -33,6 +35,7 @@ export interface BuildAppMenusOptions { export function buildAppMenus({ activeThemeId, canRefreshCurrentInput, + clearMarkedFiles, focusFilter, layoutMode, moveToAnnotatedFile, @@ -53,6 +56,7 @@ export function buildAppMenus({ toggleHunkHeaders, toggleLineNumbers, toggleLineWrap, + toggleMarkedFile, toggleSidebar, wrapLines, }: BuildAppMenusOptions): Record { @@ -76,6 +80,19 @@ export function buildAppMenus({ hint: "/", action: focusFilter, }, + { kind: "separator" }, + { + kind: "item", + label: "Mark file", + hint: "m", + action: toggleMarkedFile, + }, + { + kind: "item", + label: "Unmark all", + hint: "M", + action: clearMarkedFiles, + }, ]; if (canRefreshCurrentInput) { @@ -154,7 +171,7 @@ export function buildAppMenus({ { kind: "item", label: "Hunk metadata", - hint: "m", + hint: "H", checked: showHunkHeaders, action: toggleHunkHeaders, }, diff --git a/src/ui/lib/files.test.ts b/src/ui/lib/files.test.ts index 4e2b653f..45f98fd7 100644 --- a/src/ui/lib/files.test.ts +++ b/src/ui/lib/files.test.ts @@ -117,6 +117,47 @@ describe("files helpers", () => { }); }); + test("buildSidebarEntries defaults marked to false when no mark set is given", () => { + const file = createTestDiffFile({ + id: "alpha", + path: "alpha.ts", + before: lines("const alpha = 1;"), + after: lines("const alpha = 2;"), + }); + + const entries = buildSidebarEntries([file]).filter((entry) => entry.kind === "file"); + + expect(entries[0]?.marked).toBe(false); + }); + + test("buildSidebarEntries flags only the matching file ids as marked and preserves order", () => { + const alpha = createTestDiffFile({ + id: "alpha", + path: "alpha.ts", + before: lines("const alpha = 1;"), + after: lines("const alpha = 2;"), + }); + const beta = createTestDiffFile({ + id: "beta", + path: "beta.ts", + before: lines("const beta = 1;"), + after: lines("const beta = 2;"), + }); + const gamma = createTestDiffFile({ + id: "gamma", + path: "gamma.ts", + before: lines("const gamma = 1;"), + after: lines("const gamma = 2;"), + }); + + const entries = buildSidebarEntries([alpha, beta, gamma], { + markedFileIds: new Set(["beta"]), + }).filter((entry) => entry.kind === "file"); + + expect(entries.map((entry) => entry.id)).toEqual(["alpha", "beta", "gamma"]); + expect(entries.map((entry) => entry.marked)).toEqual([false, true, false]); + }); + test("fileLabelParts strips parser-added line endings from rename labels", () => { const renamedAcrossDirectories = { ...createTestDiffFile({ diff --git a/src/ui/lib/files.ts b/src/ui/lib/files.ts index 562860fc..4b75666a 100644 --- a/src/ui/lib/files.ts +++ b/src/ui/lib/files.ts @@ -12,6 +12,8 @@ export interface FileListEntry { deletionsText: string | null; changeType: FileDiffMetadata["type"]; isUntracked: boolean; + /** True when the user has marked this file to hide it from the review stream. */ + marked: boolean; } export interface FileGroupEntry { @@ -116,10 +118,19 @@ export function filterReviewFiles(files: DiffFile[], query: string): DiffFile[] }); } -/** Build the grouped sidebar entries while preserving the review stream order. */ -export function buildSidebarEntries(files: DiffFile[]): SidebarEntry[] { +/** + * Build the grouped sidebar entries while preserving the review stream order. + * + * Marked files stay in the sidebar so the user can unmark them, and each entry + * carries a `marked` flag so the row can render dimmed and crossed out. + */ +export function buildSidebarEntries( + files: DiffFile[], + options?: { markedFileIds?: ReadonlySet }, +): SidebarEntry[] { const entries: SidebarEntry[] = []; let activeGroup: string | null = null; + const markedFileIds = options?.markedFileIds; files.forEach((file, index) => { const path = normalizeDiffPath(file.path) ?? file.path; @@ -148,6 +159,7 @@ export function buildSidebarEntries(files: DiffFile[]): SidebarEntry[] { deletionsText: formatSidebarStat("-", file.stats.deletions), changeType: file.metadata.type, isUntracked: file.isUntracked ?? false, + marked: markedFileIds?.has(file.id) ?? false, }); }); diff --git a/src/ui/lib/reviewState.test.ts b/src/ui/lib/reviewState.test.ts new file mode 100644 index 00000000..821a279b --- /dev/null +++ b/src/ui/lib/reviewState.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, test } from "bun:test"; +import { createTestDiffFile, lines } from "../../../test/helpers/diff-helpers"; +import { buildReviewState } from "./reviewState"; + +/** Build a minimal review-state input with sensible defaults that callers can override. */ +function buildOptions(overrides: Partial[0]> = {}) { + return { + files: [], + liveCommentsByFileId: {}, + filterQuery: "", + markedFileIds: new Set(), + selectedFileId: "", + selectedHunkIndex: 0, + ...overrides, + }; +} + +describe("buildReviewState marked files", () => { + test("hides marked files from the review stream while keeping them in the sidebar", () => { + const alpha = createTestDiffFile({ + id: "alpha", + path: "alpha.ts", + before: lines("export const alpha = 1;"), + after: lines("export const alpha = 2;"), + }); + const beta = createTestDiffFile({ + id: "beta", + path: "beta.ts", + before: lines("export const beta = 1;"), + after: lines("export const beta = 2;"), + }); + + const state = buildReviewState( + buildOptions({ + files: [alpha, beta], + markedFileIds: new Set([alpha.id]), + selectedFileId: beta.id, + }), + ); + + expect(state.visibleFiles.map((file) => file.id)).toEqual(["beta"]); + expect(state.unmarkedFiles.map((file) => file.id)).toEqual(["beta"]); + expect(state.hiddenByMarkCount).toBe(1); + + const fileEntries = state.sidebarEntries.filter((entry) => entry.kind === "file"); + expect(fileEntries.map((entry) => entry.id)).toEqual(["alpha", "beta"]); + expect(fileEntries.map((entry) => entry.marked)).toEqual([true, false]); + expect(state.hunkCursors.every((cursor) => cursor.fileId === "beta")).toBe(true); + }); + + test("a marked file is not visible even when the filter would otherwise match it", () => { + const alpha = createTestDiffFile({ + id: "alpha", + path: "alpha.ts", + before: lines("export const alpha = 1;"), + after: lines("export const alpha = 2;"), + }); + const beta = createTestDiffFile({ + id: "beta", + path: "beta.ts", + before: lines("export const beta = 1;"), + after: lines("export const beta = 2;"), + }); + + const state = buildReviewState( + buildOptions({ + files: [alpha, beta], + filterQuery: "alpha", + markedFileIds: new Set([alpha.id]), + }), + ); + + expect(state.visibleFiles.map((file) => file.id)).toEqual([]); + expect(state.unmarkedFiles.map((file) => file.id)).toEqual(["beta"]); + expect(state.hiddenByMarkCount).toBe(1); + // The sidebar still respects the filter so the user sees a narrow consistent view, but + // the matched alpha entry is flagged as marked so it stays unmarkable. + const fileEntries = state.sidebarEntries.filter((entry) => entry.kind === "file"); + expect(fileEntries.map((entry) => entry.id)).toEqual(["alpha"]); + expect(fileEntries[0]?.marked).toBe(true); + }); + + test("an empty mark set leaves the review stream and sidebar untouched", () => { + const alpha = createTestDiffFile({ + id: "alpha", + path: "alpha.ts", + before: lines("export const alpha = 1;"), + after: lines("export const alpha = 2;"), + }); + + const state = buildReviewState(buildOptions({ files: [alpha], selectedFileId: alpha.id })); + + expect(state.visibleFiles.map((file) => file.id)).toEqual(["alpha"]); + expect(state.hiddenByMarkCount).toBe(0); + expect( + state.sidebarEntries + .filter((entry) => entry.kind === "file") + .every((entry) => entry.marked === false), + ).toBe(true); + }); +}); diff --git a/src/ui/lib/reviewState.ts b/src/ui/lib/reviewState.ts index c5a647ba..6de03720 100644 --- a/src/ui/lib/reviewState.ts +++ b/src/ui/lib/reviewState.ts @@ -30,13 +30,19 @@ export interface BuildReviewStateOptions { files: DiffFile[]; liveCommentsByFileId: Record; filterQuery: string; + /** File ids the user has marked to hide from the review stream. */ + markedFileIds: ReadonlySet; selectedFileId: string; selectedHunkIndex: number; } export interface ReviewState { allFiles: DiffFile[]; + /** All files minus the user-marked ones, before any filter is applied. */ + unmarkedFiles: DiffFile[]; visibleFiles: DiffFile[]; + /** How many files the active mark set is hiding from the review stream. */ + hiddenByMarkCount: number; sidebarEntries: SidebarEntry[]; selectedFile: DiffFile | undefined; selectedHunk: DiffFile["metadata"]["hunks"][number] | undefined; @@ -50,22 +56,37 @@ export interface ReviewNavigationTarget { scrollToNote: boolean; } -/** Build the derived review stream state from files, filter text, and selection. */ +/** + * Build the derived review stream state from files, marks, filter text, and selection. + * + * Visibility is layered explicitly so future features (filter, search) compose on the + * same model: marked files are dropped first, then the filter narrows what remains. + * The sidebar still receives the full file list with `marked` flags so the user can + * unmark a hidden file from the navigation pane. + */ export function buildReviewState({ files, liveCommentsByFileId, filterQuery, + markedFileIds, selectedFileId, selectedHunkIndex, }: BuildReviewStateOptions): ReviewState { const allFiles = mergeFileAnnotationsByFileId(files, liveCommentsByFileId); - const visibleFiles = filterReviewFiles(allFiles, filterQuery); + const unmarkedFiles = allFiles.filter((file) => !markedFileIds.has(file.id)); + const visibleFiles = filterReviewFiles(unmarkedFiles, filterQuery); + const hiddenByMarkCount = allFiles.length - unmarkedFiles.length; + // The sidebar shows marked files alongside unmarked ones (so they can be unmarked), + // but it still respects the active filter so users see a consistent narrow view. + const sidebarFiles = filterReviewFiles(allFiles, filterQuery); const selectedFile = resolveSelectedFile(allFiles, visibleFiles, selectedFileId); return { allFiles, + unmarkedFiles, visibleFiles, - sidebarEntries: buildSidebarEntries(visibleFiles), + hiddenByMarkCount, + sidebarEntries: buildSidebarEntries(sidebarFiles, { markedFileIds }), selectedFile, selectedHunk: selectedFile?.metadata.hunks[selectedHunkIndex], hunkCursors: buildHunkCursors(visibleFiles), diff --git a/src/ui/lib/ui-lib.test.ts b/src/ui/lib/ui-lib.test.ts index 98c77b8f..2ba25946 100644 --- a/src/ui/lib/ui-lib.test.ts +++ b/src/ui/lib/ui-lib.test.ts @@ -123,6 +123,7 @@ describe("ui helpers", () => { const menus = buildAppMenus({ activeThemeId: "graphite", canRefreshCurrentInput: true, + clearMarkedFiles: () => {}, focusFilter: () => {}, layoutMode: "stack", moveToAnnotatedFile: () => {}, @@ -143,6 +144,7 @@ describe("ui helpers", () => { toggleHunkHeaders: () => {}, toggleLineNumbers: () => {}, toggleLineWrap: () => {}, + toggleMarkedFile: () => {}, toggleSidebar: () => {}, wrapLines: true, }); @@ -151,7 +153,14 @@ describe("ui helpers", () => { menus.file .filter((entry): entry is Extract => entry.kind === "item") .map((entry) => entry.label), - ).toEqual(["Toggle files/filter focus", "Focus filter", "Reload", "Quit"]); + ).toEqual([ + "Toggle files/filter focus", + "Focus filter", + "Mark file", + "Unmark all", + "Reload", + "Quit", + ]); expect(menus.file[0]).toMatchObject({ kind: "item", label: "Toggle files/filter focus", diff --git a/test/pty/marked-files-integration.test.ts b/test/pty/marked-files-integration.test.ts new file mode 100644 index 00000000..814b3912 --- /dev/null +++ b/test/pty/marked-files-integration.test.ts @@ -0,0 +1,61 @@ +import { afterEach, describe, expect, setDefaultTimeout, test } from "bun:test"; +import { createPtyHarness } from "./harness"; + +const harness = createPtyHarness(); + +setDefaultTimeout(20_000); + +afterEach(() => { + harness.cleanup(); +}); + +describe("marked files", () => { + test("pressing m hides the focused file and shift+m restores everything", async () => { + const fixture = harness.createSidebarJumpRepoFixture(); + const session = await harness.launchHunk({ + args: ["diff", "--mode", "split"], + cwd: fixture.dir, + cols: 220, + rows: 16, + }); + + try { + const initial = await session.waitForText(/View\s+Navigate\s+Theme\s+Agent\s+Help/, { + timeout: 15_000, + }); + + // The first file (alpha.ts) is focused at startup, and its diff content is visible. + expect(initial).toContain("alphaOnly = true"); + expect(initial).toContain("betaValue = 2"); + expect(initial).not.toMatch(/\d+ hidden/); + + await session.press("m"); + const marked = await harness.waitForSnapshot( + session, + (text) => text.includes("1 hidden") && !text.includes("alphaOnly = true"), + 5_000, + ); + + // The hidden footer surfaces the count, and the diff stream no longer shows alpha.ts. + expect(marked).toContain("1 hidden"); + expect(marked).not.toContain("alphaOnly = true"); + // beta.ts is the next visible file and its diff content is still on screen. + expect(marked).toContain("betaValue = 2"); + // alpha.ts itself stays in the sidebar so the user can unmark it. + expect(marked).toContain("alpha.ts"); + + await session.type("M"); + const restored = await harness.waitForSnapshot( + session, + (text) => text.includes("alphaOnly = true") && !text.match(/\d+ hidden/), + 5_000, + ); + + expect(restored).toContain("alphaOnly = true"); + expect(restored).toContain("betaValue = 2"); + expect(restored).not.toMatch(/\d+ hidden/); + } finally { + session.close(); + } + }); +});