diff --git a/DESIGN.md b/DESIGN.md index f9c57181..3b85e109 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -424,6 +424,12 @@ such detail into a token. New layout spacing must use the scale. each tint with Pierre's existing background instead of replacing it. - Navigation uses a quiet background, aligned text/icons, and a clear current item. Reviewed files retain readable text and a check; do not fade the entire row. +- “Hide reviewed files” is an icon toggle beside the file filter, matching the + field's height and `--border-muted` boundary; pressed uses the accent toggle + state. `useFileNavigation` owns one visible file set for the tree, diff list + and Alt+J/K. Marking the file in view hides it at once and moves selection to + its next visible neighbor. Empty results say “All files reviewed” with a + “Show reviewed files” action; comment navigation to a hidden file shows them. - Navigating from the file list briefly pulses the destination file header with the accent for about one second. Reduced-motion mode uses static feedback that remains visible long enough to identify the destination without animation. diff --git a/apps/web/src/DiffWorkspace.tsx b/apps/web/src/DiffWorkspace.tsx index 247fcffe..751f0275 100644 --- a/apps/web/src/DiffWorkspace.tsx +++ b/apps/web/src/DiffWorkspace.tsx @@ -158,6 +158,8 @@ export function DiffWorkspace() { selectVisibleFile, filter, setFilter, + matchingFiles, + setHideReviewed, navigationTarget, commentNavigationTarget, navigateComment, @@ -629,24 +631,32 @@ export function DiffWorkspace() { ); const loadingChanges = !repository && !diff.notice; + // Matching files with none visible were all hidden as reviewed. + const allReviewedHidden = matchingFiles.length > 0; const emptyTitle = !repository ? "Cannot load changes" - : filter - ? "No matching files" - : piped - ? "No file changes in this input" - : mode === "staged" - ? "Nothing staged" - : "No changes in this snapshot"; + : allReviewedHidden + ? filter + ? "All matching files reviewed" + : "All files reviewed" + : filter + ? "No matching files" + : piped + ? "No file changes in this input" + : mode === "staged" + ? "Nothing staged" + : "No changes in this snapshot"; const emptyDescription = !repository ? "Check that the server is running, then refresh." - : filter - ? "Try a different filename or clear the filter." - : piped - ? "Run a command that emits a Git patch, then pipe it into diffx." - : mode === "staged" - ? "No staged changes were collected in this snapshot." - : "No changes in this snapshot. Collect another snapshot with diffx or diffx sync."; + : allReviewedHidden + ? "Reviewed files are hidden." + : filter + ? "Try a different filename or clear the filter." + : piped + ? "Run a command that emits a Git patch, then pipe it into diffx." + : mode === "staged" + ? "No staged changes were collected in this snapshot." + : "No changes in this snapshot. Collect another snapshot with diffx or diffx sync."; return (
@@ -809,14 +819,24 @@ export function DiffWorkspace() { <>

{emptyTitle}

{emptyDescription}

- {!!filter && ( + {allReviewedHidden ? ( + ) : ( + !!filter && ( + + ) )} )} diff --git a/apps/web/src/FileExplorerNav.tsx b/apps/web/src/FileExplorerNav.tsx index 039bfd35..bbf16ab7 100644 --- a/apps/web/src/FileExplorerNav.tsx +++ b/apps/web/src/FileExplorerNav.tsx @@ -1,8 +1,9 @@ import { useMemo } from "react"; -import { XIcon } from "lucide-react"; +import { EyeOffIcon, XIcon } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Toggle } from "@/components/ui/toggle"; import { togglePath, useDiffSource, @@ -26,6 +27,9 @@ export function FileExplorerNav() { setTab, filter, setFilter, + hideReviewed, + setHideReviewed, + matchingFiles, files, activePath, closed, @@ -162,27 +166,39 @@ export function FileExplorerNav() { -
- - { - setFilter(event.target.value); - setFilteredClosed(new Set()); - }} - /> - Alt+/ +
+
+ + { + setFilter(event.target.value); + setFilteredClosed(new Set()); + }} + /> + Alt+/ +
+ +

- {filter + {filter || hideReviewed ? `${files.length} of ${allFiles.length} changed files shown` : ""}

@@ -190,7 +206,13 @@ export function FileExplorerNav() { files={files} selected={activePath} closed={filter ? filteredClosed : closed} - filtering={!!filter} + emptyMessage={ + !allFiles.length + ? "No changed files" + : matchingFiles.length + ? "All files reviewed" + : "No matching files" + } isReviewed={isReviewed} commentCounts={commentCounts} stagingMetadata={stagingMetadataEnabled} diff --git a/apps/web/src/app-state.tsx b/apps/web/src/app-state.tsx index ca35bb06..8d8fe354 100644 --- a/apps/web/src/app-state.tsx +++ b/apps/web/src/app-state.tsx @@ -116,6 +116,7 @@ function WorkspaceProvider({ children }: { children: ReactNode }) { refresh, changeMode, setCollapsed, + isReviewed: reviewed.isReviewed, setFeedback: review.setFeedback, showSidebar, closeMobile, diff --git a/apps/web/src/file-navigation.tsx b/apps/web/src/file-navigation.tsx index d0558d02..2afe5651 100644 --- a/apps/web/src/file-navigation.tsx +++ b/apps/web/src/file-navigation.tsx @@ -93,7 +93,7 @@ interface Props { files: ChangedFile[]; selected: string; closed: Set; - filtering: boolean; + emptyMessage: string; isReviewed: (file: ChangedFile) => boolean; commentCounts: Map; stagingMetadata: boolean; @@ -105,7 +105,7 @@ export function FileNavigation({ files, selected, closed, - filtering, + emptyMessage, isReviewed, commentCounts, stagingMetadata, @@ -238,9 +238,7 @@ export function FileNavigation({ {files.length ? ( renderNodes(buildFileTree(files), 0) ) : ( -

- {filtering ? "No matching files" : "No changed files"} -

+

{emptyMessage}

)} ); diff --git a/apps/web/src/preferences.ts b/apps/web/src/preferences.ts index bcb9b220..5a6f459e 100644 --- a/apps/web/src/preferences.ts +++ b/apps/web/src/preferences.ts @@ -56,6 +56,23 @@ export function sessionPaths(key: string): Set { } } +export function sessionFlag(key: string): boolean { + try { + return sessionStorage.getItem(key) === "true"; + } catch { + return false; + } +} + +export function saveSessionFlag(key: string, value: boolean) { + try { + if (value) sessionStorage.setItem(key, "true"); + else sessionStorage.removeItem(key); + } catch { + // Storage is optional. + } +} + export function saveSessionPaths(key: string, paths: ReadonlySet) { try { if (paths.size) sessionStorage.setItem(key, JSON.stringify([...paths])); diff --git a/apps/web/src/style.css b/apps/web/src/style.css index 25c27bae..aacf447b 100644 --- a/apps/web/src/style.css +++ b/apps/web/src/style.css @@ -555,11 +555,17 @@ kbd { padding: 1px var(--space-1); color: var(--muted); } +.file-filter-row { + display: flex; + gap: var(--space-1-5); + margin: 0 var(--space-3) var(--space-2); +} .search-box { display: flex; + flex: 1; + min-width: 0; align-items: center; gap: var(--space-1-5); - margin: 0 var(--space-3) var(--space-2); height: var(--control-small); min-height: var(--control-small); padding: 0 var(--space-1-5); @@ -583,6 +589,25 @@ kbd { outline: var(--focus-ring); outline-offset: var(--focus-offset); } +/* Pairs with the filter field; pressed state uses the shared accent toggle. */ +.hide-reviewed-toggle { + flex: none; + width: var(--control-small); + min-width: var(--control-small); + height: var(--control-small); + padding: 0; + border: 1px solid var(--border-muted); + background: var(--bg); + color: var(--muted); +} +.hide-reviewed-toggle:hover { + background: var(--hover); + color: var(--fg); +} +.hide-reviewed-toggle[aria-pressed="true"] { + background: var(--accent-bg); + color: var(--accent); +} #file-tree { padding: 0 var(--space-1) var(--space-4); flex: 1; diff --git a/apps/web/src/use-file-navigation.ts b/apps/web/src/use-file-navigation.ts index d6f96a70..bf344e97 100644 --- a/apps/web/src/use-file-navigation.ts +++ b/apps/web/src/use-file-navigation.ts @@ -1,5 +1,10 @@ import type { CodeViewHandle } from "@pierre/diffs/react"; -import type { DiffMode, RepositoryDiff, ReviewComment } from "@diffx/shared"; +import type { + ChangedFile, + DiffMode, + RepositoryDiff, + ReviewComment, +} from "@diffx/shared"; import { useCallback, useEffect, @@ -10,7 +15,12 @@ import { type SetStateAction, } from "react"; import { ancestorPaths, filesInTreeOrder } from "./file-tree.ts"; -import { saveSessionPaths, sessionPaths } from "./preferences.ts"; +import { + saveSessionFlag, + saveSessionPaths, + sessionFlag, + sessionPaths, +} from "./preferences.ts"; import { commentInFile, placedInline, @@ -47,6 +57,7 @@ interface NavigationOptions { refresh: () => void; changeMode: (mode: DiffMode) => void; setCollapsed: Dispatch>>; + isReviewed: (file: ChangedFile) => boolean; setFeedback: (message: string) => void; showSidebar: () => void; closeMobile: () => void; @@ -62,6 +73,7 @@ export function useFileNavigation({ refresh, changeMode, setCollapsed, + isReviewed, setFeedback, showSidebar, closeMobile, @@ -86,12 +98,19 @@ export function useFileNavigation({ saveSessionPaths(closedKey, closed); }, [closedKey, closed]); const [filteredClosed, setFilteredClosed] = useState(new Set()); + const hideReviewedKey = `hide-reviewed:${stream}`; + const [hideReviewed, setHideReviewed] = useState(() => + sessionFlag(hideReviewedKey), + ); + useEffect(() => { + saveSessionFlag(hideReviewedKey, hideReviewed); + }, [hideReviewedKey, hideReviewed]); const [pendingComment, setPendingComment] = useState( null, ); const viewer = useRef>(null); const search = useRef(null); - const files = useMemo( + const matchingFiles = useMemo( () => filesInTreeOrder( repository?.files.filter((file) => @@ -100,9 +119,26 @@ export function useFileNavigation({ ), [repository, filter], ); - const activePath = files.some((file) => file.path === selected) - ? selected - : (files[0]?.path ?? ""); + // The tree and diff list share one visible set. + const files = useMemo( + () => + hideReviewed + ? matchingFiles.filter((file) => !isReviewed(file)) + : matchingFiles, + [matchingFiles, hideReviewed, isReviewed], + ); + // A selected file hidden as reviewed hands off to its next visible neighbor. + const activePath = useMemo(() => { + if (files.some((file) => file.path === selected)) return selected; + const index = matchingFiles.findIndex((file) => file.path === selected); + const visible = (file: ChangedFile) => files.includes(file); + const neighbor = + index < 0 + ? undefined + : (matchingFiles.slice(index + 1).find(visible) ?? + matchingFiles.slice(0, index).findLast(visible)); + return (neighbor ?? files[0])?.path ?? ""; + }, [files, matchingFiles, selected]); const selectVisibleFile = useCallback((path: string) => { setSelected(path); const expandParents = (previous: Set) => { @@ -122,6 +158,25 @@ export function useFileNavigation({ ?.scrollIntoView({ block: "nearest" }); }); }, []); + // Hiding the file in view continues the review at its neighbor. + useEffect(() => { + if ( + !hideReviewed || + !activePath || + activePath === selected || + !matchingFiles.some((file) => file.path === selected) + ) + return; + selectVisibleFile(activePath); + requestAnimationFrame(() => { + viewer.current?.scrollTo({ + type: "item", + id: activePath, + align: "start", + behavior: "instant", + }); + }); + }, [hideReviewed, activePath, selected, matchingFiles, selectVisibleFile]); const revealFile = useCallback( (path: string) => { selectVisibleFile(path); @@ -183,6 +238,10 @@ export function useFileNavigation({ setFilter(""); return; } + if (!files.some((file) => file.path === target.path)) { + setHideReviewed(false); + return; + } revealFile(target.path); const frame = requestAnimationFrame(() => { if (!target.range) { @@ -220,7 +279,7 @@ export function useFileNavigation({ } setTab("comments"); setPendingComment(null); - }, [pendingComment, busy, repository, filter, revealFile]); + }, [pendingComment, busy, repository, filter, files, revealFile]); const showOutdatedComments = useCallback(() => { setTab("comments"); @@ -287,6 +346,9 @@ export function useFileNavigation({ setTab, filter, setFilter, + hideReviewed, + setHideReviewed, + matchingFiles, files, activePath, navigationTarget, @@ -305,6 +367,8 @@ export function useFileNavigation({ [ tab, filter, + hideReviewed, + matchingFiles, files, activePath, navigationTarget, diff --git a/apps/web/test/browser/app.spec.ts b/apps/web/test/browser/app.spec.ts index d538af3c..dd67a806 100644 --- a/apps/web/test/browser/app.spec.ts +++ b/apps/web/test/browser/app.spec.ts @@ -822,6 +822,75 @@ test("reset reviewed clears file review marks and disables when empty", async ({ await expect(reset).toBeDisabled(); }); +test("hide reviewed files filters the tree and diff together", async ({ + page, +}) => { + await page.setViewportSize({ width: 1280, height: 4_000 }); + await page.goto("/"); + await showDiff(page); + + const hide = page.getByRole("button", { name: "Hide reviewed files" }); + const rows = page.locator("#file-tree .file-row"); + const headers = page.locator("#viewer diffs-container"); + const status = page.locator("#file-panel [role=status]"); + await expect(hide).toHaveAttribute("aria-pressed", "false"); + await expect(rows).toHaveCount(12); + await expect(headers).toHaveCount(12); + + await page + .getByRole("button", { name: "Mark config/diffx.json reviewed" }) + .click(); + await expect(page.locator("#review-count")).toHaveText("1 of 12 reviewed"); + await hide.click(); + await expect(hide).toHaveAttribute("aria-pressed", "true"); + await expect(rows).toHaveCount(11); + await expect(headers).toHaveCount(11); + await expect( + page.locator('#file-tree [data-path="config/diffx.json"]'), + ).toHaveCount(0); + await expect(status).toHaveText("11 of 12 changed files shown"); + + // Marking the selected file hides it at once and selects its next neighbor. + await page.locator('#file-tree [data-path="docs/legacy.md"]').click(); + await page + .getByRole("button", { name: "Mark docs/legacy.md reviewed" }) + .click(); + await expect(rows).toHaveCount(10); + await expect(headers).toHaveCount(10); + await expect(page.locator("#file-tree [aria-current=true]")).toHaveAttribute( + "data-path", + "scripts/release.sh", + ); + + // Text filtering composes with hidden reviewed files. + await page.getByRole("searchbox", { name: "Filter files" }).fill("docs"); + await expect(rows).toHaveCount(0); + await expect(page.locator("#file-tree")).toHaveText("All files reviewed"); + await expect(page.locator("#empty h2")).toHaveText( + "All matching files reviewed", + ); + await page.getByRole("searchbox", { name: "Filter files" }).fill(""); + + await page.reload(); + await showDiff(page); + await expect(hide).toHaveAttribute("aria-pressed", "true"); + await expect(rows).toHaveCount(10); + + const unreviewed = page.locator("#viewer .review-button[aria-pressed=false]"); + for (let remaining = 10; remaining > 0; remaining -= 1) { + await unreviewed.first().click(); + await expect(rows).toHaveCount(remaining - 1); + } + await expect(page.locator("#review-count")).toHaveText("12 of 12 reviewed"); + await expect(rows).toHaveCount(0); + await expect(page.locator("#file-tree")).toHaveText("All files reviewed"); + await expect(page.locator("#empty h2")).toHaveText("All files reviewed"); + await page.getByRole("button", { name: "Show reviewed files" }).click(); + await expect(hide).toHaveAttribute("aria-pressed", "false"); + await expect(rows).toHaveCount(12); + await expect(headers).toHaveCount(12); +}); + test("mobile keeps review progress visible beside the diff", async ({ page, }) => { diff --git a/docs-website/src/pages/reviewing-changes.md b/docs-website/src/pages/reviewing-changes.md index 8eda44c1..c8c0e40d 100644 --- a/docs-website/src/pages/reviewing-changes.md +++ b/docs-website/src/pages/reviewing-changes.md @@ -22,7 +22,8 @@ Browse all reviews (Ctrl/Cmd+K) to reach older or empty snapshots. Select a repository, then an observation. Search/filter stored metadata to find a branch, worktree, host, source or agent run. Switch between its collected all, staged and unstaged scopes. Use the file tree to navigate, mark files as reviewed -and comment on lines. **Copy unresolved** and **Copy all** export comments as +and comment on lines. **Hide reviewed files**, beside the file filter, removes +reviewed files from both the tree and the diff for that tab. **Copy unresolved** and **Copy all** export comments as agent-ready XML. Observations are immutable. New content creates another observation; matching diff --git a/docs/development.md b/docs/development.md index 20d64bd8..7bfea6a5 100644 --- a/docs/development.md +++ b/docs/development.md @@ -260,8 +260,8 @@ account before mounting review state; distributed login and `/settings` work wit zero observations. Account changes dispose workspace requests and event streams. Local mode bypasses account flows. Consumers subscribe through domain hooks for diff source, navigation, draft, review, reviewed files, and collapse state. Collapsed diff -files (per stream and scope) and closed tree folders (per stream) persist in -`sessionStorage`, so reloads and newer snapshots keep them within a tab; piped +files (per stream and scope), closed tree folders and Hide reviewed files (per +stream) persist in `sessionStorage`, so reloads and newer snapshots keep them within a tab; piped snapshots key by context. One draft persists across scopes. Context selection uses the Repos view and the popup picker (Cmd/Ctrl+K or the Repos “Search reviews” magnifier). Repos and Diff share the panel