diff --git a/DESIGN.md b/DESIGN.md index 123baa6c..50ec028d 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -204,15 +204,17 @@ such detail into a token. New layout spacing must use the scale. 16px icons, and 1.7px strokes, including menu and dialog triggers. Hover and expanded states use the same neutral surface feedback. On mobile, both rows use 8px action gaps and the same trailing inset so icon columns align. -- Header uses `--panel`, a quiet brand, and the review trigger as its primary +- Header uses `--panel`, a quiet brand, and the review identity as its primary context. Keep the secondary scope heading and muted monospace path inline; hide the path at 768–1011px and both below 768px. Group ghost Refresh/theme actions at the trailing edge; share `--hover` feedback with the trigger. - Projects drawer: the outermost full-height column, `--projects-width` (16rem) on `--panel` with a separator edge. Its header matches `--topbar-height` and holds the brand and a ghost “Hide projects” action (panel-left icon). Below it: - a search row styled like the file filter that opens the review picker, a quiet - semibold “Projects” caption, then repositories ordered by latest arrival. + a “Search projects” field styled like the file filter that filters current + streams in place (Escape clears it; matching repositories expand), a quiet + semibold “Projects” caption with a trailing compact ghost “Browse all reviews” + icon action that opens the review picker, then repositories ordered by latest arrival. Repository rows match tree-row height with a chevron, repository icon, medium name and muted monospace age. Stream rows indent under them: branch icon, name, trailing monospace changed-file count, and a muted metadata line (worktree, @@ -340,7 +342,15 @@ such detail into a token. New layout spacing must use the scale. Comment status badges match the same control height. - Resolved comments collapse to their header by default. A labeled chevron expands the body and actions; reopening restores the expanded open state. -- Comment views default to open comments and offer Open, Resolved, and All filters. +- Comment views default to open comments and offer Open, Resolved, Outdated, and + All filters. The Comments panel lists Current comments, then an Outdated section + for comments whose code changed or left the diff. Outdated comments carry an + "Outdated" badge, a muted placement note, and their original code; they stay + out of code lines and render in the file's lineNumber 0 slot. File headers and + the file tree show muted outdated counts beside accent current counts. A + snapshot-notice-style banner points to open outdated comments. "Keep open" + moves an outdated comment to its file on the latest snapshot, still labeled + outdated. Export uses one primary `Copy review` action plus a menu for broader scopes. - Keep `Save comment` enabled when idle; empty submission focuses the labeled editor and shows an inline error. Disable it while saving. Deleting a saved @@ -363,16 +373,18 @@ such detail into a token. New layout spacing must use the scale. ### Overlays -- Project navigation uses a compact borderless ghost action after the brand - divider, with a semibold repository name, quieter branch, and repository - icon. Give the repository name priority when long context labels truncate. +- The header identifies the open review after the brand divider with static, + non-interactive text at control height: a semibold repository name, quieter + branch, and repository icon. Give the repository name priority when long context labels truncate. Piped reviews use a terminal icon, “Piped”, and collection time; omit repository, branch, worktree, and directory identity. - Use a search icon and shortcut hint, never a dropdown chevron. Click and - Cmd/Ctrl+K open the same searchable “Switch review” dialog. Position it at + Switching lives in the projects drawer; never add a dropdown chevron. + Cmd/Ctrl+K and the drawer's browse action open the searchable “Switch review” + dialog. Position it at `--topbar-height` plus `--space-6` on desktop (64px default), `--space-4` on mobile (16px default). Bound picker width to 35rem and viewport gutters. The - picker owns focus and stays available during context errors. + picker owns focus and stays available during context errors. Choosing a + review moves focus to the drawer's current stream. - Search leads the picker; keep “Switch review” as its screen-reader title. Label global search “Search reviews” and Piped history search “Search Piped”. Use one integrated field (36px input) with close at its right edge and the @@ -432,7 +444,7 @@ Do not add motion for decoration. New animation must honor - Keep scope and desktop layout choice visible. View options and collapse/expand all are adjacent icon buttons with action titles. Put wrap, inline detail, code theme, and narrow layout choice in View options. -- Mobile retains the review trigger's repository/branch or Piped/time context; truncate long +- Mobile retains the review identity's repository/branch or Piped/time context; truncate long labels and hide its shortcut hint. Hide the secondary heading/path and refresh text, preserving accessible labels and theme/sidebar controls. Keep the linked-worktree icon visible when its text label is hidden. diff --git a/README.md b/README.md index e7fd28ca..b35ae5c2 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ go install github.com/flexdinesh/diffx/cmd/diffx@main ## Usage -Capture the selected checkout once in one foreground process: +Capture the selected checkout in one foreground process: ```sh diffx @@ -45,9 +45,10 @@ Collection and ingestion run in-process. The same process serves the web UI, REST and MCP until Ctrl-C. The path defaults to the current directory and resolves to its containing checkout, including linked worktrees. Only that checkout is collected. The browser opens the captured snapshot; later edits and branch switches -do not change it. Run the command again to capture a newer diff. Observations and -comments persist across restarts in the shared local database. Failed collection -never substitutes an empty diff. +do not change it until you explicitly refresh, which captures a new snapshot of +the same checkout. Observations and comments persist across restarts in the +shared local database; each process shows only its own checkout or piped diff. +Failed collection never substitutes an empty diff. Only one local instance runs. Starting another prompts before replacing it; scripts must pass `--replace`. Replacement requests authenticated graceful @@ -256,12 +257,16 @@ the bundled plugins invoke the Go collector. The Projects drawer lists repositories by recent activity, each with its current streams: one per source, checkout, branch and comparison. Each stream shows its newest snapshot only; clean or merged streams and superseded snapshots are hidden. -A dot marks streams with changes you have not opened in this browser. Collapse the -drawer from its header; it stays collapsed across visits. When a newer snapshot +A dot marks streams with changes you have not opened in this browser. Search +projects to filter current streams by repository, branch, worktree, host or agent. +Collapse the drawer from its header; it stays collapsed across visits. The header +names the open review's repository, branch and worktree. In a local checkout +process, **Refresh** (Alt+R) collects the checkout again and opens the new snapshot. +Collapsed files and folders are remembered across reloads in the same tab. When a newer snapshot arrives for the stream you are reviewing, a notice offers **View latest** without replacing the open review. -Search reviews (Ctrl/Cmd+K) to reach older or empty snapshots. Select a +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 @@ -270,8 +275,11 @@ agent-ready XML. Observations are immutable. New content creates another observation; matching submissions associate new provenance with an existing review without changing -its captured diff. Comments and reviewed marks are -stored in SQLite. Display preferences remain in the browser. +its captured diff. Comments and reviewed marks belong to the checkout's stream +(or one piped capture) and are stored in SQLite: later snapshots re-anchor +comments to moved code, flag outdated ones, and keep marks on files whose patch +is unchanged. Deleting one snapshot keeps them; they end with the stream's last +snapshot. Display preferences remain in the browser. ## Piped diffs @@ -322,7 +330,7 @@ WebMCP while the page is open; see [docs/webmcp.md](docs/webmcp.md). Local data lives in `$XDG_STATE_HOME/diffx/state.db`, or `~/.local/state/diffx/state.db`. `--state memory` disables persistence. -Current schema 10 state persists across restarts. Older, newer or unrecognized +Current schema 11 state persists across restarts. Older, newer or unrecognized databases are refused without an automatic reset. To start fresh, stop the server, back up its state directory, and explicitly choose a new database path. Legacy commands and pending producer payloads are retired. diff --git a/apps/web/PRODUCT.md b/apps/web/PRODUCT.md index 9a807b0b..62af7836 100644 --- a/apps/web/PRODUCT.md +++ b/apps/web/PRODUCT.md @@ -20,7 +20,7 @@ A local review workspace that combines a browser-based Git diff with comments th ## Operating Context -- A developer runs `diffx` from a Git repository. The Go server opens the review UI in a browser. +- A developer runs `diffx` from a Git repository. The Go server opens the review UI in a browser, shows only that checkout, and collects it again when the developer refreshes. - Reviews can cover all, staged, or unstaged changes. The file tree, reviewed-file marks, and comments help track progress. - Comments can be copied as agent-ready XML. The same review comments are available to compatible coding agents through MCP and, in supported browsers, WebMCP. - The CLI can also display a piped Git patch as a fixed review. diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index ec3cc157..32135182 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,4 +1,5 @@ import { useReviewState } from "./app-state.tsx"; +import { OutdatedCommentsNotice } from "./CommentsPanel.tsx"; import { DiffWorkspace } from "./DiffWorkspace.tsx"; import { FileExplorerNav, SidebarResizer } from "./FileExplorerNav.tsx"; import { Header } from "./Header.tsx"; @@ -14,6 +15,7 @@ export function App() {
+
diff --git a/apps/web/src/CommentsPanel.tsx b/apps/web/src/CommentsPanel.tsx index d7ccc54f..d7f19532 100644 --- a/apps/web/src/CommentsPanel.tsx +++ b/apps/web/src/CommentsPanel.tsx @@ -1,5 +1,9 @@ import { useState } from "react"; -import { ChevronDownIcon, Trash2Icon } from "lucide-react"; +import { + ChevronDownIcon, + MessageSquareWarningIcon, + Trash2Icon, +} from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { @@ -25,32 +29,33 @@ import { useReviewState, } from "./app-state.tsx"; import { DraftComment, ReviewCommentCard } from "./review.tsx"; +import { capabilityEnabled, useSession } from "./session-context.tsx"; import { - anchored, - commentApplicability, + commentStale, + placedInline, + placementFor, type ReviewComment, } from "./review-model.ts"; -type CommentState = "open" | "resolved" | "stale"; -type CommentFilter = CommentState | "all"; +type CommentFilter = "open" | "resolved" | "outdated" | "all"; const COMMENT_FILTERS: readonly CommentFilter[] = [ "open", "resolved", - "stale", + "outdated", "all", ]; const DELETE_FILTERS: readonly CommentFilter[] = [ "all", "open", "resolved", - "stale", + "outdated", ]; function filterLabel(filter: CommentFilter) { if (filter === "open") return "Open"; if (filter === "resolved") return "Resolved"; - if (filter === "stale") return "Stale"; + if (filter === "outdated") return "Outdated"; return "All"; } @@ -63,15 +68,17 @@ export function CommentsPanel() { const { draft } = useDraft(); const review = useReviewState(); const { navigateComment } = useNavigation(); - const state = (comment: ReviewComment): CommentState => - commentApplicability(comment, repository) === "stale" - ? "stale" - : comment.status; + // Lifecycle filters by status; Outdated selects comments written against earlier code. + const matches = (comment: ReviewComment, value: CommentFilter) => + value === "all" || + (value === "outdated" + ? commentStale(comment, repository) + : comment.status === value); const counts = { - open: review.comments.filter((comment) => state(comment) === "open").length, - resolved: review.comments.filter((comment) => state(comment) === "resolved") + open: review.comments.filter((comment) => matches(comment, "open")).length, + resolved: review.comments.filter((comment) => matches(comment, "resolved")) .length, - stale: review.comments.filter((comment) => state(comment) === "stale") + outdated: review.comments.filter((comment) => matches(comment, "outdated")) .length, }; const activeCount = @@ -82,14 +89,11 @@ export function CommentsPanel() { : deleteSelection === "all" ? review.comments.length : counts[deleteSelection]; - const currentRound = review.rounds.find((round) => round.current); - const earlierRounds = review.rounds.filter((round) => !round.current); - const visible = (comment: ReviewComment) => - filter === "all" || filter === state(comment); - const currentComments = currentRound?.comments.filter(visible) ?? []; - const visibleEarlierComments = earlierRounds.reduce( - (count, round) => count + round.comments.filter(visible).length, - 0, + const currentComments = review.currentComments.filter((comment) => + matches(comment, filter), + ); + const outdatedComments = review.outdatedComments.filter((comment) => + matches(comment, filter), ); return ( {draft && - (!anchored(draft, repository) ? ( + (!placedInline(placementFor(draft, repository).applicability) ? ( ) : ( + + )} +
+ ); +} diff --git a/apps/web/src/ContextIdentity.tsx b/apps/web/src/ContextIdentity.tsx new file mode 100644 index 00000000..dd726d86 --- /dev/null +++ b/apps/web/src/ContextIdentity.tsx @@ -0,0 +1,58 @@ +import type { ApiContext } from "@diffx/api"; +import { + FolderGit2Icon, + GitBranchIcon, + GitForkIcon, + SquareTerminalIcon, +} from "lucide-react"; +import { + contextCheckoutLabel, + contextDetail, + contextDiagnostics, + contextIsLinkedWorktree, + contextIsPiped, + contextTimestampLabel, +} from "./project-picker.ts"; + +/** The open review's repository, branch and worktree; switching lives in the projects drawer. */ +export function ContextIdentity({ current }: { current: ApiContext }) { + const piped = contextIsPiped(current); + const Icon = piped ? SquareTerminalIcon : FolderGit2Icon; + return ( +
+
+ ); +} diff --git a/apps/web/src/DiffWorkspace.tsx b/apps/web/src/DiffWorkspace.tsx index 6ec080af..cdb4ab1d 100644 --- a/apps/web/src/DiffWorkspace.tsx +++ b/apps/web/src/DiffWorkspace.tsx @@ -30,7 +30,14 @@ import { DiffToolbar } from "./DiffToolbar.tsx"; import { DiffComparison } from "./DiffComparison.tsx"; import { themesFor } from "./display-options.ts"; import { DraftComment, ReviewCommentCard } from "./review.tsx"; -import type { CommentAnnotation } from "./review-model.ts"; +import { + commentInFile, + commentStale, + placedInline, + placedOutdated, + placementFor, + type CommentAnnotation, +} from "./review-model.ts"; import { ServerMetrics } from "./ServerMetrics.tsx"; import { WorkspaceFeedback } from "./WorkspaceFeedback.tsx"; import { capabilityEnabled, useSession } from "./session-context.tsx"; @@ -156,7 +163,7 @@ function itemVersions() { // Keep Pierre's annotation versions and event callbacks local to its renderer. export function DiffWorkspace() { - const { diff, repository, piped, mode } = useDiffSource(); + const { diff, repository, piped, mode, refreshEnabled } = useDiffSource(); const { id: contextId, capabilities } = useSession(); const { theme, diffTheme, lineDiffType, layout, wrap } = useAppearance(); const { collapsed, setCollapsed } = useDiffCollapse(); @@ -176,7 +183,6 @@ export function DiffWorkspace() { const review = useReviewState(); const commentsEnabled = capabilityEnabled(capabilities.review.comments); const contentsEnabled = capabilityEnabled(capabilities.files.contents); - const refreshEnabled = capabilityEnabled(capabilities.diff.refresh); const lineMetric = useRef(null); const viewport = useRef(null); const [scrollbarHeight, setScrollbarHeight] = useState(0); @@ -235,19 +241,33 @@ export function DiffWorkspace() { ) .filter( (comment) => - comment.path === file.path && + commentInFile(comment, file) && comment.scope === mode && - comment.fingerprint === file.fingerprint && comment.id !== draft?.id, ) - .map((comment) => ({ - side: - comment.target === "file" && file.status === "D" - ? "deletions" - : comment.side, - lineNumber: comment.end, - metadata: { kind: "saved", comment }, - })); + .flatMap((comment): DiffLineAnnotation[] => { + const placement = placementFor(comment, repository); + const fileSide = file.status === "D" ? "deletions" : "additions"; + // Inline comments sit on their (possibly moved) lines; outdated + // ones stay at file level with their original code. + if (placedInline(placement.applicability)) + return [ + { + side: comment.target === "file" ? fileSide : placement.side, + lineNumber: comment.target === "file" ? 0 : placement.end, + metadata: { kind: "saved", comment }, + }, + ]; + return placedOutdated(placement.applicability) + ? [ + { + side: fileSide, + lineNumber: 0, + metadata: { kind: "saved", comment }, + }, + ] + : []; + }); if ( draft && draft.path === file.path && @@ -297,6 +317,7 @@ export function DiffWorkspace() { files, diff.items, review.comments, + repository, draft, mode, collapsed, @@ -554,8 +575,17 @@ export function DiffWorkspace() { const currentDraft = actions.current.draft; const ranges = (context.item.annotations ?? []).flatMap( (annotation): CommentLineRange[] => { - if (annotation.metadata.kind === "saved") - return [annotation.metadata.comment]; + if (annotation.metadata.kind === "saved") { + const { comment } = annotation.metadata; + const placement = placementFor( + comment, + actions.current.repository, + ); + return comment.target !== "file" && + placedInline(placement.applicability) + ? [placement] + : []; + } return currentDraft && currentDraft.path === context.item.id ? [currentDraft] : []; @@ -710,8 +740,25 @@ export function DiffWorkspace() { )} renderHeaderMetadata={(item) => { const file = allFiles.find((file) => file.path === item.id); + const outdated = + commentsEnabled && file + ? review.comments.filter( + (comment) => + comment.scope === mode && + commentInFile(comment, file) && + commentStale(comment, repository), + ).length + : 0; return file ? (
+ {outdated > 0 && ( + + {outdated} outdated + + )} - +

- -

Projects

+ setQuery(event.target.value)} + onKeyDown={(event) => { + if (event.key !== "Escape" || !query) return; + event.stopPropagation(); + setQuery(""); + }} + /> +
+

+ {searching + ? `${groups.reduce((sum, group) => sum + group.streams.length, 0)} matching streams` + : ""} +

+
+

Projects

+ +
{groups.length ? (
    @@ -193,7 +224,7 @@ export function ProjectsNav() { toggleGroup(group.id)} currentStream={currentStream} now={now} @@ -201,6 +232,10 @@ export function ProjectsNav() { /> ))}
+ ) : searching && allGroups.length ? ( +
+

No matching projects

+
) : ( { save("review-tools-collapsed", String(toolsCollapsed)); }, [toolsCollapsed]); + // Unresolved exports include open outdated comments, tagged stale. const unresolved = review.comments.filter( - (comment) => - comment.status === "open" && - commentApplicability(comment, repository) !== "stale", + (comment) => comment.status === "open", ).length; return (
diff --git a/apps/web/src/WorkspaceFeedback.tsx b/apps/web/src/WorkspaceFeedback.tsx index 8f178a13..3552621c 100644 --- a/apps/web/src/WorkspaceFeedback.tsx +++ b/apps/web/src/WorkspaceFeedback.tsx @@ -1,11 +1,21 @@ -import { useReviewState, useReviewedFilesState } from "./app-state.tsx"; +import { + useDiffSource, + useReviewState, + useReviewedFilesState, +} from "./app-state.tsx"; import { Button } from "@/components/ui/button"; +import { useCatalog } from "./session-context.tsx"; export function WorkspaceFeedback() { const { feedback } = useReviewState(); const { error, retry } = useReviewedFilesState(); + const { collectError } = useCatalog(); + const { refresh } = useDiffSource(); return ( - ); } diff --git a/apps/web/src/app-state.tsx b/apps/web/src/app-state.tsx index 39395ad1..1fc6903d 100644 --- a/apps/web/src/app-state.tsx +++ b/apps/web/src/app-state.tsx @@ -10,7 +10,12 @@ import { type Dispatch, type SetStateAction, } from "react"; -import { capabilityEnabled, useSession } from "./session-context.tsx"; +import { saveSessionPaths, sessionPaths } from "./preferences.ts"; +import { + capabilityEnabled, + useCatalog, + useSession, +} from "./session-context.tsx"; import { useSidebarState } from "./sidebar-context.tsx"; import { useDiff } from "./use-diff.ts"; import { useFileNavigation } from "./use-file-navigation.ts"; @@ -30,6 +35,9 @@ interface DiffSource { mode: DiffMode; piped: boolean; changeMode: (mode: DiffMode) => void; + /** Collects the checkout again when the process supports it, else reloads. */ + refresh: () => void; + refreshEnabled: boolean; } const SourceContext = createContext(null); const NavigationContext = createContext>>; } | null>(null); +const collapsedKey = (contextId: string, mode: DiffMode) => + `collapsed-files:${contextId}:${mode}`; + function WorkspaceProvider({ children }: { children: ReactNode }) { const { id: contextId, capabilities } = useSession(); - const refreshEnabled = capabilityEnabled(capabilities.diff.refresh); + const { collect } = useCatalog(); + const refreshEnabled = + collect !== null || capabilityEnabled(capabilities.diff.refresh); const commentsEnabled = capabilityEnabled(capabilities.review.comments); const scopes = capabilities.diff.scopes.values; const [mode, setMode] = useState("all"); const [draft, setDraft] = useState(null); - const [collapsed, setCollapsed] = useState(new Set()); + const [collapsed, setCollapsed] = useState(() => + sessionPaths(collapsedKey(contextId, "all")), + ); + useEffect(() => { + saveSessionPaths(collapsedKey(contextId, mode), collapsed); + }, [contextId, mode, collapsed]); const diff = useDiff(contextId, mode); const repository = diff.repository; const review = useReview( @@ -65,23 +83,33 @@ function WorkspaceProvider({ children }: { children: ReactNode }) { setDraft, commentsEnabled, ); + const { refresh: reload } = diff; + const { setFeedback } = review; + const refresh = useCallback(() => { + if (!collect) return reload(); + void collect().then((result) => { + if (result === "unchanged") + setFeedback("No new changes since this snapshot."); + }); + }, [collect, reload, setFeedback]); const reviewed = useReviewedFiles(contextId, repository, mode, setCollapsed); const { show: showSidebar, closeMobile } = useSidebarState(); const changeMode = useCallback( (value: DiffMode) => { if (value === mode || !scopes.includes(value)) return; setMode(value); - setCollapsed(new Set()); + setCollapsed(sessionPaths(collapsedKey(contextId, value))); }, - [mode, scopes], + [contextId, mode, scopes], ); const navigation = useFileNavigation({ + contextId, repository, mode, scopes, busy: diff.busy, refreshEnabled, - refresh: diff.refresh, + refresh, changeMode, setCollapsed, setFeedback: review.setFeedback, @@ -93,8 +121,16 @@ function WorkspaceProvider({ children }: { children: ReactNode }) { document.title = `diffx · ${piped ? "Piped" : "Git diff"}`; }, [piped]); const source = useMemo( - () => ({ diff, repository, mode, piped, changeMode }), - [diff, repository, mode, piped, changeMode], + () => ({ + diff, + repository, + mode, + piped, + changeMode, + refresh, + refreshEnabled, + }), + [diff, repository, mode, piped, changeMode, refresh, refreshEnabled], ); const draftState = useMemo(() => ({ draft, setDraft }), [draft]); const collapse = useMemo(() => ({ collapsed, setCollapsed }), [collapsed]); diff --git a/apps/web/src/file-navigation.tsx b/apps/web/src/file-navigation.tsx index b2008591..d0558d02 100644 --- a/apps/web/src/file-navigation.tsx +++ b/apps/web/src/file-navigation.tsx @@ -84,13 +84,18 @@ function StatusBadge({ ); } +export interface CommentCount { + current: number; + outdated: number; +} + interface Props { files: ChangedFile[]; selected: string; closed: Set; filtering: boolean; isReviewed: (file: ChangedFile) => boolean; - commentCounts: Map; + commentCounts: Map; stagingMetadata: boolean; onToggle: (path: string) => void; onSelect: (path: string) => void; @@ -181,7 +186,10 @@ export function FileNavigation({ const file = node.file; const viewed = isReviewed(file); const decoration = gitDecoration(file); - const count = commentCounts.get(file.path) ?? 0; + const { current: count, outdated } = commentCounts.get(file.path) ?? { + current: 0, + outdated: 0, + }; return (
  • diff --git a/apps/web/src/preferences.ts b/apps/web/src/preferences.ts index 6b78f189..bcb9b220 100644 --- a/apps/web/src/preferences.ts +++ b/apps/web/src/preferences.ts @@ -41,3 +41,26 @@ export function savedReviews(key: string): Map { return new Map(); } } + +// Per-tab view state survives reloads but not closing the tab. +export function sessionPaths(key: string): Set { + try { + const data: unknown = JSON.parse(sessionStorage.getItem(key) ?? "[]"); + return new Set( + Array.isArray(data) + ? data.filter((value) => typeof value === "string") + : [], + ); + } catch { + return new Set(); + } +} + +export function saveSessionPaths(key: string, paths: ReadonlySet) { + try { + if (paths.size) sessionStorage.setItem(key, JSON.stringify([...paths])); + else sessionStorage.removeItem(key); + } catch { + // Storage is optional. + } +} diff --git a/apps/web/src/project-nav.ts b/apps/web/src/project-nav.ts index 13439734..ebb35454 100644 --- a/apps/web/src/project-nav.ts +++ b/apps/web/src/project-nav.ts @@ -1,8 +1,13 @@ import type { ApiContext } from "@diffx/api"; -import { contextHasChanges, contextIsPiped } from "./project-picker.ts"; +import { + contextHasChanges, + contextIsPiped, + contextMatches, + searchWords, +} from "./project-picker.ts"; // The projects drawer shows only current work: each stream's head with changes. -// Stale snapshots stay reachable through the review picker. +// Stale snapshots stay reachable through the review picker (Cmd/Ctrl+K). export type ProjectStream = { id: string; @@ -78,6 +83,20 @@ export function projectGroups(heads: ApiContext[], seen: SeenStreams | null) { ); } +/** Keeps streams whose metadata matches every search word, preserving order. */ +export function searchProjectGroups(groups: ProjectGroup[], query: string) { + const words = searchWords(query); + if (!words.length) return groups; + return groups + .map((group) => ({ + ...group, + streams: group.streams.filter((stream) => + contextMatches(stream.context, words).every((score) => score > 0), + ), + })) + .filter((group) => group.streams.length); +} + function streamHost(context: ApiContext) { return context.observation?.hostname ?? context.observation?.sourceId ?? ""; } diff --git a/apps/web/src/project-picker.ts b/apps/web/src/project-picker.ts index 84b1a632..9386b5c1 100644 --- a/apps/web/src/project-picker.ts +++ b/apps/web/src/project-picker.ts @@ -137,48 +137,57 @@ function pickerRecency(context: ApiContext) { return context.lastSubmittedAt; } +export function searchWords(query: string) { + return query.toLowerCase().trim().split(/\s+/).filter(Boolean); +} + +/** Each word's best match across searchable metadata; 0 means no match. */ +export function contextMatches(context: ApiContext, words: string[]) { + const values = ( + contextIsPiped(context) + ? [ + "Piped", + contextTimestampLabel(context), + new Date(contextTimestamp(context)).toLocaleString(), + context.id, + ] + : [ + context.name, + context.branch, + context.worktreeName, + context.root, + context.observation?.repositoryName, + context.observation?.remoteUrl, + context.observation?.hostname, + context.observation?.sourceId, + context.observation?.runId, + context.observation?.agent, + context.observation?.trigger, + ...(context.sessions?.flatMap((session) => [ + session.sourceId, + session.harness, + session.id, + session.name, + ]) ?? []), + ] + ) + .filter((value) => typeof value === "string") + .map((value) => value.toLowerCase()); + return words.map((word) => + Math.max(...values.map((value) => matchScore(value, word))), + ); +} + export function pickerResults( contexts: ApiContext[], query: string, filters: PickerFilters = defaultPickerFilters, ) { - const words = query.toLowerCase().trim().split(/\s+/).filter(Boolean); + const words = searchWords(query); return contexts .filter((context) => matchesFilters(context, filters)) .map((context) => { - const values = ( - contextIsPiped(context) - ? [ - "Piped", - contextTimestampLabel(context), - new Date(contextTimestamp(context)).toLocaleString(), - context.id, - ] - : [ - context.name, - context.branch, - context.worktreeName, - context.root, - context.observation?.repositoryName, - context.observation?.remoteUrl, - context.observation?.hostname, - context.observation?.sourceId, - context.observation?.runId, - context.observation?.agent, - context.observation?.trigger, - ...(context.sessions?.flatMap((session) => [ - session.sourceId, - session.harness, - session.id, - session.name, - ]) ?? []), - ] - ) - .filter((value) => typeof value === "string") - .map((value) => value.toLowerCase()); - const matches = words.map((word) => - Math.max(...values.map((value) => matchScore(value, word))), - ); + const matches = contextMatches(context, words); return { context, matches, diff --git a/apps/web/src/review-model.ts b/apps/web/src/review-model.ts index a28a6457..0834fd6c 100644 --- a/apps/web/src/review-model.ts +++ b/apps/web/src/review-model.ts @@ -1,20 +1,12 @@ -import type { ReviewComment } from "@diffx/shared"; +import type { ChangedFile, RepositoryDiff, ReviewComment } from "@diffx/shared"; export { - anchored, - commentApplicability, commentContext, formatComments, lineContext, parseComments, - reviewRounds, -} from "@diffx/shared"; -export type { - CommentApplicability, - ReviewComment, - ReviewOrigin, - ReviewRound, } from "@diffx/shared"; +export type { ReviewComment, ReviewOrigin } from "@diffx/shared"; export type CommentAnnotation = | { kind: "saved"; comment: ReviewComment } @@ -27,3 +19,60 @@ export function createCommentId( "", ); } + +export type CommentPlacement = NonNullable; +export type Applicability = CommentPlacement["applicability"]; + +/** + * Server placement in the viewed snapshot. Comments saved since the last + * comment read lack one; they belong to the viewed snapshot by construction, + * and anything else unplaced is conservatively outdated. + */ +export function placementFor( + comment: ReviewComment, + repository: RepositoryDiff | null, +): CommentPlacement { + if (comment.placement) return comment.placement; + return { + applicability: + repository === null + ? "unknown" + : comment.scope !== repository.mode + ? "other-scope" + : comment.versionId === repository.versionId + ? "anchored" + : "outdated", + side: comment.side, + start: comment.start, + end: comment.end, + }; +} + +/** Anchored and moved comments render on their lines. */ +export function placedInline(applicability: Applicability) { + return applicability === "anchored" || applicability === "moved"; +} + +/** Outdated comments stay visible at file level, never on lines. */ +export function placedOutdated(applicability: Applicability) { + return applicability === "outdated" || applicability === "unchanged"; +} + +/** Written against earlier code: outdated now, or kept open from an outdated state. */ +export function commentStale( + comment: ReviewComment, + repository: RepositoryDiff | null, +) { + return ( + comment.carriedFrom !== undefined || + placedOutdated(placementFor(comment, repository).applicability) + ); +} + +/** Stream comments follow renames: the viewed file may carry the comment's path as its old path. */ +export function commentInFile( + comment: ReviewComment, + file: Pick, +) { + return comment.path === file.path || comment.path === file.oldPath; +} diff --git a/apps/web/src/review.css b/apps/web/src/review.css index c2e56089..4ba2e94d 100644 --- a/apps/web/src/review.css +++ b/apps/web/src/review.css @@ -222,6 +222,15 @@ font: var(--text-xs) var(--font-sans); white-space: nowrap; } +.file-comment-count.outdated, +.file-outdated-count { + color: var(--muted); +} +.file-outdated-count { + flex-shrink: 0; + font-size: var(--text-xs); + white-space: nowrap; +} .file-comment-count svg { width: var(--icon-sm); height: var(--icon-sm); @@ -426,12 +435,15 @@ .review-round h2 { margin: 0 0 var(--space-2); } -.earlier-review { +.outdated-review { border-top: 1px solid var(--border); padding-top: var(--space-3); } -.earlier-review summary { - cursor: pointer; +.review-round-hint { + margin: 0 0 var(--space-2); + color: var(--muted); + font-size: var(--text-xs); + line-height: var(--leading-copy); } .comment-card, .comment-editor { @@ -652,7 +664,8 @@ border-radius: var(--radius-sm); } #comments-panel .comment-actions { - flex-wrap: nowrap; + /* Outdated comments add Keep open; wrap instead of overflowing the card. */ + flex-wrap: wrap; min-height: var(--control-height); gap: var(--space-1); padding: var(--space-0-5) var(--space-1-5); diff --git a/apps/web/src/review.tsx b/apps/web/src/review.tsx index e93674c5..07f55c94 100644 --- a/apps/web/src/review.tsx +++ b/apps/web/src/review.tsx @@ -5,6 +5,7 @@ import { CircleDotIcon, CopyIcon, PencilIcon, + PinIcon, RotateCcwIcon, SendIcon, Trash2Icon, @@ -38,11 +39,49 @@ import { } from "./app-state.tsx"; import { useAppearance } from "./appearance-context.tsx"; import { CopyPathButton } from "./CopyPathButton.tsx"; -import { commentApplicability, type ReviewComment } from "./review-model.ts"; +import { + commentInFile, + commentStale, + placedInline, + placedOutdated, + placementFor, + type CommentPlacement, + type ReviewComment, +} from "./review-model.ts"; + +function lines(start: number, end: number) { + return `${start}${end !== start ? `–${end}` : ""}`; +} -function location(comment: ReviewComment) { +// Inline comments name their current lines; outdated ones their original lines. +function location(comment: ReviewComment, placement: CommentPlacement) { if (comment.target === "file") return comment.path; - return `${comment.path}:${comment.start}${comment.end !== comment.start ? `–${comment.end}` : ""}`; + return placedInline(placement.applicability) + ? `${comment.path}:${lines(placement.start, placement.end)}` + : `${comment.path}:${lines(comment.start, comment.end)}`; +} + +function draftLocation(draft: ReviewComment) { + return draft.target === "file" + ? draft.path + : `${draft.path}:${lines(draft.start, draft.end)}`; +} + +function placementNote(comment: ReviewComment, placement: CommentPlacement) { + if (comment.carriedFrom) + return `Outdated — kept open from line ${lines(comment.carriedFrom.start, comment.carriedFrom.end)} of earlier code`; + switch (placement.applicability) { + case "moved": + return `Moved from line ${lines(comment.start, comment.end)}`; + case "outdated": + return "Outdated — the commented code changed or left the diff"; + case "unchanged": + return "Outdated — the commented code is no longer changed"; + case "unknown": + return "Original code preserved"; + default: + return ""; + } } // Both the diff annotations and sidebar edit the same page-level draft. @@ -91,6 +130,7 @@ export function ReviewCommentCard({ if (review.edit(comment)) navigateComment(comment); }} onToggle={review.toggle} + onCarry={review.carry} onDelete={review.remove} onCopy={sidebar ? review.copySidebarComment : review.copyComment} /> @@ -144,7 +184,7 @@ export function CommentEditor({ - {location(draft)} + {draftLocation(draft)} void; onEdit: (comment: ReviewComment) => void; onToggle: (comment: ReviewComment) => void; + onCarry: (comment: ReviewComment) => void; onDelete: (comment: ReviewComment) => void; onCopy: (comment: ReviewComment) => void; }) { const resolved = comment.status === "resolved"; - const applicability = commentApplicability(comment, repository); - const stale = applicability === "stale"; + const placement = placementFor(comment, repository); + const { applicability } = placement; + const stale = commentStale(comment, repository); + const note = + applicability === "other-scope" && sidebar + ? `From ${comment.scope} changes` + : placementNote(comment, placement); + // Keep open re-targets the comment to its file in the viewed snapshot. + const carryable = + !resolved && + !comment.carriedFrom && + placedOutdated(applicability) && + !!repository?.files.some((file) => commentInFile(comment, file)); const [expanded, setExpanded] = useState(!resolved && !stale); const [confirmDelete, setConfirmDelete] = useState(false); const contentId = useId(); @@ -262,7 +315,7 @@ export function CommentCard({ size="icon-xs" aria-controls={contentId} aria-expanded={expanded} - aria-label={`${expanded ? "Collapse" : "Expand"} ${stale ? "stale " : resolved ? "resolved " : ""}comment at ${location(comment)}`} + aria-label={`${expanded ? "Collapse" : "Expand"} ${stale ? "outdated " : resolved ? "resolved " : ""}comment at ${location(comment, placement)}`} onClick={() => setExpanded((current) => !current)} > @@ -314,28 +369,12 @@ export function CommentCard({ >

    {comment.body}

    - {sidebar && ( - <> - {applicability !== "anchored" && ( -

    - {applicability === "other-scope" - ? `From ${comment.scope} changes` - : applicability === "stale" - ? comment.target === "file" - ? "Stale — file changed" - : "Stale — file changed; original code preserved" - : comment.target === "file" - ? "File comment from an earlier diff" - : "Original code preserved"} -

    - )} - {comment.target !== "file" && ( -
    - Code context -
    {comment.code}
    -
    - )} - + {note &&

    {note}

    } + {(sidebar || stale) && comment.code && ( +
    + {stale ? "Original code" : "Code context"} +
    {comment.code}
    +
    )}
    @@ -344,7 +383,7 @@ export function CommentCard({ className="copy-comment" variant="ghost" size={sidebar ? "icon-xs" : "icon-sm"} - aria-label={`Copy comment at ${location(comment)}`} + aria-label={`Copy comment at ${location(comment, placement)}`} title="Copy comment" onClick={() => onCopy(comment)} > @@ -375,6 +414,18 @@ export function CommentCard({ )} {resolved ? "Reopen" : "Resolve"} + {carryable && ( + + )}