From 75fa59f9e2b6502ec118bb40c854f43c5f15b1c7 Mon Sep 17 00:00:00 2001 From: Dean Stratakos <29683763+dastratakos@users.noreply.github.com> Date: Sat, 17 Oct 2026 01:40:58 +0000 Subject: [PATCH 1/6] feat(desktop): Files changed lists every file, with a filter, collapse and expand all, and stage-cli's keys Co-authored-by: luke-agent[bot] <339870089+luke-agent[bot]@users.noreply.github.com> --- apps/desktop/bundle-budget.json | 2 +- .../src/renderer/diff-view/contract.ts | 82 ++++- apps/desktop/src/renderer/diff-view/index.ts | 254 +++++++++++--- apps/desktop/src/renderer/styles/tasks.css | 66 +++- .../renderer/tasks/agent-changes-tab.test.ts | 331 +++++++++++++++--- .../src/renderer/tasks/agent-changes-tab.tsx | 239 +++++++++---- .../tasks/agent-pull-request-tab.test.ts | 32 +- .../src/renderer/tasks/changes-list.tsx | 264 ++++++++++++++ .../src/renderer/tasks/changes-model.test.ts | 83 ++++- .../src/renderer/tasks/changes-model.ts | 104 +++++- apps/desktop/src/renderer/tasks/diff-list.tsx | 183 ++++++++++ apps/desktop/src/renderer/tasks/file-diff.tsx | 112 +----- .../src/renderer/tasks/file-tree-pane.tsx | 12 +- packages/settings/src/shortcuts.ts | 2 + 14 files changed, 1469 insertions(+), 297 deletions(-) create mode 100644 apps/desktop/src/renderer/tasks/changes-list.tsx create mode 100644 apps/desktop/src/renderer/tasks/diff-list.tsx diff --git a/apps/desktop/bundle-budget.json b/apps/desktop/bundle-budget.json index b6b4fbb2bd..de348eff2e 100644 --- a/apps/desktop/bundle-budget.json +++ b/apps/desktop/bundle-budget.json @@ -12,7 +12,7 @@ "renderer/pcm-tee-worklet.js": 413, "renderer/avatar-session.js": 149485, "renderer/diagram.js": 195519, - "renderer/diff-view.js": 142443, + "renderer/diff-view.js": 178690, "renderer/factory-world.js": 161750, "renderer/factory-world-dev.js": 1745, "renderer/factory-world-station.js": 31732, diff --git a/apps/desktop/src/renderer/diff-view/contract.ts b/apps/desktop/src/renderer/diff-view/contract.ts index f6c64a61c2..4ab4f572b4 100644 --- a/apps/desktop/src/renderer/diff-view/contract.ts +++ b/apps/desktop/src/renderer/diff-view/contract.ts @@ -9,7 +9,7 @@ import type { } from "@luke/settings/diff-view"; /** - * contract.ts -- what the Files changed view and the diff bundle say to each other: the one mount call, and the handle the view redraws the diff through. + * contract.ts -- what the Files changed view and the diff bundle say to each other: a mount for one patch and one for a list of files, and the handles the view redraws them through. * * A changed file's patch is drawn by Pierre's diffs (`@pierre/diffs`), with * Shiki colouring it, which is too large for the bundle every window parses, @@ -45,6 +45,24 @@ export interface DiffLook { readonly ligatures: boolean; } +/** Whether two looks draw a diff alike: every setting the same, each appearance's theme among them. */ +export function sameLook(a: DiffLook, b: DiffLook): boolean { + return ( + a.layout === b.layout && + a.indicators === b.indicators && + a.inlineDiff === b.inlineDiff && + a.backgrounds === b.backgrounds && + a.lineNumbers === b.lineNumbers && + a.wrap === b.wrap && + a.themes.dark === b.themes.dark && + a.themes.light === b.themes.light && + a.font === b.font && + a.fontSize === b.fontSize && + a.lineHeight === b.lineHeight && + a.ligatures === b.ligatures + ); +} + /** Which side of a diff a selected line is counted on: the old side's, a removed line's, or the new side's. */ export const DIFF_SELECTION_SIDE = { DELETIONS: "deletions", @@ -66,6 +84,22 @@ export interface DiffAnnotation { readonly line: number; } +/** Whether two runs of notes stand under the same lines by the same keys. */ +export function sameNotes(a: readonly DiffAnnotation[], b: readonly DiffAnnotation[]): boolean { + return ( + a.length === b.length && + a.every((note, at) => { + const other = b[at]; + return ( + other !== undefined && + note.key === other.key && + note.side === other.side && + note.line === other.line + ); + }) + ); +} + /** What the view hands the bundle: one file's patch, headed as git writes one, how to draw it, the lines lit in it, if any, and whether it is an excerpt. */ export interface DiffViewProps { readonly patch: string; @@ -73,10 +107,42 @@ export interface DiffViewProps { readonly selected?: DiffSelection | undefined; /** Whether the patch is an excerpt, a few lines of a file as a review thread's, drawn with nothing said of the lines around them. */ readonly excerpt?: boolean | undefined; - /** The notes drawn under lines, each by its key, whose element `renderAnnotation` hands over; none for none. */ - readonly annotations?: readonly DiffAnnotation[] | undefined; - /** The element a note's key is drawn in, kept by the view and filled by it, which the bundle stands under its line. */ - readonly renderAnnotation?: ((key: string) => HTMLElement) | undefined; +} + +/** + * The line a note stands on to stand above a file's first hunk rather than + * under a line: Pierre's own place for a note on the whole file, which is + * drawn under the file's header whether or not the file has any hunks. + */ +export const FILE_NOTE_LINE = 0; + +/** One file of a list: its path, which keys it; its patch, headed as git writes one, with no hunks where it has none to draw; whether only its header is drawn; and its notes. */ +export interface DiffListFile { + readonly path: string; + readonly patch: string; + readonly collapsed: boolean; + readonly annotations: readonly DiffAnnotation[]; +} + +/** + * What the view hands the bundle for a list of files: the files in order, + * how to draw them, the element each file's header is drawn in and each + * note, both kept by the view and filled by it, and the door told which file + * stands at the top of the list as it scrolls. + */ +export interface DiffListProps { + readonly files: readonly DiffListFile[]; + readonly look: DiffLook; + readonly renderHeader: (path: string) => HTMLElement; + readonly renderAnnotation: (path: string, key: string) => HTMLElement; + readonly onTopFile: (path: string) => void; +} + +/** The list as mounted: drawn again for new props, scrolled to a file's header, and taken down. */ +export interface DiffListHandle { + readonly update: (props: DiffListProps) => void; + readonly reveal: (path: string) => void; + readonly unmount: () => void; } /** The diff as mounted: drawn again for new props, and taken down. */ @@ -92,4 +158,10 @@ export interface DiffViewModule { * find reads (`../find-matches.ts`'s `FIND_SHADOW_ATTRIBUTE`). */ readonly mount: (host: HTMLElement, props: DiffViewProps) => DiffViewHandle; + /** + * Draws the files under `host`, which is the list's scroller, each in a + * shadow root of its own the tab's find reads, only those near the view + * drawn at all. + */ + readonly mountList: (host: HTMLElement, props: DiffListProps) => DiffListHandle; } diff --git a/apps/desktop/src/renderer/diff-view/index.ts b/apps/desktop/src/renderer/diff-view/index.ts index dc7e5dbf60..3a653f59a7 100644 --- a/apps/desktop/src/renderer/diff-view/index.ts +++ b/apps/desktop/src/renderer/diff-view/index.ts @@ -1,12 +1,28 @@ import { DIFF_FONT, DIFF_LAYOUT, DIFF_LINE_HEIGHT } from "@luke/settings/diff-view"; import { + CodeView, + type CodeViewDiffItem, + type CodeViewOptions, type DiffLineAnnotation, + type FileContents, + type FileDiffMetadata, type FileDiffOptions, getSingularPatch, FileDiff as PierreFileDiff, } from "@pierre/diffs"; import { FIND_SHADOW_ATTRIBUTE } from "../find-matches"; -import type { DiffLook, DiffViewHandle, DiffViewModule, DiffViewProps } from "./contract"; +import { + type DiffAnnotation, + type DiffListFile, + type DiffListHandle, + type DiffListProps, + type DiffLook, + type DiffViewHandle, + type DiffViewModule, + type DiffViewProps, + sameLook, + sameNotes, +} from "./contract"; import { adoptShadowStyles } from "./inline-styles"; /** @@ -42,10 +58,19 @@ import { adoptShadowStyles } from "./inline-styles"; * Between two hunks of a file stands how many unchanged lines they leave * out, with no offer to show them, since the service reads the patch alone * and those lines are not at hand; an excerpt, a review thread's lines, - * says nothing of the lines around it. A note under a line, a review - * thread's, is the element the view keeps for its key, which Pierre stands - * in the host's light DOM, slotted under its line, so the panel's own - * stylesheets draw it and the view fills it as it does any of its own. + * says nothing of the lines around it. + * + * Files changed draws its pull request's files as one list (`mountList`), + * through Pierre's own `CodeView`: every file under a header that sticks to + * the top while its lines scroll past, only the files near the view drawn at + * all, each in a shadow root of its own that the find reads and the same + * sheet dresses, and a collapsed file drawn as its header alone. The header + * is the element the view keeps for the file, which Pierre stands in the + * file's light DOM, so the panel's own stylesheets draw it and the view + * fills it as it does any of its own; a note under a line, a review + * thread's, is the same, by its key, and a note on the whole file stands + * under its header (`FILE_NOTE_LINE`). The view is told which file stands at + * the top as the list scrolls, and scrolls it to a file's header. */ /** Each font's stack, ending in the window's own code type should the Mac lack it. */ @@ -83,6 +108,7 @@ const FIND_SKIPPED = "[data-gutter]"; const LOOK_CSS = ` @layer unsafe { :host { + display: block; --diffs-addition-color-override: var(--diff-added-mark); --diffs-deletion-color-override: var(--diff-removed-mark); --diffs-bg-addition-override: var(--diff-added-ground); @@ -93,14 +119,8 @@ const LOOK_CSS = ` --diffs-bg-deletion-emphasis-override: var(--diff-removed-word); --diffs-modified-color-override: var(--code-pointed-edge); } - /* The theme's ground runs to the foot of the box a diff is drawn in, however few its lines. */ - :host { - display: block; - min-height: 100%; - } pre { box-sizing: border-box; - min-height: 100%; -webkit-user-select: text; user-select: text; } @@ -122,34 +142,40 @@ const LOOK_CSS = ` } `; -let lookSheet: CSSStyleSheet | undefined; - -/** The look's sheet, made once and adopted by every diff's shadow root. */ -function look(): CSSStyleSheet { - if (lookSheet !== undefined) return lookSheet; - lookSheet = new CSSStyleSheet(); - lookSheet.replaceSync(LOOK_CSS); - return lookSheet; +/** One diff alone: the theme's ground runs to the foot of the box it is drawn in, however few its lines. */ +const FILL_CSS = ` +@layer unsafe { + :host { + min-height: 100%; + } + pre { + min-height: 100%; + } } +`; -/** A note's place as Pierre takes it, its key carried as the note's own. */ -function annotationsOf(props: DiffViewProps): DiffLineAnnotation[] { - return (props.annotations ?? []).map((note) => ({ - side: note.side, - lineNumber: note.line, - metadata: note.key, - })); +/** How tall a list's file header stands, in pixels, which Pierre reserves for it before it is measured: the view's `.diff-list-head` (`styles/tasks.css`). */ +const LIST_HEADER_HEIGHT = 34; + +/** The room below a list's last file, in pixels, so its last lines can be scrolled up off the foot of the pane. */ +const LIST_FOOT_ROOM = 48; + +let sheets: { look: CSSStyleSheet; fill: CSSStyleSheet } | undefined; + +/** The look's sheets, made once and adopted by every diff's shadow root: the look every diff in a list wears, and the same with the fill one diff alone wears. */ +function lookSheets(): { look: CSSStyleSheet; fill: CSSStyleSheet } { + if (sheets !== undefined) return sheets; + const look = new CSSStyleSheet(); + look.replaceSync(LOOK_CSS); + const fill = new CSSStyleSheet(); + fill.replaceSync(`${LOOK_CSS}${FILL_CSS}`); + sheets = { look, fill }; + return sheets; } -/** - * Pierre's options for what is drawn and how: a whole file's patch, or an - * excerpt with nothing said of the lines around it, and each note drawn in - * the element the view keeps for its key. - */ -function optionsOf(props: DiffViewProps): FileDiffOptions { - const { look: drawn, renderAnnotation } = props; - const excerpt = props.excerpt === true; - const options: FileDiffOptions = { +/** The options a single diff and a list share: the theme, the layout, and how a changed line is marked. */ +function sharedOptions(drawn: DiffLook) { + return { theme: drawn.themes, themeType: "system", preferredHighlighter: "shiki-js", @@ -160,14 +186,16 @@ function optionsOf(props: DiffViewProps): FileDiffOptions { disableBackground: !drawn.backgrounds, disableLineNumbers: !drawn.lineNumbers, overflow: drawn.wrap ? "wrap" : "scroll", - hunkSeparators: excerpt ? "simple" : "line-info-basic", + } as const satisfies FileDiffOptions; +} + +/** Pierre's options for one patch: a whole file's, or an excerpt with nothing said of the lines around it. */ +function optionsOf(props: DiffViewProps): FileDiffOptions { + return { + ...sharedOptions(props.look), + hunkSeparators: props.excerpt === true ? "simple" : "line-info-basic", disableFileHeader: true, }; - if (renderAnnotation !== undefined) { - options.renderAnnotation = (note: DiffLineAnnotation) => - renderAnnotation(note.metadata); - } - return options; } /** Sets the diff's type on its host, through the CSSOM, whose `--diffs-*` properties the shadow root inherits over its own. */ @@ -185,12 +213,12 @@ function mount(host: HTMLElement, props: DiffViewProps): DiffViewHandle { container.setAttribute(FIND_SHADOW_ATTRIBUTE, FIND_SKIPPED); host.append(container); const root = container.shadowRoot ?? container.attachShadow({ mode: "open" }); - const release = adoptShadowStyles(root, look()); + const release = adoptShadowStyles(root, lookSheets().fill); typeset(container, props.look); const view = new PierreFileDiff(optionsOf(props)); let patch = props.patch; let fileDiff = getSingularPatch(patch); - view.render({ fileDiff, fileContainer: container, lineAnnotations: annotationsOf(props) }); + view.render({ fileDiff, fileContainer: container }); view.setSelectedLines(props.selected ?? null); return { update: (next) => { @@ -200,12 +228,7 @@ function mount(host: HTMLElement, props: DiffViewProps): DiffViewHandle { } typeset(container, next.look); view.setOptions(optionsOf(next)); - view.render({ - fileDiff, - fileContainer: container, - forceRender: true, - lineAnnotations: annotationsOf(next), - }); + view.render({ fileDiff, fileContainer: container, forceRender: true }); view.setSelectedLines(next.selected ?? null); }, unmount: () => { @@ -216,4 +239,135 @@ function mount(host: HTMLElement, props: DiffViewProps): DiffViewHandle { }; } -export const diffView: DiffViewModule = { mount }; +/** A note's place as Pierre takes it, its key carried as the note's own. */ +function annotationsOf(notes: readonly DiffAnnotation[]): DiffLineAnnotation[] { + return notes.map((note) => ({ side: note.side, lineNumber: note.line, metadata: note.key })); +} + +/** A file's patch read, or, where it does not read as one, the file with no hunks, so one unreadable patch costs its own lines and not the list. */ +function patchOf(file: DiffListFile): FileDiffMetadata { + try { + return getSingularPatch(file.patch); + } catch { + const path = file.path; + return getSingularPatch(`diff --git a/${path} b/${path}\n--- a/${path}\n+++ b/${path}\n`); + } +} + +/** A file as the list last drew it: what it was handed, and the item Pierre was given for it. */ +interface DrawnFile { + readonly file: DiffListFile; + readonly item: CodeViewDiffItem; +} + +/** + * Pierre's options for a list: the look, each file's header and notes drawn + * in the elements the view keeps for them, read off the latest props, and + * each file's shadow root dressed as it is first drawn. + */ +function listOptions( + drawn: DiffLook, + latest: () => DiffListProps, + dress: (element: HTMLElement) => void, +): CodeViewOptions { + return { + ...sharedOptions(drawn), + hunkSeparators: "line-info-basic", + stickyHeaders: true, + itemMetrics: { + lineHeight: Number(drawn.fontSize) * Number(LINE_HEIGHT_RATIO[drawn.lineHeight]), + diffHeaderHeight: LIST_HEADER_HEIGHT, + }, + layout: { paddingTop: 0, paddingBottom: LIST_FOOT_ROOM, gap: 0 }, + renderCustomHeader: ( + _drawn: FileDiffMetadata | FileContents, + context: { item: { id: string } }, + ) => latest().renderHeader(context.item.id), + renderAnnotation: (note: { metadata: string }, context: { item: { id: string } }) => + latest().renderAnnotation(context.item.id, note.metadata), + onPostRender: (node: HTMLElement) => dress(node), + }; +} + +/** + * Draws a list of files, as Files changed shows its pull request's. Note + * that a file's patch is read again only when it changes, and its item + * handed anew, its version counted up, only when what it draws changes, + * because Pierre draws again whatever it is handed anew. + */ +function mountList(host: HTMLElement, props: DiffListProps): DiffListHandle { + let latest = props; + const drawnFiles = new Map(); + const releases: Array<() => void> = []; + const dressed = new WeakSet(); + // Note that Pierre keeps a file's element for another file once the first + // scrolls away, so each element is dressed once, the first time it is drawn. + const dress = (element: HTMLElement) => { + if (dressed.has(element) || element.shadowRoot === null) return; + dressed.add(element); + element.setAttribute(FIND_SHADOW_ATTRIBUTE, FIND_SKIPPED); + releases.push(adoptShadowStyles(element.shadowRoot, lookSheets().look)); + }; + const itemsOf = (files: readonly DiffListFile[]) => + files.map((file) => { + const held = drawnFiles.get(file.path); + if ( + held !== undefined && + held.file.patch === file.patch && + held.file.collapsed === file.collapsed && + sameNotes(held.file.annotations, file.annotations) + ) { + return held.item; + } + const item: CodeViewDiffItem = { + id: file.path, + type: "diff", + fileDiff: held?.file.patch === file.patch ? held.item.fileDiff : patchOf(file), + collapsed: file.collapsed, + annotations: annotationsOf(file.annotations), + version: (held?.item.version ?? 0) + 1, + }; + drawnFiles.set(file.path, { file, item }); + return item; + }); + typeset(host, props.look); + const view = new CodeView(listOptions(props.look, () => latest, dress)); + view.setup(host); + view.setItems(itemsOf(props.files)); + view.render(true); + // The file whose header stands at the top of the list, told when it changes. + let top: string | undefined; + const tellTop = (scrollTop: number) => { + const standing = latest.files.findLast( + (file) => (view.getTopForItem(file.path) ?? Number.POSITIVE_INFINITY) <= scrollTop + 1, + ); + const path = standing?.path ?? latest.files[0]?.path; + if (path === undefined || path === top) return; + top = path; + latest.onTopFile(path); + }; + const unsubscribe = view.subscribeToScroll((scrollTop) => tellTop(scrollTop)); + tellTop(0); + return { + update: (next) => { + const was = latest; + latest = next; + typeset(host, next.look); + if (!sameLook(next.look, was.look)) + view.setOptions(listOptions(next.look, () => latest, dress)); + view.setItems(itemsOf(next.files)); + view.render(); + tellTop(host.scrollTop); + }, + reveal: (path) => + view.scrollTo({ type: "item", id: path, align: "start", behavior: "smooth-auto" }), + unmount: () => { + unsubscribe(); + view.cleanUp(); + for (const release of releases) release(); + host.replaceChildren(); + }, + }; +} + +export const diffView: DiffViewModule = { mount, mountList }; diff --git a/apps/desktop/src/renderer/styles/tasks.css b/apps/desktop/src/renderer/styles/tasks.css index 6f5e199722..41e0173243 100644 --- a/apps/desktop/src/renderer/styles/tasks.css +++ b/apps/desktop/src/renderer/styles/tasks.css @@ -1452,14 +1452,74 @@ border-top: 1px solid var(--luke-color-border); } -/* A Files changed diff (file-diff.tsx): Pierre draws it in a shadow root - of its own, in the panel's look (diff-view/index.ts); its box fills the - File tab's place and scrolls as a File tab's lines do. */ +/* A Files changed list of diffs (diff-list.tsx), or a review thread's lines + (file-diff.tsx): Pierre draws each file in a shadow root of its own, in + the panel's look (diff-view/index.ts); the list's box fills the File + tab's place and scrolls as a File tab's lines do. */ .diff-view { -webkit-user-select: text; user-select: text; } +/* Files changed's list of diffs (diff-list.tsx): every file under a header + of its own, which Pierre keeps at the top while the file's lines scroll + under it, so it is drawn opaque, on the code's ground, at the height the + bundle reserves for it (diff-view/index.ts's LIST_HEADER_HEIGHT). The file + stage-cli's keys stand on is ringed as a focused control is. */ +.diff-list-head { + border-top: 1px solid var(--hairline); + background: var(--code-ground); + color: var(--text-primary); + font-family: var(--luke-font-sans); +} + +.diff-list-head-row { + display: flex; + box-sizing: border-box; + height: 33px; + min-width: 0; + align-items: center; + gap: var(--space-8); + padding: 0 var(--space-12) 0 var(--space-6); + background: var(--section-fill); + font-size: 12px; + white-space: nowrap; +} + +.diff-list-head:not([data-collapsed]) > .diff-list-head-row { + border-bottom: 1px solid var(--hairline); +} + +.diff-list-head[data-standing] { + outline: var(--focus-ring-width) solid var(--focus-ring); + outline-offset: var(--focus-ring-inset); +} + +.diff-list-fold > svg { + transform: rotate(90deg); + transition: transform var(--duration-quick) ease; +} + +.diff-list-head[data-collapsed] .diff-list-fold > svg { + transform: none; +} + +.diff-list-path { + min-width: 0; + flex: 0 1 auto; + overflow: hidden; + font-family: var(--font-mono); + text-overflow: ellipsis; +} + +/* Why a file has no hunks to draw, under its header's row in the list. */ +.diff-list-notice { + margin: 0; + padding: var(--space-12) var(--space-12) var(--space-12) 38px; + color: var(--text-secondary); + font-size: 12.5px; +} + /* The tree's search field and its filter, in one row over the tree. */ .file-tree-search { display: flex; diff --git a/apps/desktop/src/renderer/tasks/agent-changes-tab.test.ts b/apps/desktop/src/renderer/tasks/agent-changes-tab.test.ts index bf04737fd1..d521e4ceb8 100644 --- a/apps/desktop/src/renderer/tasks/agent-changes-tab.test.ts +++ b/apps/desktop/src/renderer/tasks/agent-changes-tab.test.ts @@ -4,14 +4,17 @@ import assert from "node:assert/strict"; import { CHANGED_FILE_STATUS, CODING_AGENT_STATUS, + type CodingAgentChangedFile, type CodingAgentSummary, DIFF_SIDE, + PATCH_OMITTED, } from "@luke/features/coding-agent-wire"; import { ACT_KIND, ACT_OUTCOME_STATUS, type Act, type ActOutcome } from "@luke/host/acts"; import { DIFF_LAYOUT } from "@luke/settings/diff-view"; +import { APP_COMMAND } from "@luke/settings/shortcuts"; import { Effect } from "effect"; import * as AtomRegistry from "effect/reactivity/AtomRegistry"; -import { act, createElement } from "react"; +import { act, createElement, type ReactElement } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeAll, beforeEach, test, vi } from "vitest"; import { fakeAppBridge, fakeAppBridgeCalls } from "#testing/app-bridge"; @@ -21,14 +24,18 @@ import { relayout } from "#testing/resize-observer"; import { landedBundles, onSideBundles } from "#testing/side-bundles"; import { codingAgentsControl } from "#testing/tasks-control"; import { appBridge } from "../app-bridge"; +import { useAppCommand, useAppKeymap } from "../app-commands"; import type { SideBundles } from "../bundle-loader"; -import type { DiffViewModule, DiffViewProps } from "../diff-view/contract"; +import type { DiffListProps, DiffViewModule } from "../diff-view/contract"; import type { ChangesThreads } from "./agent-changes-tab"; /** * agent-changes-tab.test.ts -- a Files changed view hands the diff bundle - * each file's patch, headed as git writes one, and how the File tabs' - * settings draw it: unified with its changed words marked until told + * every file as one list, in the tree's order, each file's patch headed as + * git writes one, a filter narrowing it, deleted files and lockfiles + * folded, each fold and all of them turned by its controls and stage-cli's + * keys, which J and K step through only inside the view; and how the File + * tabs' settings draw it: unified with its changed words marked until told * otherwise, split once Split view is chosen in its ⋯, tinted whole once * Highlight changed words is turned off, each choice kept for the next * view; a diff too narrow to split is drawn unified, Split view dimmed, the @@ -60,6 +67,26 @@ const PATCH = [ const PAGE = "https://github.com/acme/relay/pull/7"; +/** A changed file of the pull request, modified with the seats patch unless told otherwise. */ +function changed( + path: string, + overrides: Partial = {}, +): CodingAgentChangedFile { + return { + path, + previousPath: null, + status: CHANGED_FILE_STATUS.MODIFIED, + additions: 2, + deletions: 1, + omitted: null, + patch: PATCH, + ...overrides, + }; +} + +/** The pull request's files, in the order GitHub lists them, which is not the tree's. */ +let files: CodingAgentChangedFile[] = [changed("src/seats.ts")]; + const control = codingAgentsControl({ agents: [AGENT], agentIds: [AGENT.id], @@ -69,17 +96,7 @@ const control = codingAgentsControl({ pullRequest: { url: "https://github.com/acme/relay/pull/7", truncated: false, - files: [ - { - path: "src/seats.ts", - previousPath: null, - status: CHANGED_FILE_STATUS.MODIFIED, - additions: 2, - deletions: 1, - omitted: null, - patch: PATCH, - }, - ], + files, reviewers: [], commitAuthors: [], comments: [], @@ -88,15 +105,22 @@ const control = codingAgentsControl({ }); /** What the stand-in bundle was handed, mount first, each draw after it. */ -let drawn: DiffViewProps[] = []; +let drawn: DiffListProps[] = []; +/** The files the list was scrolled to, in order. */ +let revealed: string[] = []; /** How wide the box the diff is drawn in lays out, in pixels. */ let boxWidth = 900; let root: Root | undefined; const standIn: DiffViewModule = { - mount: (_host, props) => { + mount: () => ({ update: () => undefined, unmount: () => undefined }), + mountList: (_host, props) => { drawn.push(props); - return { update: (next) => drawn.push(next), unmount: () => undefined }; + return { + update: (next) => drawn.push(next), + reveal: (path) => revealed.push(path), + unmount: () => undefined, + }; }, }; @@ -108,6 +132,8 @@ beforeAll(async () => { beforeEach(() => { drawn = []; + revealed = []; + files = [changed("src/seats.ts")]; boxWidth = 900; vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(function ( this: HTMLElement, @@ -126,39 +152,90 @@ afterEach(() => { vi.restoreAllMocks(); }); -/** Draws the agent's Files changed view, its read landed, and answers what the diff was last drawn with. */ -async function drawTab(threads?: ChangesThreads): Promise { +/** The window's keymap with the Inbox's J offered, as the Inbox offers it while it stands, counting each run. */ +function InboxBeside({ onNext }: { onNext: () => void }): null { + useAppKeymap(true); + useAppCommand(APP_COMMAND.NEXT_INBOX_ITEM, onNext); + return null; +} + +/** One registry for the tests' draws, so a draw again keeps the view's state as the panel would. */ +let registry = AtomRegistry.make(); + +/** Draws the view again in the root standing, its tree open or not, beside whatever else is given. */ +async function redraw( + options: { threads?: ChangesThreads; treeOpen?: boolean; beside?: ReactElement } = {}, +): Promise { const { AgentChangesTab } = await import("./agent-changes-tab"); - act(() => root?.unmount()); - document.body.innerHTML = ""; - const container = document.body.appendChild(document.createElement("div")); - root = createRoot(container); + const tab = createElement(AgentChangesTab, { + agentId: AGENT.id, + control, + shown: true, + repository: "acme/relay", + tree: { open: options.treeOpen ?? false, onToggle: () => undefined }, + threads: options.threads, + }); act(() => root?.render( onRegistry( - AtomRegistry.make(), - onSideBundles( - bundles, - createElement(AgentChangesTab, { - agentId: AGENT.id, - control, - shown: true, - repository: "acme/relay", - tree: { open: false, onToggle: () => undefined }, - threads, - }), - ), + registry, + onSideBundles(bundles, createElement("div", null, tab, options.beside ?? null)), ), ), ); - await act(async () => { - for (let tick = 0; tick < 12; tick += 1) await Promise.resolve(); - }); + await flush(); return lastDrawn(); } -function lastDrawn(): DiffViewProps { - return drawn.at(-1) ?? assert.fail("the diff is drawn"); +/** Draws the agent's Files changed view afresh, its read landed, and answers what the list was last drawn with. */ +async function drawTab( + options: { threads?: ChangesThreads; treeOpen?: boolean; beside?: ReactElement } = {}, +): Promise { + act(() => root?.unmount()); + document.body.innerHTML = ""; + registry = AtomRegistry.make(); + const container = document.body.appendChild(document.createElement("div")); + root = createRoot(container); + return redraw(options); +} + +function lastDrawn(): DiffListProps { + return drawn.at(-1) ?? assert.fail("the files are drawn"); +} + +/** The paths the list was last drawn with, each marked folded or not. */ +function folds(): Array<[string, boolean]> { + return lastDrawn().files.map((file) => [file.path, file.collapsed]); +} + +/** Presses a key on `target`, answering whether something took it. */ +function press(target: Element, init: KeyboardEventInit): boolean { + const event = new KeyboardEvent("keydown", { bubbles: true, cancelable: true, ...init }); + act(() => target.dispatchEvent(event)); + return event.defaultPrevented; +} + +/** The box the list is drawn in, inside the view. */ +function listBox(): HTMLElement { + return document.querySelector(".diff-list") ?? assert.fail("the list is drawn"); +} + +/** The header the view drew for a file, as the bundle stands it at the head of the file. */ +function header(path: string): HTMLElement { + const slot = lastDrawn().renderHeader(path); + return slot.querySelector(".diff-list-head") ?? assert.fail(`${path} has a header`); +} + +/** Types into the tree's search field as the developer would. */ +function typeSearch(words: string): void { + const field = + document.querySelector('input[aria-label="Search files"]') ?? + assert.fail("the tree's search stands"); + const setValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set; + act(() => { + setValue?.call(field, words); + field.dispatchEvent(new Event("input", { bubbles: true })); + }); } /** The ⋯'s item of a label, the menu opened. */ @@ -217,16 +294,149 @@ function settingControl(popover: HTMLElement, selector: string, name: string): H return found ?? assert.fail(`the settings offer ${name}`); } -test("a diff is handed over whole as git writes it, drawn as today's File tab look until told otherwise", async () => { +test("every file is handed over whole as git writes it, in the tree's order, drawn as today's File tab look until told otherwise", async () => { + files = [changed("src/seats.ts"), changed("README.md"), changed("src/billing/plan.ts")]; const props = await drawTab(); + assert.deepEqual( + props.files.map((file) => file.path), + ["src/billing/plan.ts", "src/seats.ts", "README.md"], + ); assert.equal( - props.patch, + props.files[1]?.patch, `diff --git a/src/seats.ts b/src/seats.ts\n--- a/src/seats.ts\n+++ b/src/seats.ts\n${PATCH}\n`, ); assert.deepEqual(props.look, DEFAULT_LOOK); }); +test("a file with no hunks to draw stands in the list under its header, which says why", async () => { + files = [ + changed("logo.png", { omitted: PATCH_OMITTED.BINARY, patch: null, additions: 0, deletions: 0 }), + ]; + const props = await drawTab(); + + assert.equal( + props.files[0]?.patch, + "diff --git a/logo.png b/logo.png\n--- a/logo.png\n+++ b/logo.png\n", + ); + assert.equal( + header("logo.png").querySelector(".diff-list-notice")?.textContent, + "Binary file not shown.", + ); +}); + +test("deleted files and lockfiles start folded, and a file's fold and Collapse all turn them", async () => { + files = [ + changed("src/seats.ts"), + changed("src/old.ts", { status: CHANGED_FILE_STATUS.DELETED }), + changed("pnpm-lock.yaml"), + ]; + await drawTab(); + assert.deepEqual(folds(), [ + ["src/old.ts", true], + ["src/seats.ts", false], + ["pnpm-lock.yaml", true], + ]); + + act(() => + header("src/old.ts") + .querySelector('button[aria-label="Expand file"]') + ?.click(), + ); + await flush(); + assert.deepEqual(folds()[0], ["src/old.ts", false]); + + const foldAll = () => + document.querySelector( + 'button[aria-label="Collapse all files"], button[aria-label="Expand all files"]', + ) ?? assert.fail("the bar offers Collapse all"); + assert.equal(foldAll().getAttribute("aria-label"), "Collapse all files"); + act(() => foldAll().click()); + await flush(); + assert.ok(folds().every(([, collapsed]) => collapsed)); + + assert.equal(foldAll().getAttribute("aria-label"), "Expand all files"); + act(() => foldAll().click()); + await flush(); + assert.ok(folds().every(([, collapsed]) => !collapsed)); +}); + +test("the tree's search narrows the list, said over it while the tree is hidden, until cleared", async () => { + files = [changed("src/seats.ts"), changed("src/billing/plan.ts"), changed("README.md")]; + await drawTab({ treeOpen: true }); + + typeSearch("BILLING"); + await flush(); + assert.deepEqual( + lastDrawn().files.map((file) => file.path), + ["src/billing/plan.ts"], + ); + + await redraw({ treeOpen: false }); + const notice = [...document.querySelectorAll(".file-tab-notice")].find((each) => + each.textContent?.includes("Only files whose path holds"), + ); + assert.match(notice?.textContent ?? "", /BILLING/u); + act(() => notice?.querySelector("button")?.click()); + await flush(); + assert.equal(lastDrawn().files.length, 3); +}); + +test("J and K step through the files inside the view, ; and ⇧; fold, and Escape lets go of the file", async () => { + files = [changed("a.ts"), changed("b.ts"), changed("c.ts")]; + await drawTab(); + const box = listBox(); + + assert.ok(press(box, { key: "j" }), "J is the view's"); + assert.deepEqual(revealed, ["a.ts"], "a first press stands on the file at the top"); + press(box, { key: "j" }); + press(box, { key: "j" }); + press(box, { key: "k" }); + assert.deepEqual(revealed, ["a.ts", "b.ts", "c.ts", "b.ts"]); + assert.ok(header("b.ts").hasAttribute("data-standing")); + assert.match(document.querySelector(".file-view-bar")?.textContent ?? "", /b\.ts/u); + + press(box, { key: ";", code: "Semicolon" }); + await flush(); + assert.deepEqual(folds(), [ + ["a.ts", false], + ["b.ts", true], + ["c.ts", false], + ]); + press(box, { key: ":", code: "Semicolon", shiftKey: true }); + await flush(); + assert.ok(folds().every(([, collapsed]) => collapsed)); + + assert.ok(press(box, { key: "Escape" }), "Escape lets go of the file stood on"); + assert.equal(header("b.ts").hasAttribute("data-standing"), false); + assert.equal(press(box, { key: "Escape" }), false, "with none stood on, Escape is the window's"); +}); + +test("J outside Files changed is still the Inbox's, and inside it is the view's alone", async () => { + files = [changed("a.ts"), changed("b.ts")]; + let inboxNext = 0; + await drawTab({ beside: createElement(InboxBeside, { onNext: () => (inboxNext += 1) }) }); + + press(document.body, { key: "j" }); + assert.equal(inboxNext, 1, "outside the view J runs the Inbox's next item"); + assert.deepEqual(revealed, []); + + press(listBox(), { key: "j" }); + assert.equal(inboxNext, 1, "inside it J goes no further"); + assert.deepEqual(revealed, ["a.ts"]); +}); + +test("a key typed into the tree's search is the field's", async () => { + files = [changed("a.ts"), changed("b.ts")]; + await drawTab({ treeOpen: true }); + const field = + document.querySelector('input[aria-label="Search files"]') ?? + assert.fail("the tree's search stands"); + + assert.equal(press(field, { key: "j" }), false); + assert.deepEqual(revealed, []); +}); + test("the diff settings set the layout, the indicators, and a switch for every diff, kept for the next view", async () => { await drawTab(); const popover = openSettings(); @@ -266,7 +476,7 @@ test("with Highlight changed words turned off a changed line is tinted whole, fo assert.equal((await drawTab()).look.inlineDiff, "none"); }); -test("a diff too narrow to split is drawn unified with Split view dimmed and its settings saying so, and split again once there is room", async () => { +test("a list too narrow to split is drawn unified with Split view dimmed and its settings saying so, and split again once there is room", async () => { handSettings({ diffLayout: DIFF_LAYOUT.SPLIT }); await drawTab(); assert.equal(lastDrawn().look.layout, "split"); @@ -373,29 +583,34 @@ function seatsThread(line: number | null, words: string) { }; } -test("a review thread is drawn under its line in the diff, and one a push outdated stands above the diff", async () => { +test("a review thread is drawn under its line in its file, and one a push outdated stands above the file's first hunk", async () => { const threads: ChangesThreads = { list: [seatsThread(2, "why-42"), seatsThread(null, "was-3")], draw: (thread) => createElement("p", { className: "drawn-thread" }, thread.comments[0]?.body), }; - const props = await drawTab(threads); - - assert.deepEqual(props.annotations, [{ key: `${PAGE}#why-42`, side: "additions", line: 2 }]); - const note = props.renderAnnotation?.(`${PAGE}#why-42`) ?? assert.fail("the note has an element"); - assert.equal(note.textContent, "why-42"); - const above = - document.querySelector('[aria-label="Comments on lines this diff does not show"]') ?? - assert.fail("the outdated thread stands above the diff"); - assert.equal(above.textContent, "was-3"); + const props = await drawTab({ threads }); + + const [outdated, current] = props.files[0]?.annotations ?? []; + assert.deepEqual(current, { key: `${PAGE}#why-42`, side: "additions", line: 2 }); + assert.equal(props.renderAnnotation("src/seats.ts", `${PAGE}#why-42`).textContent, "why-42"); + assert.equal(outdated?.side, "additions"); + assert.equal(outdated?.line, 0); + const above = props.renderAnnotation("src/seats.ts", outdated?.key ?? ""); + assert.equal( + above.querySelector('[aria-label="Comments on lines this diff does not show"]')?.textContent, + "was-3", + ); }); test("Refresh changes reads the review threads again with the files, so the two describe the same push", async () => { let threadReads = 0; await drawTab({ - list: [], - draw: () => null, - refresh: () => { - threadReads += 1; + threads: { + list: [], + draw: () => null, + refresh: () => { + threadReads += 1; + }, }, }); diff --git a/apps/desktop/src/renderer/tasks/agent-changes-tab.tsx b/apps/desktop/src/renderer/tasks/agent-changes-tab.tsx index 0df1696931..4413944eff 100644 --- a/apps/desktop/src/renderer/tasks/agent-changes-tab.tsx +++ b/apps/desktop/src/renderer/tasks/agent-changes-tab.tsx @@ -23,6 +23,8 @@ import { } from "@luke/ui"; import { fileName } from "@luke/voice/code-pane-model"; import { + ChevronsDownUpIcon, + ChevronsUpDownIcon, ClipboardCopyIcon, Columns2Icon, FileDiffIcon, @@ -32,10 +34,12 @@ import { import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { appPlatform } from "../app-platform"; import { useCopyToast } from "../copy-feedback"; +import { DIFF_SELECTION_SIDE, type DiffSelection, FILE_NOTE_LINE } from "../diff-view/contract"; import { SpinnerRegion } from "../spinner"; import { Tooltip } from "../tooltip"; import { LinesChanged } from "./agent-published"; import { AgentActorRef, type AgentSubject } from "./agent-ref"; +import { type ChangesList, FileHead, FOLD_CHORD, LIST_WORDS, useChangesList } from "./changes-list"; import { CHANGES_WORDS, changedFolders, @@ -43,13 +47,15 @@ import { fileNotice, filePatch, linesChanged, + listPatch, STATUS_LETTER, STATUS_WORDS, threadsOn, } from "./changes-model"; import { agentStillWriting, pullRequestNumberLabel } from "./coding-agent-model"; +import { type DiffListEntry, type DiffNote, PierreDiffList } from "./diff-list"; import { DiffSettingsButton } from "./diff-settings"; -import { PierreDiff, useDiffLook, useSplitRoom } from "./file-diff"; +import { useDiffLook, useSplitRoom } from "./file-diff"; import { LinesMenu, useSaveText } from "./file-menu"; import { FileTreePane } from "./file-tree-pane"; import { FileViewBar, usePaneRoom } from "./file-view"; @@ -76,28 +82,34 @@ import type { CodingAgentsControl, HeldPeople } from "./use-coding-agents"; * what the reviews said stands in the tab's overview, whole, in its * conversation. * - * Otherwise the tab is a File view (`file-view.tsx`) over the diff: its bar - * names the chosen file as breadcrumbs, whose trees list only the changed - * files, with the letter of what became of it and the lines it adds and - * removes quietly beside, then the pull request's files on GitHub, the - * diff settings, every one stage-cli offers in a popover - * (`diff-settings.tsx`), the ⋯ (the file's name or its patch copied, the - * patch saved, the changes read again, the File tabs' two settings, and - * two of the diff's as quick checks: Split view, offered dimmed while the - * diff is too narrow to split (`file-diff.tsx`), and Highlight changed - * words), and the tree's toggle, which shows and hides the tree for every - * File and Changes tab alike. The tree - * is a File tab's own pane (`file-tree-pane.tsx`) narrowed to the changed - * files, every folder open, each row lettered A, M, D, or R, searched in - * place, with the pull request's count under it, or under the diff while - * the tree is hidden; choosing a file there or - * in a crumb's tree shows its diff. The diff is drawn by Pierre's diffs in - * a bundle of its own (`../diff-view/`), on the File tab's ground, in its - * type and colours, unified or split, numbered and wrapped as the File - * tabs' settings say, its numbers left out of the find and of a copy as a - * File tab's are; what is a diff's alone is the tint of a line added or - * removed, the words a changed line changed, and the count of unchanged - * lines between two hunks. + * Otherwise the tab is a File view (`file-view.tsx`) over its files' diffs, + * every file in one list as stage-cli draws them (`diff-list.tsx`), each + * under a header of its own that folds it, names it, with the letter of + * what became of it and the lines it adds and removes, and stays at the top + * while its lines scroll by; a file with no hunks says why under its header. + * Its bar names the file the keys stand on, or else the one at the top of + * the list, as breadcrumbs, whose trees list only the changed files, the + * same letter and lines quietly beside, then the pull request's files on + * GitHub, the diff settings, every one stage-cli offers in a popover + * (`diff-settings.tsx`), Collapse all or Expand all, the ⋯ (the file's name + * or its patch copied, the patch saved, the changes read again, the File + * tabs' two settings, and two of the diff's as quick checks: Split view, + * offered dimmed while the list is too narrow to split (`file-diff.tsx`), + * and Highlight changed words), and the tree's toggle, which shows and + * hides the tree for every File and Changes tab alike. The tree is a File + * tab's own pane (`file-tree-pane.tsx`) narrowed to the changed files, + * every folder open, each row lettered A, M, D, or R, its search the + * list's filter, narrowing the list to the files whose path holds it as it + * narrows the rows, said over the list while the tree is hidden, with the + * pull request's count under it, or under the list while the tree is + * hidden; choosing a file there or in a crumb's tree scrolls the list to + * it. stage-cli's keys walk the list (`changes-list.tsx`). The diffs are + * drawn by Pierre's diffs in a bundle of its own (`../diff-view/`), on the + * File tab's ground, in its type and colours, unified or split, numbered + * and wrapped as the File tabs' settings say, their numbers left out of the + * find and of a copy as a File tab's are; what is a diff's alone is the + * tint of a line added or removed, the words a changed line changed, and + * the count of unchanged lines between two hunks. * Every name and line here is the repository's, so the tab is left out of * the screen recording (`ph-no-capture`). */ @@ -257,51 +269,132 @@ export const THREAD_PLACE = { export type ThreadPlace = (typeof THREAD_PLACE)[keyof typeof THREAD_PLACE]; -/** The chosen file: where it moved from, and its diff, or why there is none, in a File tab's place. */ -function FileDiff({ - file, +/** + * The key each side's review threads with no drawn row stand above a + * file's first hunk by, in a list of their own; a review thread's note is + * keyed by its page, which neither is. + */ +const ABOVE_KEY = { + [DIFF_SELECTION_SIDE.DELETIONS]: "above-deletions", + [DIFF_SELECTION_SIDE.ADDITIONS]: "above-additions", +} as const satisfies Record; + +/** + * A file of the list as the bundle is handed it: its patch, its fold, its + * header, saying why it has no hunks where it has none, and its notes: each + * review thread under its line, and those its diff draws no row for above + * the first hunk (`threadsOn`), on the side each counts on, as GitHub's + * split diff has them. + */ +function entryOf( + file: CodingAgentChangedFile, + list: ChangesList, + threads: ChangesThreads | undefined, +): DiffListEntry { + const { placed, above } = threadsOn(threads?.list ?? [], file); + const notes: DiffNote[] = placed.map((each) => ({ + key: each.key, + side: each.side, + line: each.line, + content: threads?.draw(each.thread, THREAD_PLACE.ON_LINE), + })); + for (const side of Object.values(DIFF_SELECTION_SIDE)) { + const unplaced = above.filter((each) => each.side === side); + if (unplaced.length === 0) continue; + notes.unshift({ + key: ABOVE_KEY[side], + side, + line: FILE_NOTE_LINE, + content: ( +
    + {unplaced.map((each) => ( +
  1. {threads?.draw(each.thread, THREAD_PLACE.ABOVE)}
  2. + ))} +
+ ), + }); + } + const path = file.path; + const collapsed = list.collapsed(path); + return { + path, + patch: listPatch(file), + collapsed, + header: ( + list.toggle(path)} + onStand={() => list.stand(path)} + /> + ), + notes, + }; +} + +/** Collapse all or Expand all, for the files the filter keeps, in the bar. */ +function FoldAllButton({ list }: { list: ChangesList }): React.JSX.Element { + const label = list.allCollapsed ? LIST_WORDS.EXPAND_ALL : LIST_WORDS.COLLAPSE_ALL; + return ( + + + + ); +} + +/** The list of files, or the words saying the filter keeps none, and over it the filter where the tree that holds it is hidden. */ +function FilesList({ + list, + threads, + filterShown, roomy, boxRef, - threads, }: { - file: CodingAgentChangedFile; - /** Whether the diff is wide enough to split. */ + list: ChangesList; + threads: ChangesThreads | undefined; + /** Whether the tree, whose search is the filter, is drawn, so the filter is in sight. */ + filterShown: boolean; roomy: boolean; - /** Handed the box the diff is drawn in, which the view measures for a split's room. */ boxRef: (element: HTMLDivElement | null) => void; - /** The pull request's review threads, and how one is drawn; none draws none. */ - threads: ChangesThreads | undefined; }): React.JSX.Element { const look = useDiffLook(roomy); - // A file with no notice has a patch with hunks, headed as git writes one for Pierre to read. - const patch = fileNotice(file) === undefined ? filePatch(file) : undefined; - const { placed, above } = threadsOn(threads?.list ?? [], file); - const notes = placed.map((each) => ({ - key: each.key, - side: each.side, - line: each.line, - content: threads?.draw(each.thread, THREAD_PLACE.ON_LINE), - })); + const query = list.query.trim(); return ( -
- {file.previousPath === null ? null : ( +
+ {query === "" || filterShown ? null : (
-

- Renamed from {file.previousPath} +

+ {LIST_WORDS.FILTERED} “{query}”

+
)} - {above.length === 0 ? null : ( -
    - {above.map((each) => ( -
  1. {threads?.draw(each.thread, THREAD_PLACE.ABOVE)}
  2. - ))} -
- )} - {patch === undefined ? ( -

{fileNotice(file) ?? CHANGES_WORDS.UNREADABLE}

+ {list.kept.length === 0 ? ( +

{LIST_WORDS.NONE_KEPT}

) : ( - + entryOf(file, list, threads))} + look={look} + door={list.door} + onTopFile={list.onTopFile} + boxRef={boxRef} + /> )}
); @@ -396,7 +489,6 @@ export function AgentChangesTab({ const status = control.agents?.find((each) => each.id === agentId)?.status; const ended = !agentStillWriting(status ?? CODING_AGENT_STATUS.RUNNING); const { answer, out, ask } = useAgentRead(agentId, shown, ended, control.readChanges, UNANSWERED); - const [chosen, setChosen] = useState(undefined); const room = usePaneRoom(tree); const [diffBox, setDiffBox] = useState(null); const roomy = useSplitRoom(diffBox); @@ -406,19 +498,20 @@ export function AgentChangesTab({ const changed = useMemo(() => { const files = pullRequest?.files ?? []; const paths = files.map((file) => file.path); - return { paths, marks: changeMarks(files), folders: changedFolders(paths) }; + return { files, paths, marks: changeMarks(files), folders: changedFolders(paths) }; }, [pullRequest]); + const list = useChangesList(changed.files); const { closeOver } = room; - // A file chosen in the tree floating over the diff takes the tree down, to show the file. + const { go } = list; + // A file chosen in the tree floating over the list takes the tree down, to show the file. const choose = useCallback( (path: string) => { closeOver(); - setChosen(path); + go(path); }, - [closeOver], + [closeOver, go], ); - const shownFile = - pullRequest?.files.find((file) => file.path === chosen) ?? pullRequest?.files[0]; + const shownFile = changed.files.find((file) => file.path === list.current) ?? changed.files[0]; // A failure gives way to the wait as soon as it is asked again; files drawn stand until the answer lands. if (answer === undefined || (out && "failure" in answer)) { return ; @@ -451,7 +544,15 @@ export function AgentChangesTab({ ); return ( -
+ // biome-ignore lint/a11y/noStaticElementInteractions: the view hears stage-cli's keys from whatever in it has the focus, the list's own scroller among them. +
{ + room.root.onKeyDown(event); + list.onKeyDown(event); + }} + > + ) : null}
- {room.drawn ? ( ) : null}
- {/* The count stands under the tree, and under the diff while the tree + {/* The count stands under the tree, and under the list while the tree stands nowhere beside it, which the stylesheet says (tasks.css). */}

{counted}

diff --git a/apps/desktop/src/renderer/tasks/agent-pull-request-tab.test.ts b/apps/desktop/src/renderer/tasks/agent-pull-request-tab.test.ts index 49416fd6a1..7dfd8cd520 100644 --- a/apps/desktop/src/renderer/tasks/agent-pull-request-tab.test.ts +++ b/apps/desktop/src/renderer/tasks/agent-pull-request-tab.test.ts @@ -52,7 +52,7 @@ import { markdownHtmlParserLanded } from "#testing/markdown"; import { landedBundles, onSideBundles } from "#testing/side-bundles"; import { codingAgentsControl } from "#testing/tasks-control"; import type { SideBundles } from "../bundle-loader"; -import type { DiffViewModule, DiffViewProps } from "../diff-view/contract"; +import type { DiffListProps, DiffViewModule, DiffViewProps } from "../diff-view/contract"; import { type GitHubConnectControl, GitHubConnectProvider } from "../github-connect"; import { MemberDirectory } from "../member-directory"; import { rendererRuntime } from "../renderer-runtime"; @@ -201,6 +201,14 @@ const DIFF_STAND_IN: DiffViewModule = { previews.push(props); return { update: () => undefined, unmount: () => undefined }; }, + mountList: (_host, props) => { + lists.push(props); + return { + update: (next) => lists.push(next), + reveal: () => undefined, + unmount: () => undefined, + }; + }, }; let bundles: SideBundles; @@ -216,6 +224,8 @@ let changes: (readonly [string, PullRequestActionRequest])[] = []; let changeAnswer: PullRequestWriteAnswer = { outcome: PULL_REQUEST_WRITE_OUTCOME.DONE }; /** What the service waits on before it answers a change, where a test holds it; none answers at once. */ let changeHeld: Promise | undefined; +/** What Files changed handed the diff bundle's list, mount first, each draw after it. */ +let lists: DiffListProps[] = []; beforeAll(() => Effect.runPromise(markdownHtmlParserLanded)); @@ -226,6 +236,7 @@ beforeEach(() => { previews = []; changes = []; changeAnswer = { outcome: PULL_REQUEST_WRITE_OUTCOME.DONE }; + lists = []; changeHeld = undefined; fakeAppPlatform(DESKTOP_LINKS); fakeAppBridge({ @@ -2107,14 +2118,12 @@ test("Files changed draws each review thread under its line, its comments throug act(() => tab.querySelector('button[aria-label="Diff settings"]')?.click()); }; - const file = previews.find( - (each) => each.annotations !== undefined && each.annotations.length > 0, - ); + const drawn = lists.at(-1) ?? assert.fail("the files are drawn"); + const file = drawn.files.find((each) => each.path === "src/invite.ts"); assert.deepEqual(file?.annotations, [ { key: `${PAGE}#discussion_r1`, side: "additions", line: 13 }, ]); - const note = - file?.renderAnnotation?.(`${PAGE}#discussion_r1`) ?? assert.fail("the thread is drawn"); + const note = drawn.renderAnnotation("src/invite.ts", `${PAGE}#discussion_r1`); assert.equal( note.querySelector('.pull-request-thread-comment [data-streamdown="strong"]')?.textContent, "once", @@ -2153,11 +2162,12 @@ test("a review thread on its line in Files changed is replied on and resolved fr ); await act(async () => option(tab, "Files changed").click()); await settle(); - const note = - previews - .find((each) => each.annotations?.some((one) => one.line === 13)) - ?.renderAnnotation?.(`${PAGE}#discussion_r1`) ?? - assert.fail("the thread is drawn on its line"); + const drawn = lists.at(-1) ?? assert.fail("the files are drawn"); + assert.ok( + drawn.files.some((file) => file.annotations.some((one) => one.line === 13)), + "the thread is drawn on its line", + ); + const note = drawn.renderAnnotation("src/invite.ts", `${PAGE}#discussion_r1`); writeReply(note, "Renamed."); act(() => threadPress(note, "Reply").click()); diff --git a/apps/desktop/src/renderer/tasks/changes-list.tsx b/apps/desktop/src/renderer/tasks/changes-list.tsx new file mode 100644 index 0000000000..d10774664e --- /dev/null +++ b/apps/desktop/src/renderer/tasks/changes-list.tsx @@ -0,0 +1,264 @@ +import type { CodingAgentChangedFile } from "@luke/features/coding-agent-wire"; +import { SHORTCUT_KEY, type ShortcutChord } from "@luke/settings/shortcuts"; +import { ChevronRightIcon } from "lucide-react"; +import { type RefObject, useCallback, useMemo, useRef, useState } from "react"; +import { Tooltip } from "../tooltip"; +import { LinesChanged } from "./agent-published"; +import { + filterFiles, + STATUS_LETTER, + STATUS_WORDS, + startsCollapsed, + treeOrder, +} from "./changes-model"; +import type { DiffListDoor } from "./diff-list"; + +/** + * changes-list.tsx -- how Files changed's list of files is walked: which files a filter keeps, which are collapsed, the file the keys stand on, and the keys, stage-cli's, that move between them. + * + * The keys are stage-cli's own, a letter alone each: J and K step to the + * next and the previous file, `;` collapses or expands the file stood on, + * ⇧; collapses every file the filter keeps, or expands them all once all + * are collapsed, and Escape lets go of the file stood on. They answer only + * where the press lands inside Files changed and is not typing, and go no + * further once answered, so J and K keep their meaning everywhere else in + * the window (the Inbox's next and previous item, `#shared/shortcuts`), an + * Escape with no file stood on reaches the window's layers, and the tab's ⌘F + * is never one of them. The file stood on is outlined in the list and named + * in the bar; with none stood on, the bar names the file at the top of the + * list, and a key starts from it. + * + * A file's fold is its default, `startsCollapsed`, turned over for the files + * the developer folded the other way, so a file that starts collapsed and is + * opened stays open, and Collapse all and Expand all turn over exactly the + * files the filter keeps. + */ + +/** What a key does in the list. */ +const FILE_KEY = { + NEXT: "next", + PREVIOUS: "previous", + TOGGLE: "toggle", + TOGGLE_ALL: "toggle-all", + LET_GO: "let-go", +} as const; + +type FileKey = (typeof FILE_KEY)[keyof typeof FILE_KEY]; + +/** The fold keys' chords, for the hints of the controls that do the same. */ +export const FOLD_CHORD = { + ONE: { key: SHORTCUT_KEY.SEMICOLON, plain: true }, + ALL: { key: SHORTCUT_KEY.SEMICOLON, plain: true, shift: true }, +} as const satisfies Record; + +/** What the list says where a filter keeps no file. */ +export const LIST_WORDS = { + NONE_KEPT: "No changed file's path holds that.", + FILTERED: "Only files whose path holds", + CLEAR_FILTER: "Clear filter", + COLLAPSE_ALL: "Collapse all files", + EXPAND_ALL: "Expand all files", +} as const; + +/** Whether the press landed in something taking typing, which keeps its letters. */ +function typingInto(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) return false; + return target.isContentEditable || target.matches("input, textarea, select"); +} + +/** + * What a press does in the list, or nothing: a letter alone, or ⇧ with `;`, + * read by its key for J and K and by its place for `;`, which ⇧ turns into + * `:` on most keyboards. Note that Escape is the list's only while a file is + * stood on, because otherwise it is the window's. + */ +function fileKeyOf(event: React.KeyboardEvent, standing: boolean): FileKey | undefined { + if (event.defaultPrevented || event.nativeEvent.isComposing) return undefined; + if (event.metaKey || event.ctrlKey || event.altKey || typingInto(event.target)) return undefined; + if (event.key === "Escape") return standing ? FILE_KEY.LET_GO : undefined; + if (event.code === "Semicolon") return event.shiftKey ? FILE_KEY.TOGGLE_ALL : FILE_KEY.TOGGLE; + if (event.shiftKey) return undefined; + const key = event.key.toLowerCase(); + if (key === "j") return FILE_KEY.NEXT; + return key === "k" ? FILE_KEY.PREVIOUS : undefined; +} + +/** The path `step` files on from `from` among `paths`, held at either end; the first for a file not among them. */ +function stepped(paths: readonly string[], from: string | undefined, step: number) { + const at = from === undefined ? -1 : paths.indexOf(from); + if (at === -1) return paths[0]; + return paths[Math.min(paths.length - 1, Math.max(0, at + step))]; +} + +/** The folds turned over, with `paths` folded as `collapsed` says. */ +function foldedAs( + overrides: ReadonlySet, + files: readonly CodingAgentChangedFile[], + collapsed: boolean, +): ReadonlySet { + const next = new Set(overrides); + for (const file of files) { + if (startsCollapsed(file) === collapsed) next.delete(file.path); + else next.add(file.path); + } + return next; +} + +/** The list's state and doors: the files kept, each fold, the file stood on and the one at the top, and what the keys and controls do. */ +export interface ChangesList { + readonly query: string; + readonly onQuery: (query: string) => void; + readonly kept: readonly CodingAgentChangedFile[]; + readonly collapsed: (path: string) => boolean; + readonly allCollapsed: boolean; + readonly toggle: (path: string) => void; + readonly toggleAll: () => void; + /** The file the keys stand on, outlined; none until a key or a choice stands on one. */ + readonly standing: string | undefined; + /** The file the bar names: the one stood on, else the one at the top of the list. */ + readonly current: string | undefined; + readonly onTopFile: (path: string) => void; + /** Stands on a file and scrolls the list to it, as a choice in the tree or a crumb does. */ + readonly go: (path: string) => void; + /** Stands on a file without scrolling, as a press on its header does. */ + readonly stand: (path: string) => void; + readonly onKeyDown: (event: React.KeyboardEvent) => void; + readonly door: RefObject; +} + +/** Files changed's list over `files`, every fold at its default and no file stood on until the developer acts. */ +export function useChangesList(files: readonly CodingAgentChangedFile[]): ChangesList { + const [query, onQuery] = useState(""); + const [overrides, setOverrides] = useState>(new Set()); + const [standing, setStanding] = useState(undefined); + const [top, setTop] = useState(undefined); + const door = useRef(null); + const ordered = useMemo(() => treeOrder(files), [files]); + const kept = useMemo(() => filterFiles(ordered, query), [ordered, query]); + const paths = useMemo(() => kept.map((file) => file.path), [kept]); + const folds = useMemo( + () => + new Map(files.map((file) => [file.path, startsCollapsed(file) !== overrides.has(file.path)])), + [files, overrides], + ); + const collapsed = useCallback((path: string) => folds.get(path) === true, [folds]); + const allCollapsed = kept.length > 0 && kept.every((file) => collapsed(file.path)); + const keptStanding = standing !== undefined && paths.includes(standing) ? standing : undefined; + const current = keptStanding ?? (top !== undefined && paths.includes(top) ? top : paths[0]); + + const toggle = useCallback((path: string) => { + setOverrides((was) => { + const next = new Set(was); + if (!next.delete(path)) next.add(path); + return next; + }); + }, []); + const toggleAll = useCallback( + () => setOverrides((was) => foldedAs(was, kept, !allCollapsed)), + [kept, allCollapsed], + ); + const go = useCallback((path: string) => { + setStanding(path); + door.current?.reveal(path); + }, []); + + const onKeyDown = (event: React.KeyboardEvent) => { + const key = fileKeyOf(event, keptStanding !== undefined); + if (key === undefined || current === undefined) return; + // Note that an answered press goes no further, because the window's keymap would run the Inbox's J and K too. + event.preventDefault(); + event.stopPropagation(); + if (key === FILE_KEY.LET_GO) return setStanding(undefined); + if (key === FILE_KEY.TOGGLE_ALL) return toggleAll(); + if (key === FILE_KEY.TOGGLE) { + toggle(current); + return go(current); + } + // A first press stands on the file at the top rather than stepping past it. + const step = key === FILE_KEY.NEXT ? 1 : -1; + const next = keptStanding === undefined ? current : stepped(paths, current, step); + if (next !== undefined) go(next); + }; + + return { + query, + onQuery, + kept, + collapsed, + allCollapsed, + toggle, + toggleAll, + standing: keptStanding, + current, + onTopFile: setTop, + go, + stand: setStanding, + onKeyDown, + door, + }; +} + +/** + * A file's header in the list: its fold, the letter of what became of it, + * its path, from the path it moved from where it moved, and the lines it + * adds and removes, and under them, while it is open, why it has no hunks + * where it has none, which stands here rather than as a note because a note + * on the whole file stands on one side of a split. Pierre keeps it at the + * top of the list while the file's lines scroll under it. + */ +export function FileHead({ + file, + notice, + collapsed, + standing, + onToggle, + onStand, +}: { + file: CodingAgentChangedFile; + /** Why the file has no hunks to draw; none for a file that has them. */ + notice: string | undefined; + collapsed: boolean; + /** Whether the keys stand on the file, which outlines it. */ + standing: boolean; + onToggle: () => void; + onStand: () => void; +}): React.JSX.Element { + const label = collapsed ? "Expand file" : "Collapse file"; + return ( +
+
+ + + + + + {STATUS_LETTER[file.status]} + + + + {file.previousPath === null ? null : ( + <> + {file.previousPath} →{" "} + + )} + {file.path} + + +
+ {notice === undefined || collapsed ? null :

{notice}

} +
+ ); +} diff --git a/apps/desktop/src/renderer/tasks/changes-model.test.ts b/apps/desktop/src/renderer/tasks/changes-model.test.ts index 668da29b20..a4757d0800 100644 --- a/apps/desktop/src/renderer/tasks/changes-model.test.ts +++ b/apps/desktop/src/renderer/tasks/changes-model.test.ts @@ -1,14 +1,21 @@ import assert from "node:assert/strict"; -import { CHANGED_FILE_STATUS, DIFF_SIDE, PATCH_OMITTED } from "@luke/features/coding-agent-wire"; +import { + CHANGED_FILE_STATUS, + type CodingAgentChangedFile, + DIFF_SIDE, + PATCH_OMITTED, +} from "@luke/features/coding-agent-wire"; import { test } from "vitest"; -import { threadPreview, threadsOn } from "./changes-model"; +import { filterFiles, startsCollapsed, threadPreview, threadsOn, treeOrder } from "./changes-model"; /** * changes-model.test.ts -- a review thread previews the end of the hunk * GitHub froze for it, found on the side its line counts on, from a few * rows above its range to its line, though GitHub's header still counts * the whole hunk: written as a patch of the thread's file whose header - * counts the rows kept, with the thread's own lines selected. + * counts the rows kept, with the thread's own lines selected. Files + * changed lists its files in the tree's order, keeps those whose path holds + * a filter, and folds what is rarely read. */ /** A hunk as GitHub freezes one for a thread: its header counting the whole hunk, its lines cut at the thread's line. */ @@ -192,3 +199,73 @@ test("every thread on a file whose diff is not drawn stands above where its diff [`${R}1`], ); }); + +/** A changed file, modified, a few lines changed, unless told otherwise. */ +function changed( + path: string, + overrides: Partial = {}, +): CodingAgentChangedFile { + return { + path, + previousPath: null, + status: CHANGED_FILE_STATUS.MODIFIED, + additions: 2, + deletions: 1, + omitted: null, + patch: "@@ -1 +1 @@\n-a\n+b", + ...overrides, + }; +} + +test("the files are listed as the tree lists them: folders first at each level, dotted names first, then by name whatever its case", () => { + const paths = [ + "src/b.ts", + "README.md", + "src/A.ts", + ".github/ci.yml", + "src/lib/x.ts", + ".env", + "docs/a.md", + ]; + + assert.deepEqual( + treeOrder(paths.map((path) => changed(path))).map((file) => file.path), + [".github/ci.yml", "docs/a.md", "src/lib/x.ts", "src/A.ts", "src/b.ts", ".env", "README.md"], + ); +}); + +test("a filter keeps the files whose path, or the path it moved from, holds it whatever its case, and nothing but spaces keeps every file", () => { + const files = [ + changed("src/seats.ts"), + changed("src/plan.ts", { + previousPath: "billing/plan.ts", + status: CHANGED_FILE_STATUS.RENAMED, + }), + changed("README.md"), + ]; + + assert.deepEqual( + filterFiles(files, " SEATS ").map((file) => file.path), + ["src/seats.ts"], + ); + assert.deepEqual( + filterFiles(files, "billing").map((file) => file.path), + ["src/plan.ts"], + ); + assert.equal(filterFiles(files, " ").length, 3); +}); + +test("a deleted file, a lockfile, and a file of more than a thousand changed lines start folded; a file with no hunks never does", () => { + assert.equal(startsCollapsed(changed("src/seats.ts")), false); + assert.equal( + startsCollapsed(changed("src/old.ts", { status: CHANGED_FILE_STATUS.DELETED })), + true, + ); + assert.equal(startsCollapsed(changed("apps/web/package-lock.json")), true); + assert.equal(startsCollapsed(changed("src/big.ts", { additions: 900, deletions: 101 })), true); + assert.equal(startsCollapsed(changed("src/big.ts", { additions: 900, deletions: 100 })), false); + assert.equal( + startsCollapsed(changed("pnpm-lock.yaml", { patch: null, omitted: PATCH_OMITTED.TOO_LARGE })), + false, + ); +}); diff --git a/apps/desktop/src/renderer/tasks/changes-model.ts b/apps/desktop/src/renderer/tasks/changes-model.ts index d66cfe32f1..0fde65fcab 100644 --- a/apps/desktop/src/renderer/tasks/changes-model.ts +++ b/apps/desktop/src/renderer/tasks/changes-model.ts @@ -18,7 +18,7 @@ import { } from "../file-tree/contract"; /** - * changes-model.ts -- an agent's changes as its Files changed view draws them: each file's patch headed as a whole unified diff, or why it has none, and the lines a review thread is on as a patch of their own. + * changes-model.ts -- an agent's changes as its Files changed view draws them: each file's patch headed as a whole unified diff, or why it has none, which files a filter keeps and which start collapsed, and the lines a review thread is on as a patch of their own. * * A file's patch is the hunks of a unified diff from the first `@@` header * on, as GitHub sends it. It is drawn whole (`filePatch`), headed as git @@ -27,6 +27,19 @@ import { * drawn as unchanged: it says it is binary, too large to show, or, with an * empty patch, that its contents did not change. * + * The view draws every file in one list, under a header of its own + * (`listPatch` is the file's patch, or its header alone where it has no + * hunks to draw), in the order the tree beside it lists them, folders + * before files at each level, as Pierre's tree sorts them (`treeOrder`), so + * a key stepping to the next file steps to the next row of the tree. A + * filter keeps the files whose path holds what is typed, + * whatever its case (`filterFiles`), as the tree's search narrows its rows. + * A file starts collapsed, its header alone drawn, where reading it is + * rarely the point, as GitHub keeps such a diff folded until asked + * (`startsCollapsed`): one deleted, one changing more than + * `LARGE_FILE_LINES` lines, and a package manager's lockfile, which GitHub + * would mark generated. + * * The view draws its files in a File tab's tree and breadcrumbs, which list * a folder at a time: the folders here are listed from the changed paths * alone (`changedFolders`), so either shows only what changed, each file @@ -121,6 +134,28 @@ export const STATUS_WORDS = { /** Where git's diff names a side the file does not stand on: before it was added, or after it was deleted. */ const NO_SIDE = "/dev/null"; +/** The most lines a file may add and remove together before it starts collapsed. */ +const LARGE_FILE_LINES = 1000; + +/** The names of the lockfiles package managers write, which start collapsed wherever they stand. */ +const LOCKFILE_NAMES = new Set([ + "pnpm-lock.yaml", + "package-lock.json", + "npm-shrinkwrap.json", + "yarn.lock", + "bun.lock", + "bun.lockb", + "Cargo.lock", + "Gemfile.lock", + "composer.lock", + "poetry.lock", + "uv.lock", + "Pipfile.lock", + "go.sum", + "Package.resolved", + "flake.lock", +]); + /** How many rows a review thread's preview shows above the first line it is on. */ const THREAD_CONTEXT_ROWS = 3; @@ -430,3 +465,70 @@ export function filePatch(file: CodingAgentChangedFile): string | undefined { const body = file.patch.endsWith("\n") ? file.patch : `${file.patch}\n`; return [`diff --git a/${from} b/${file.path}`, `--- ${before}`, `+++ ${after}`, body].join("\n"); } + +/** + * What a file's place in the list draws: its patch as a whole unified diff, + * or, where it has no hunks to draw, the same header with none under it, so + * the file still stands in the list under its header, with why it has none + * said there (`fileNotice`). + */ +export function listPatch(file: CodingAgentChangedFile): string { + const patch = filePatch(file); + if (patch !== undefined) return patch; + const from = file.previousPath ?? file.path; + return [`diff --git a/${from} b/${file.path}`, `--- a/${from}`, `+++ b/${file.path}`, ""].join( + "\n", + ); +} + +/** The files whose path, or the path it moved from, holds `query`, whatever its case; every file for a query of nothing but spaces. */ +export function filterFiles( + files: readonly CodingAgentChangedFile[], + query: string, +): readonly CodingAgentChangedFile[] { + const asked = query.trim().toLowerCase(); + if (asked === "") return files; + return files.filter( + (file) => + file.path.toLowerCase().includes(asked) || + (file.previousPath?.toLowerCase().includes(asked) ?? false), + ); +} + +/** One step of a path as the tree sorts its rows: folders before files, a dotted name before the rest, then by name whatever its case. */ +function compareStep(a: string, aFolder: boolean, b: string, bFolder: boolean): number { + if (aFolder !== bFolder) return aFolder ? -1 : 1; + const aDot = a.startsWith("."); + if (aDot !== b.startsWith(".")) return aDot ? -1 : 1; + return a.toLowerCase().localeCompare(b.toLowerCase()); +} + +/** Two paths in the tree's order: at the first folder or name they part at, as `compareStep` sorts them. */ +function compareTreePaths(a: string, b: string): number { + const aSteps = a.split("/"); + const bSteps = b.split("/"); + for (let at = 0; at < Math.min(aSteps.length, bSteps.length); at += 1) { + const aStep = aSteps[at] ?? ""; + const bStep = bSteps[at] ?? ""; + if (aStep === bStep) continue; + return compareStep(aStep, at < aSteps.length - 1, bStep, at < bSteps.length - 1); + } + return aSteps.length - bSteps.length; +} + +/** The files in the order the tree lists them, as Pierre's tree sorts its rows. */ +export function treeOrder( + files: readonly CodingAgentChangedFile[], +): readonly CodingAgentChangedFile[] { + return files.toSorted((a, b) => compareTreePaths(a.path, b.path)); +} + +/** Whether a file starts collapsed: deleted, changing more than `LARGE_FILE_LINES` lines, or a lockfile. Note that a file with no hunks never does, because what stands under its header is why. */ +export function startsCollapsed(file: CodingAgentChangedFile): boolean { + if (fileNotice(file) !== undefined) return false; + return ( + file.status === CHANGED_FILE_STATUS.DELETED || + file.additions + file.deletions > LARGE_FILE_LINES || + LOCKFILE_NAMES.has(file.path.split("/").at(-1) ?? "") + ); +} diff --git a/apps/desktop/src/renderer/tasks/diff-list.tsx b/apps/desktop/src/renderer/tasks/diff-list.tsx new file mode 100644 index 0000000000..e525eb5e08 --- /dev/null +++ b/apps/desktop/src/renderer/tasks/diff-list.tsx @@ -0,0 +1,183 @@ +import { useAtomValue } from "@effect/atom-react"; +import * as AsyncResult from "effect/reactivity/AsyncResult"; +import { Fragment, type ReactNode, type RefObject, useCallback, useEffect, useRef } from "react"; +import { createPortal } from "react-dom"; +import { DIFF_LOAD_FAILED, useSideBundles } from "../bundle-loader"; +import { + type DiffAnnotation, + type DiffListFile, + type DiffListHandle, + type DiffListProps, + type DiffLook, + sameLook, + sameNotes, +} from "../diff-view/contract"; + +/** + * diff-list.tsx -- a pull request's files as Files changed draws them: one list, every file under a header of its own, drawn by Pierre's list (`../diff-view/`'s `mountList`), only the files near the view drawn at all. + * + * The bundle is loaded the first time a diff is shown and the list mounted + * once; new files, a file collapsed or expanded, and a change of setting + * draw the same list again rather than mounting another. Each file's header + * and each note are drawn here, into an element kept for the list's life by + * the file's path and the note's key, which the bundle stands at the head of + * the file and under its line, so a header and a note are this window's own + * React and stylesheets. The view is told the file whose header stands at + * the top as the list scrolls, and scrolls the list to a file through + * `door`. + */ + +/** A note drawn in a file of the list, a review thread's under its line or what stands above the file's first hunk: where it stands, and what it says. */ +export interface DiffNote extends DiffAnnotation { + readonly content: ReactNode; +} + +/** One file of the list as the view hands it: its path, its patch, whether it is collapsed, its header, and its notes. */ +export interface DiffListEntry { + readonly path: string; + readonly patch: string; + readonly collapsed: boolean; + readonly header: ReactNode; + readonly notes: readonly DiffNote[]; +} + +/** What the view does to the list once it is drawn: scrolls it to a file's header. */ +export interface DiffListDoor { + readonly reveal: (path: string) => void; +} + +/** A note's place alone, which is all the bundle is handed of it. */ +function placeOf({ key, side, line }: DiffAnnotation): DiffAnnotation { + return { key, side, line }; +} + +/** Whether two lists of files draw alike: the same files in the same order, each with the same patch, fold, and notes. */ +function sameFiles(a: readonly DiffListFile[], b: readonly DiffListFile[]): boolean { + return ( + a.length === b.length && + a.every((file, at) => { + const other = b[at]; + return ( + other !== undefined && + file.path === other.path && + file.patch === other.patch && + file.collapsed === other.collapsed && + sameNotes(file.annotations, other.annotations) + ); + }) + ); +} + +/** The element kept by `key` in `held`, made with `className` the first time it is asked for. */ +function keptElement(held: Map, key: string, className: string) { + const kept = held.get(key); + if (kept !== undefined) return kept; + const element = document.createElement("div"); + element.className = className; + held.set(key, element); + return element; +} + +/** + * The files of `entries` drawn as one list in `look`, in a box that fills + * the File tab's place and scrolls; while the bundle loads the place stands + * empty, and where it failed to load it says so. + */ +export function PierreDiffList({ + entries, + look, + door, + onTopFile, + boxRef, +}: { + entries: readonly DiffListEntry[]; + look: DiffLook; + /** Filled with what scrolls the list once it is drawn, emptied when it is taken down. */ + door: RefObject; + /** Told the path of the file whose header stands at the top of the list as it scrolls. */ + onTopFile: (path: string) => void; + /** Handed the box the list is drawn in, which the view measures for a split's room; a door that stays the same, as a state's setter does. */ + boxRef: (element: HTMLDivElement | null) => void; +}): React.JSX.Element { + const loaded = useAtomValue(useSideBundles().diffView); + const module = AsyncResult.isSuccess(loaded) ? loaded.value : undefined; + const host = useRef(null); + const handle = useRef(undefined); + // Note that each header and note is drawn into an element of its own, kept + // for the list's life, because Pierre stands the same element again when + // a file scrolls back into view. + const headers = useRef(new Map()); + const notes = useRef(new Map>()); + const renderHeader = useCallback( + (path: string) => keptElement(headers.current, path, "diff-list-head-slot"), + [], + ); + const renderAnnotation = useCallback((path: string, key: string) => { + const file = notes.current.get(path) ?? new Map(); + notes.current.set(path, file); + return keptElement(file, key, "diff-note"); + }, []); + const topFile = useRef(onTopFile); + topFile.current = onTopFile; + const tellTop = useCallback((path: string) => topFile.current(path), []); + const files = entries.map((entry) => ({ + path: entry.path, + patch: entry.patch, + collapsed: entry.collapsed, + annotations: entry.notes.map(placeOf), + })); + const props: DiffListProps = { files, look, renderHeader, renderAnnotation, onTopFile: tellTop }; + // What the list was last drawn with; the mount reads the latest, so it is mounted once. + const drawn = useRef(props); + const latest = useRef(props); + latest.current = props; + const hostRef = useCallback( + (element: HTMLDivElement | null) => { + host.current = element; + boxRef(element); + }, + [boxRef], + ); + + useEffect(() => { + const element = host.current; + if (module === undefined || element === null) return undefined; + drawn.current = latest.current; + const mounted = module.mountList(element, latest.current); + handle.current = mounted; + door.current = { reveal: (path) => mounted.reveal(path) }; + return () => { + door.current = null; + handle.current = undefined; + mounted.unmount(); + }; + }, [module, door]); + + useEffect(() => { + if (handle.current === undefined) return; + const was = drawn.current; + if (sameFiles(was.files, files) && sameLook(was.look, look)) return; + drawn.current = latest.current; + handle.current.update(latest.current); + }); + + if (AsyncResult.isFailure(loaded)) { + return

{DIFF_LOAD_FAILED}

; + } + return ( +
+ {entries.map((entry) => ( + + {createPortal(entry.header, renderHeader(entry.path))} + {entry.notes.map((note) => + createPortal(note.content, renderAnnotation(entry.path, note.key), note.key), + )} + + ))} +
+ ); +} diff --git a/apps/desktop/src/renderer/tasks/file-diff.tsx b/apps/desktop/src/renderer/tasks/file-diff.tsx index 19c8a68f05..fd2beab0ba 100644 --- a/apps/desktop/src/renderer/tasks/file-diff.tsx +++ b/apps/desktop/src/renderer/tasks/file-diff.tsx @@ -1,23 +1,24 @@ import { useAtomValue } from "@effect/atom-react"; import { DIFF_LAYOUT } from "@luke/settings/diff-view"; import * as AsyncResult from "effect/reactivity/AsyncResult"; -import { type ReactNode, useCallback, useEffect, useRef, useState } from "react"; -import { createPortal } from "react-dom"; +import { useEffect, useRef, useState } from "react"; import { DIFF_LOAD_FAILED, useSideBundles } from "../bundle-loader"; -import type { - DiffAnnotation, - DiffLook, - DiffSelection, - DiffViewHandle, +import { + type DiffLook, + type DiffSelection, + type DiffViewHandle, + sameLook, } from "../diff-view/contract"; import { useFileViewSettings } from "./file-view-settings"; /** - * file-diff.tsx -- a patch as Luke draws every diff: Pierre's diff (`../diff-view/`) mounted in a File tab's place in Files changed, or in a review thread's card in the overview, laid out and marked as the File tabs' settings say, and unified wherever a split would not fit. + * file-diff.tsx -- a patch as Luke draws every diff: Pierre's diff (`../diff-view/`) mounted in a review thread's card in the overview, laid out and marked as the File tabs' settings say, and unified wherever a split would not fit. * - * The bundle is loaded the first time a diff is shown and mounted once per - * file; a change of setting or of patch draws the same diff again rather - * than mounting another. A split diff sets the old side beside the new, so + * Files changed draws its files as a list through the same bundle + * (`diff-list.tsx`), in the same look and room. The bundle is loaded the + * first time a diff is shown and mounted once per diff; a change of setting + * or of patch draws the same diff again rather than mounting another. A + * split diff sets the old side beside the new, so * each side has half the room: below `SPLIT_MIN_WIDTH` it is drawn unified * whatever the setting says, and the ⋯ offers Split view dimmed, the choice * kept for a wider panel. @@ -58,53 +59,6 @@ export function useDiffLook(roomy: boolean): DiffLook { }; } -/** No notes, one run for every diff that has none. */ -const NO_NOTES: readonly DiffNote[] = []; - -/** Whether two looks draw a diff alike: every setting the same, each appearance's theme among them. */ -function sameLook(a: DiffLook, b: DiffLook): boolean { - return ( - a.layout === b.layout && - a.indicators === b.indicators && - a.inlineDiff === b.inlineDiff && - a.backgrounds === b.backgrounds && - a.lineNumbers === b.lineNumbers && - a.wrap === b.wrap && - a.themes.dark === b.themes.dark && - a.themes.light === b.themes.light && - a.font === b.font && - a.fontSize === b.fontSize && - a.lineHeight === b.lineHeight && - a.ligatures === b.ligatures - ); -} - -/** A note drawn under a line of the diff, a review thread's: where it stands, and what it says. */ -export interface DiffNote extends DiffAnnotation { - readonly content: ReactNode; -} - -/** Whether two runs of notes stand under the same lines by the same keys. */ -function sameNotes(a: readonly DiffAnnotation[], b: readonly DiffAnnotation[]): boolean { - return ( - a.length === b.length && - a.every((note, at) => { - const other = b[at]; - return ( - other !== undefined && - note.key === other.key && - note.side === other.side && - note.line === other.line - ); - }) - ); -} - -/** A note's place alone, which is all the bundle is handed of it. */ -function placeOf({ key, side, line }: DiffAnnotation): DiffAnnotation { - return { key, side, line }; -} - /** Whether two selections light the same lines. */ function sameSelection(a: DiffSelection | undefined, b: DiffSelection | undefined): boolean { if (a === undefined || b === undefined) return a === b; @@ -121,9 +75,7 @@ export function PierreDiff({ look, selected, excerpt = false, - notes = NO_NOTES, - className = "file-tab-lines diff-view scroll-fade-top", - boxRef, + className, }: { patch: string; look: DiffLook; @@ -131,41 +83,18 @@ export function PierreDiff({ selected?: DiffSelection | undefined; /** Whether the patch is a few lines out of a file, drawn with nothing said of the lines around them. */ excerpt?: boolean; - /** The notes drawn under lines, a review thread's on its line; none for none. */ - notes?: readonly DiffNote[]; - /** The box's classes: a File tab's place, filling it and scrolling, unless a card's are given. */ - className?: string; - /** Handed the box the diff is drawn in, which the view measures for a split's room; a door that stays the same, as a state's setter does. */ - boxRef?: (element: HTMLDivElement | null) => void; + /** The box's classes, a card's. */ + className: string; }): React.JSX.Element { const loaded = useAtomValue(useSideBundles().diffView); const module = AsyncResult.isSuccess(loaded) ? loaded.value : undefined; const host = useRef(null); const handle = useRef(undefined); // What the diff was last drawn with; the mount reads the latest, so it is mounted once. - // Note that each note is drawn into an element of its own, kept by its key for the diff's life, which the bundle stands under its line. - const noteElements = useRef(new Map()); - const renderAnnotation = useCallback((key: string) => { - const held = noteElements.current.get(key); - if (held !== undefined) return held; - const element = document.createElement("div"); - element.className = "diff-note"; - noteElements.current.set(key, element); - return element; - }, []); - const annotations = notes.map(placeOf); - const props = { patch, look, selected, excerpt, annotations, renderAnnotation }; + const props = { patch, look, selected, excerpt }; const drawn = useRef(props); const latest = useRef(props); latest.current = props; - const hostRef = useCallback( - (element: HTMLDivElement | null) => { - host.current = element; - boxRef?.(element); - }, - [boxRef], - ); - useEffect(() => { const element = host.current; if (module === undefined || element === null) return undefined; @@ -185,8 +114,7 @@ export function PierreDiff({ was.patch === patch && sameLook(was.look, look) && sameSelection(was.selected, selected) && - was.excerpt === excerpt && - sameNotes(was.annotations, annotations) + was.excerpt === excerpt ) { return; } @@ -197,9 +125,5 @@ export function PierreDiff({ if (AsyncResult.isFailure(loaded)) { return

{DIFF_LOAD_FAILED}

; } - return ( -
- {notes.map((note) => createPortal(note.content, renderAnnotation(note.key), note.key))} -
- ); + return
; } diff --git a/apps/desktop/src/renderer/tasks/file-tree-pane.tsx b/apps/desktop/src/renderer/tasks/file-tree-pane.tsx index b349479e7c..8bd3c4831f 100644 --- a/apps/desktop/src/renderer/tasks/file-tree-pane.tsx +++ b/apps/desktop/src/renderer/tasks/file-tree-pane.tsx @@ -30,8 +30,9 @@ import { entriesOf } from "./folder-listings"; * An agent's Changes tab stands the same pane beside its diff * (`agent-changes-tab.tsx`), over its pull request's files alone: the tree * shows only them, every folder open, each row marked with what became of - * its file, the search narrowing those rows, no filter beside it, and a - * line under the tree counting them. + * its file, the search narrowing those rows, held by the tab, which narrows + * its list of diffs by the same words, no filter beside it, and a line under + * the tree counting them. */ /** @@ -88,6 +89,7 @@ export function FileTreePane({ shown, only, marks, + search, label = "Files", foot, listFolder, @@ -105,6 +107,8 @@ export function FileTreePane({ only?: readonly string[] | undefined; /** What became of each file of a change set, lettered on its row. */ marks?: readonly FileMark[] | undefined; + /** What the search holds and the door that sets it, where its holder narrows more than the tree by it; none keeps it in the pane. */ + search?: { readonly query: string; readonly onQuery: (query: string) => void } | undefined; /** What the pane is called. */ label?: string; /** What stands under the tree, such as a change set's count. */ @@ -127,7 +131,9 @@ export function FileTreePane({ const handle = useRef(undefined); const revealed = useRef(undefined); const field = useRef(null); - const [query, setQuery] = useState(""); + const [ownQuery, setOwnQuery] = useState(""); + const query = search?.query ?? ownQuery; + const setQuery = search?.onQuery ?? setOwnQuery; const [onlyShown, setOnlyShown] = useState(false); const [scope, setScope] = useState(SEARCH_SCOPE.UNREAD); const everything = useRef(undefined); diff --git a/packages/settings/src/shortcuts.ts b/packages/settings/src/shortcuts.ts index 4fc6543966..75838b0b95 100644 --- a/packages/settings/src/shortcuts.ts +++ b/packages/settings/src/shortcuts.ts @@ -197,6 +197,8 @@ export const SHORTCUT_KEY = { }, COMMA: { accelerator: ",", glyph: ",", key: ",", code: "Comma" }, SLASH: { accelerator: "/", glyph: "/", key: "/", code: "Slash" }, + // A bare ; and ⇧; are Files changed's own fold keys, stage-cli's, read by their place. + SEMICOLON: { accelerator: ";", glyph: ";", key: ";", code: "Semicolon" }, // Note that Return is ⏎ rather than ↩, because the system face draws ⏎ as // the Mac's own Return mark, centred on the capitals as ⌘ is, and has no ↩: // a ↩ is borrowed from another face and sits off the line beside it. From ca8654bfa22455d77b7ac97259c5a5c032396b80 Mon Sep 17 00:00:00 2001 From: Dean Stratakos <29683763+dastratakos@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:10:34 +0000 Subject: [PATCH 2/6] fix(desktop): a file with no lines in Files changed stands as its notice and threads, measured, not in its header Co-authored-by: luke-agent[bot] <339870089+luke-agent[bot]@users.noreply.github.com> --- .../src/renderer/diff-view/contract.ts | 8 ++- apps/desktop/src/renderer/diff-view/index.ts | 63 ++++++++++++++++--- apps/desktop/src/renderer/styles/tasks.css | 3 +- .../renderer/tasks/agent-changes-tab.test.ts | 24 +++++-- .../src/renderer/tasks/agent-changes-tab.tsx | 55 ++++++++++++---- .../src/renderer/tasks/changes-list.tsx | 15 ++--- apps/desktop/src/renderer/tasks/diff-list.tsx | 5 +- 7 files changed, 133 insertions(+), 40 deletions(-) diff --git a/apps/desktop/src/renderer/diff-view/contract.ts b/apps/desktop/src/renderer/diff-view/contract.ts index 4ab4f572b4..f9b61ceea8 100644 --- a/apps/desktop/src/renderer/diff-view/contract.ts +++ b/apps/desktop/src/renderer/diff-view/contract.ts @@ -116,12 +116,18 @@ export interface DiffViewProps { */ export const FILE_NOTE_LINE = 0; -/** One file of a list: its path, which keys it; its patch, headed as git writes one, with no hunks where it has none to draw; whether only its header is drawn; and its notes. */ +/** One file of a list: its path, which keys it; its patch, headed as git writes one, with no hunks where it has none to draw; whether only its header is drawn; its notes; and whether those notes stand in the place of its lines. */ export interface DiffListFile { readonly path: string; readonly patch: string; readonly collapsed: boolean; readonly annotations: readonly DiffAnnotation[]; + /** + * Whether the file draws no lines of its own, only what stands in their + * place: its notes on the whole file (`FILE_NOTE_LINE`), drawn across the + * whole of its width, which say why it has no lines or show what it is. + */ + readonly lead: boolean; } /** diff --git a/apps/desktop/src/renderer/diff-view/index.ts b/apps/desktop/src/renderer/diff-view/index.ts index 3a653f59a7..8fa0b91bfc 100644 --- a/apps/desktop/src/renderer/diff-view/index.ts +++ b/apps/desktop/src/renderer/diff-view/index.ts @@ -8,7 +8,9 @@ import { type FileDiffMetadata, type FileDiffOptions, getSingularPatch, + type File as PierreFile, FileDiff as PierreFileDiff, + type PostRenderPhase, } from "@pierre/diffs"; import { FIND_SHADOW_ATTRIBUTE } from "../find-matches"; import { @@ -69,8 +71,16 @@ import { adoptShadowStyles } from "./inline-styles"; * file's light DOM, so the panel's own stylesheets draw it and the view * fills it as it does any of its own; a note under a line, a review * thread's, is the same, by its key, and a note on the whole file stands - * under its header (`FILE_NOTE_LINE`). The view is told which file stands at - * the top as the list scrolls, and scrolls it to a file's header. + * under its header (`FILE_NOTE_LINE`). A file that draws no lines of its own, + * one binary or too large, or an image the view draws as a picture, stands + * as its notes on the whole file alone (`DiffListFile.lead`): Pierre draws + * such notes only above a line, and measures them there, so the file is + * handed one empty line, hidden with the old side and the numbers, and its + * notes run across the whole of its width. Note that nothing is put in the + * header beyond its one row, because Pierre reserves the header a height of + * its own and holds it at the top while the file scrolls by. The view is + * told which file stands at the top as the list scrolls, and scrolls it to + * a file's header. */ /** Each font's stack, ending in the window's own code type should the Mac lack it. */ @@ -95,6 +105,9 @@ const LIGATURES = { ON: "'liga' 1, 'calt' 1", OFF: "'liga' 0, 'calt' 0" } as con /** The longest line, in characters, whose changed words are marked; a longer one is tinted whole. */ const MAX_LINE_DIFF_LENGTH = 1000; +/** The attribute a list's file wears while it is drawn as its notes alone. */ +const LEAD_ATTRIBUTE = "data-lead"; + /** What the find leaves out of the shadow root's words: the gutter of numbers, as a File tab's numbers are. */ const FIND_SKIPPED = "[data-gutter]"; @@ -132,6 +145,21 @@ const LOOK_CSS = ` [data-gutter] { scrollbar-width: thin; } + /* A file drawn as its notes alone, laid out as blocks: its one empty line, the old side, the numbers, and the hatching beside a side's missing lines are hidden, so its notes run across its whole width, as tall as Pierre measured them. */ + :host([${LEAD_ATTRIBUTE}]) pre, + :host([${LEAD_ATTRIBUTE}]) [data-code], + :host([${LEAD_ATTRIBUTE}]) [data-content] { + display: block; + } + :host([${LEAD_ATTRIBUTE}]) pre { + padding-bottom: 0; + } + :host([${LEAD_ATTRIBUTE}]) [data-deletions], + :host([${LEAD_ATTRIBUTE}]) [data-gutter], + :host([${LEAD_ATTRIBUTE}]) [data-line], + :host([${LEAD_ATTRIBUTE}]) [data-content-buffer] { + display: none; + } ::highlight(find-match) { background-color: var(--find-match); } @@ -244,8 +272,16 @@ function annotationsOf(notes: readonly DiffAnnotation[]): DiffLineAnnotation ({ side: note.side, lineNumber: note.line, metadata: note.key })); } +/** A file drawn as its notes alone, as Pierre takes it: a new file of one empty line, which Pierre draws its notes on the whole file above. */ +function leadPatchOf(path: string): FileDiffMetadata { + return getSingularPatch( + `diff --git a/${path} b/${path}\n--- /dev/null\n+++ b/${path}\n@@ -0,0 +1 @@\n+\n`, + ); +} + /** A file's patch read, or, where it does not read as one, the file with no hunks, so one unreadable patch costs its own lines and not the list. */ function patchOf(file: DiffListFile): FileDiffMetadata { + if (file.lead) return leadPatchOf(file.path); try { return getSingularPatch(file.patch); } catch { @@ -263,12 +299,12 @@ interface DrawnFile { /** * Pierre's options for a list: the look, each file's header and notes drawn * in the elements the view keeps for them, read off the latest props, and - * each file's shadow root dressed as it is first drawn. + * each file's element dressed as it is drawn. */ function listOptions( drawn: DiffLook, latest: () => DiffListProps, - dress: (element: HTMLElement) => void, + dress: (element: HTMLElement, path: string) => void, ): CodeViewOptions { return { ...sharedOptions(drawn), @@ -285,7 +321,12 @@ function listOptions( ) => latest().renderHeader(context.item.id), renderAnnotation: (note: { metadata: string }, context: { item: { id: string } }) => latest().renderAnnotation(context.item.id, note.metadata), - onPostRender: (node: HTMLElement) => dress(node), + onPostRender: ( + node: HTMLElement, + _instance: PierreFileDiff | PierreFile, + _phase: PostRenderPhase, + context: { item: { id: string } }, + ) => dress(node, context.item.id), }; } @@ -301,8 +342,10 @@ function mountList(host: HTMLElement, props: DiffListProps): DiffListHandle { const releases: Array<() => void> = []; const dressed = new WeakSet(); // Note that Pierre keeps a file's element for another file once the first - // scrolls away, so each element is dressed once, the first time it is drawn. - const dress = (element: HTMLElement) => { + // scrolls away, so each element's shadow root is dressed once, the first + // time it is drawn, and whether it is drawn as notes alone every time. + const dress = (element: HTMLElement, path: string) => { + element.toggleAttribute(LEAD_ATTRIBUTE, drawnFiles.get(path)?.file.lead === true); if (dressed.has(element) || element.shadowRoot === null) return; dressed.add(element); element.setAttribute(FIND_SHADOW_ATTRIBUTE, FIND_SKIPPED); @@ -314,6 +357,7 @@ function mountList(host: HTMLElement, props: DiffListProps): DiffListHandle { if ( held !== undefined && held.file.patch === file.patch && + held.file.lead === file.lead && held.file.collapsed === file.collapsed && sameNotes(held.file.annotations, file.annotations) ) { @@ -322,7 +366,10 @@ function mountList(host: HTMLElement, props: DiffListProps): DiffListHandle { const item: CodeViewDiffItem = { id: file.path, type: "diff", - fileDiff: held?.file.patch === file.patch ? held.item.fileDiff : patchOf(file), + fileDiff: + held?.file.patch === file.patch && held.file.lead === file.lead + ? held.item.fileDiff + : patchOf(file), collapsed: file.collapsed, annotations: annotationsOf(file.annotations), version: (held?.item.version ?? 0) + 1, diff --git a/apps/desktop/src/renderer/styles/tasks.css b/apps/desktop/src/renderer/styles/tasks.css index 41e0173243..fc00174fde 100644 --- a/apps/desktop/src/renderer/styles/tasks.css +++ b/apps/desktop/src/renderer/styles/tasks.css @@ -1512,7 +1512,8 @@ text-overflow: ellipsis; } -/* Why a file has no hunks to draw, under its header's row in the list. */ +/* Why a file has no hunks to draw, in their place under its header in the + list (diff-view/index.ts draws it across the file's width). */ .diff-list-notice { margin: 0; padding: var(--space-12) var(--space-12) var(--space-12) 38px; diff --git a/apps/desktop/src/renderer/tasks/agent-changes-tab.test.ts b/apps/desktop/src/renderer/tasks/agent-changes-tab.test.ts index d521e4ceb8..7b010c451b 100644 --- a/apps/desktop/src/renderer/tasks/agent-changes-tab.test.ts +++ b/apps/desktop/src/renderer/tasks/agent-changes-tab.test.ts @@ -309,19 +309,31 @@ test("every file is handed over whole as git writes it, in the tree's order, dra assert.deepEqual(props.look, DEFAULT_LOOK); }); -test("a file with no hunks to draw stands in the list under its header, which says why", async () => { +test("a file with no hunks to draw stands in the list as what is said in their place: why, and its review threads", async () => { files = [ changed("logo.png", { omitted: PATCH_OMITTED.BINARY, patch: null, additions: 0, deletions: 0 }), ]; - const props = await drawTab(); + const props = await drawTab({ + threads: { + list: [{ ...seatsThread(2, "why-binary"), path: "logo.png" }], + draw: (thread) => createElement("p", null, thread.comments[0]?.body), + }, + }); + const [file] = props.files; + assert.equal(file?.patch, "diff --git a/logo.png b/logo.png\n--- a/logo.png\n+++ b/logo.png\n"); + assert.equal(file?.lead, true); + assert.deepEqual(file?.annotations, [{ key: "lead", side: "additions", line: 0 }]); + const lead = props.renderAnnotation("logo.png", "lead"); + assert.equal(lead.querySelector(".diff-list-notice")?.textContent, "Binary file not shown."); assert.equal( - props.files[0]?.patch, - "diff --git a/logo.png b/logo.png\n--- a/logo.png\n+++ b/logo.png\n", + lead.querySelector('[aria-label="Comments on lines this diff does not show"]')?.textContent, + "why-binary", ); assert.equal( - header("logo.png").querySelector(".diff-list-notice")?.textContent, - "Binary file not shown.", + header("logo.png").querySelector(".diff-list-notice"), + null, + "the header is one row", ); }); diff --git a/apps/desktop/src/renderer/tasks/agent-changes-tab.tsx b/apps/desktop/src/renderer/tasks/agent-changes-tab.tsx index 4413944eff..63725ba0e5 100644 --- a/apps/desktop/src/renderer/tasks/agent-changes-tab.tsx +++ b/apps/desktop/src/renderer/tasks/agent-changes-tab.tsx @@ -279,12 +279,33 @@ const ABOVE_KEY = { [DIFF_SELECTION_SIDE.ADDITIONS]: "above-additions", } as const satisfies Record; +/** The key of what stands in the place of the lines of a file that draws none; a review thread's note is keyed by its page, which this never is. */ +const LEAD_KEY = "lead"; + +/** A file's review threads that stand above its lines, as one list. */ +function AboveThreads({ + above, + threads, +}: { + above: readonly { readonly key: string; readonly thread: ReviewThread }[]; + threads: ChangesThreads | undefined; +}): React.JSX.Element { + return ( +
    + {above.map((each) => ( +
  1. {threads?.draw(each.thread, THREAD_PLACE.ABOVE)}
  2. + ))} +
+ ); +} + /** * A file of the list as the bundle is handed it: its patch, its fold, its - * header, saying why it has no hunks where it has none, and its notes: each - * review thread under its line, and those its diff draws no row for above - * the first hunk (`threadsOn`), on the side each counts on, as GitHub's - * split diff has them. + * header, and its notes: each review thread under its line, and those its + * diff draws no row for above the first hunk (`threadsOn`), on the side + * each counts on, as GitHub's split diff has them. A file with no hunks to + * draw stands as what is said in their place alone (`lead`), across its + * width: why it has none, and every one of its review threads. */ function entryOf( file: CodingAgentChangedFile, @@ -292,26 +313,34 @@ function entryOf( threads: ChangesThreads | undefined, ): DiffListEntry { const { placed, above } = threadsOn(threads?.list ?? [], file); + const notice = fileNotice(file); const notes: DiffNote[] = placed.map((each) => ({ key: each.key, side: each.side, line: each.line, content: threads?.draw(each.thread, THREAD_PLACE.ON_LINE), })); - for (const side of Object.values(DIFF_SELECTION_SIDE)) { + if (notice !== undefined) { + notes.unshift({ + key: LEAD_KEY, + side: DIFF_SELECTION_SIDE.ADDITIONS, + line: FILE_NOTE_LINE, + content: ( + <> +

{notice}

+ {above.length === 0 ? null : } + + ), + }); + } + for (const side of notice === undefined ? Object.values(DIFF_SELECTION_SIDE) : []) { const unplaced = above.filter((each) => each.side === side); if (unplaced.length === 0) continue; notes.unshift({ key: ABOVE_KEY[side], side, line: FILE_NOTE_LINE, - content: ( -
    - {unplaced.map((each) => ( -
  1. {threads?.draw(each.thread, THREAD_PLACE.ABOVE)}
  2. - ))} -
- ), + content: , }); } const path = file.path; @@ -320,10 +349,10 @@ function entryOf( path, patch: listPatch(file), collapsed, + lead: notice !== undefined, header: ( list.toggle(path)} diff --git a/apps/desktop/src/renderer/tasks/changes-list.tsx b/apps/desktop/src/renderer/tasks/changes-list.tsx index d10774664e..3b639391df 100644 --- a/apps/desktop/src/renderer/tasks/changes-list.tsx +++ b/apps/desktop/src/renderer/tasks/changes-list.tsx @@ -199,24 +199,20 @@ export function useChangesList(files: readonly CodingAgentChangedFile[]): Change } /** - * A file's header in the list: its fold, the letter of what became of it, - * its path, from the path it moved from where it moved, and the lines it - * adds and removes, and under them, while it is open, why it has no hunks - * where it has none, which stands here rather than as a note because a note - * on the whole file stands on one side of a split. Pierre keeps it at the - * top of the list while the file's lines scroll under it. + * A file's header in the list, one row: its fold, the letter of what + * became of it, its path, from the path it moved from where it moved, and + * the lines it adds and removes. Pierre keeps it at the top of the list + * while the file's lines scroll under it, at the height it reserves for it, + * so nothing more stands in it. */ export function FileHead({ file, - notice, collapsed, standing, onToggle, onStand, }: { file: CodingAgentChangedFile; - /** Why the file has no hunks to draw; none for a file that has them. */ - notice: string | undefined; collapsed: boolean; /** Whether the keys stand on the file, which outlines it. */ standing: boolean; @@ -258,7 +254,6 @@ export function FileHead({
- {notice === undefined || collapsed ? null :

{notice}

}
); } diff --git a/apps/desktop/src/renderer/tasks/diff-list.tsx b/apps/desktop/src/renderer/tasks/diff-list.tsx index e525eb5e08..51624edc91 100644 --- a/apps/desktop/src/renderer/tasks/diff-list.tsx +++ b/apps/desktop/src/renderer/tasks/diff-list.tsx @@ -32,11 +32,12 @@ export interface DiffNote extends DiffAnnotation { readonly content: ReactNode; } -/** One file of the list as the view hands it: its path, its patch, whether it is collapsed, its header, and its notes. */ +/** One file of the list as the view hands it: its path, its patch, whether it is collapsed, whether its notes stand in the place of its lines, its header, and its notes. */ export interface DiffListEntry { readonly path: string; readonly patch: string; readonly collapsed: boolean; + readonly lead: boolean; readonly header: ReactNode; readonly notes: readonly DiffNote[]; } @@ -62,6 +63,7 @@ function sameFiles(a: readonly DiffListFile[], b: readonly DiffListFile[]): bool file.path === other.path && file.patch === other.patch && file.collapsed === other.collapsed && + file.lead === other.lead && sameNotes(file.annotations, other.annotations) ); }) @@ -124,6 +126,7 @@ export function PierreDiffList({ path: entry.path, patch: entry.patch, collapsed: entry.collapsed, + lead: entry.lead, annotations: entry.notes.map(placeOf), })); const props: DiffListProps = { files, look, renderHeader, renderAnnotation, onTopFile: tellTop }; From 93d5c95ca145f4cc7f90a4949b60afa8dd003b7f Mon Sep 17 00:00:00 2001 From: Dean Stratakos <29683763+dastratakos@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:17:46 +0000 Subject: [PATCH 3/6] fix(desktop): Files changed's list and inline thread styles read the luke design tokens Co-authored-by: luke-agent[bot] <339870089+luke-agent[bot]@users.noreply.github.com> --- apps/desktop/src/renderer/styles/tasks.css | 38 +++++++++++----------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/apps/desktop/src/renderer/styles/tasks.css b/apps/desktop/src/renderer/styles/tasks.css index fc00174fde..7752868f6f 100644 --- a/apps/desktop/src/renderer/styles/tasks.css +++ b/apps/desktop/src/renderer/styles/tasks.css @@ -1467,9 +1467,9 @@ bundle reserves for it (diff-view/index.ts's LIST_HEADER_HEIGHT). The file stage-cli's keys stand on is ringed as a focused control is. */ .diff-list-head { - border-top: 1px solid var(--hairline); + border-top: 1px solid var(--luke-color-border); background: var(--code-ground); - color: var(--text-primary); + color: var(--luke-color-text); font-family: var(--luke-font-sans); } @@ -1479,20 +1479,20 @@ height: 33px; min-width: 0; align-items: center; - gap: var(--space-8); - padding: 0 var(--space-12) 0 var(--space-6); - background: var(--section-fill); + gap: var(--luke-space-8); + padding: 0 var(--luke-space-12) 0 var(--luke-space-6); + background: var(--luke-color-surface); font-size: 12px; white-space: nowrap; } .diff-list-head:not([data-collapsed]) > .diff-list-head-row { - border-bottom: 1px solid var(--hairline); + border-bottom: 1px solid var(--luke-color-border); } .diff-list-head[data-standing] { - outline: var(--focus-ring-width) solid var(--focus-ring); - outline-offset: var(--focus-ring-inset); + outline: var(--luke-focus-ring-width) solid var(--luke-color-focus-ring); + outline-offset: var(--luke-focus-ring-inset); } .diff-list-fold > svg { @@ -1508,7 +1508,7 @@ min-width: 0; flex: 0 1 auto; overflow: hidden; - font-family: var(--font-mono); + font-family: var(--luke-font-mono); text-overflow: ellipsis; } @@ -1516,8 +1516,8 @@ list (diff-view/index.ts draws it across the file's width). */ .diff-list-notice { margin: 0; - padding: var(--space-12) var(--space-12) var(--space-12) 38px; - color: var(--text-secondary); + padding: var(--luke-space-12) var(--luke-space-12) var(--luke-space-12) 38px; + color: var(--luke-color-text-muted); font-size: 12.5px; } @@ -2338,29 +2338,29 @@ button.pull-request-check:hover { so the window's own type and card draw it, inset from the code's edges as GitHub's are; an outdated one stands in a list above the diff. */ .diff-note { - padding: var(--space-6) var(--space-12); + padding: var(--luke-space-6) var(--luke-space-12); font-family: var(--luke-font-sans); white-space: normal; } .diff-inline-thread { - --focus-ring-offset: var(--focus-ring-inset); + --luke-focus-ring-offset: var(--luke-focus-ring-inset); max-width: 720px; overflow: clip; - border: 1px solid var(--hairline); - border-radius: var(--control-radius); - background: var(--section-fill); + border: 1px solid var(--luke-color-border); + border-radius: var(--luke-control-radius); + background: var(--luke-color-surface); } .diff-outdated-threads { display: flex; flex: none; flex-direction: column; - gap: var(--space-6); + gap: var(--luke-space-6); margin: 0; - padding: var(--space-8) var(--space-12); + padding: var(--luke-space-8) var(--luke-space-12); list-style: none; - border-bottom: 1px solid var(--hairline); + border-bottom: 1px solid var(--luke-color-border); } .pull-request-thread-head { From a6b0234a845c108e57fbb6d5ac109c80dc26eb60 Mon Sep 17 00:00:00 2001 From: Dean Stratakos <29683763+dastratakos@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:49:36 +0000 Subject: [PATCH 4/6] fix(desktop): a file the filter leaves out, chosen from the crumbs, clears the filter and is shown Co-authored-by: luke-agent[bot] <339870089+luke-agent[bot]@users.noreply.github.com> --- .../renderer/tasks/agent-changes-tab.test.ts | 48 ++++++++++++++++++- .../src/renderer/tasks/changes-list.tsx | 26 +++++++--- 2 files changed, 67 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/renderer/tasks/agent-changes-tab.test.ts b/apps/desktop/src/renderer/tasks/agent-changes-tab.test.ts index 7b010c451b..d941a287e7 100644 --- a/apps/desktop/src/renderer/tasks/agent-changes-tab.test.ts +++ b/apps/desktop/src/renderer/tasks/agent-changes-tab.test.ts @@ -27,6 +27,7 @@ import { appBridge } from "../app-bridge"; import { useAppCommand, useAppKeymap } from "../app-commands"; import type { SideBundles } from "../bundle-loader"; import type { DiffListProps, DiffViewModule } from "../diff-view/contract"; +import type { FileTreeModule } from "../file-tree/contract"; import type { ChangesThreads } from "./agent-changes-tab"; /** @@ -124,15 +125,41 @@ const standIn: DiffViewModule = { }, }; +/** The doors each tree the view mounted opens a chosen file through, the latest last. */ +let treeOpens: Array<(path: string, newTab: boolean) => void> = []; + +/** The tree bundle, stood in for: it draws nothing and keeps the door its tree opens a file through. */ +const treeStandIn: FileTreeModule = { + mount: (_host, props) => { + treeOpens.push(props.onOpen); + const nothing = () => undefined; + return { + reveal: nothing, + search: nothing, + only: nothing, + within: nothing, + mark: nothing, + unmount: nothing, + }; + }, + fileIcon: () => ({ symbol: "", type: "" }), +}; + let bundles: SideBundles; beforeAll(async () => { - bundles = await Effect.runPromise(landedBundles({ diffView: () => Promise.resolve(standIn) })); + bundles = await Effect.runPromise( + landedBundles({ + diffView: () => Promise.resolve(standIn), + fileTree: () => Promise.resolve(treeStandIn), + }), + ); }); beforeEach(() => { drawn = []; revealed = []; + treeOpens = []; files = [changed("src/seats.ts")]; boxWidth = 900; vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(function ( @@ -394,6 +421,25 @@ test("the tree's search narrows the list, said over it while the tree is hidden, assert.equal(lastDrawn().files.length, 3); }); +test("a file the filter leaves out, chosen in a tree of every changed file, clears the filter and is scrolled to", async () => { + files = [changed("src/seats.ts"), changed("src/billing/plan.ts"), changed("README.md")]; + await drawTab({ treeOpen: true }); + typeSearch("billing"); + await flush(); + assert.deepEqual( + lastDrawn().files.map((file) => file.path), + ["src/billing/plan.ts"], + ); + + const open = treeOpens.at(-1) ?? assert.fail("a tree is mounted"); + act(() => open("README.md", false)); + await flush(); + + assert.equal(lastDrawn().files.length, 3, "the filter is cleared"); + assert.deepEqual(revealed, ["README.md"]); + assert.match(document.querySelector(".file-view-bar")?.textContent ?? "", /README\.md/u); +}); + test("J and K step through the files inside the view, ; and ⇧; fold, and Escape lets go of the file", async () => { files = [changed("a.ts"), changed("b.ts"), changed("c.ts")]; await drawTab(); diff --git a/apps/desktop/src/renderer/tasks/changes-list.tsx b/apps/desktop/src/renderer/tasks/changes-list.tsx index 3b639391df..d867402ee0 100644 --- a/apps/desktop/src/renderer/tasks/changes-list.tsx +++ b/apps/desktop/src/renderer/tasks/changes-list.tsx @@ -1,7 +1,7 @@ import type { CodingAgentChangedFile } from "@luke/features/coding-agent-wire"; import { SHORTCUT_KEY, type ShortcutChord } from "@luke/settings/shortcuts"; import { ChevronRightIcon } from "lucide-react"; -import { type RefObject, useCallback, useMemo, useRef, useState } from "react"; +import { type RefObject, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Tooltip } from "../tooltip"; import { LinesChanged } from "./agent-published"; import { @@ -31,7 +31,9 @@ import type { DiffListDoor } from "./diff-list"; * A file's fold is its default, `startsCollapsed`, turned over for the files * the developer folded the other way, so a file that starts collapsed and is * opened stays open, and Collapse all and Expand all turn over exactly the - * files the filter keeps. + * files the filter keeps. A file the filter leaves out, chosen where every + * changed file is listed, as the bar's crumbs list them, clears the filter + * and is scrolled to once the list holds it. */ /** What a key does in the list. */ @@ -157,10 +159,22 @@ export function useChangesList(files: readonly CodingAgentChangedFile[]): Change () => setOverrides((was) => foldedAs(was, kept, !allCollapsed)), [kept, allCollapsed], ); - const go = useCallback((path: string) => { - setStanding(path); - door.current?.reveal(path); - }, []); + // A file the filter leaves out is chosen in the tree's crumbs, which list every changed file: the filter is cleared, and the list scrolled to it once it holds it again. + const [revealing, setRevealing] = useState(undefined); + const go = useCallback( + (path: string) => { + setStanding(path); + if (paths.includes(path)) return door.current?.reveal(path); + onQuery(""); + setRevealing(path); + }, + [paths], + ); + useEffect(() => { + if (revealing === undefined || !paths.includes(revealing)) return; + setRevealing(undefined); + door.current?.reveal(revealing); + }, [revealing, paths]); const onKeyDown = (event: React.KeyboardEvent) => { const key = fileKeyOf(event, keptStanding !== undefined); From 8ac7b6ffe665cc7aa15a3b5abc7fc4a476899dc5 Mon Sep 17 00:00:00 2001 From: Dean Stratakos <29683763+dastratakos@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:51:30 +0000 Subject: [PATCH 5/6] fix(desktop): Files changed's filter matches a file's path alone, as the tree's search does Co-authored-by: luke-agent[bot] <339870089+luke-agent[bot]@users.noreply.github.com> --- apps/desktop/src/renderer/tasks/changes-model.test.ts | 5 +++-- apps/desktop/src/renderer/tasks/changes-model.ts | 8 ++------ 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/renderer/tasks/changes-model.test.ts b/apps/desktop/src/renderer/tasks/changes-model.test.ts index a4757d0800..ac9a60d6e5 100644 --- a/apps/desktop/src/renderer/tasks/changes-model.test.ts +++ b/apps/desktop/src/renderer/tasks/changes-model.test.ts @@ -234,7 +234,7 @@ test("the files are listed as the tree lists them: folders first at each level, ); }); -test("a filter keeps the files whose path, or the path it moved from, holds it whatever its case, and nothing but spaces keeps every file", () => { +test("a filter keeps the files whose path holds it whatever its case, as the tree's search does, not the path one moved from, and nothing but spaces keeps every file", () => { const files = [ changed("src/seats.ts"), changed("src/plan.ts", { @@ -249,9 +249,10 @@ test("a filter keeps the files whose path, or the path it moved from, holds it w ["src/seats.ts"], ); assert.deepEqual( - filterFiles(files, "billing").map((file) => file.path), + filterFiles(files, "PLAN").map((file) => file.path), ["src/plan.ts"], ); + assert.deepEqual(filterFiles(files, "billing"), []); assert.equal(filterFiles(files, " ").length, 3); }); diff --git a/apps/desktop/src/renderer/tasks/changes-model.ts b/apps/desktop/src/renderer/tasks/changes-model.ts index 0fde65fcab..84df86a4b4 100644 --- a/apps/desktop/src/renderer/tasks/changes-model.ts +++ b/apps/desktop/src/renderer/tasks/changes-model.ts @@ -481,18 +481,14 @@ export function listPatch(file: CodingAgentChangedFile): string { ); } -/** The files whose path, or the path it moved from, holds `query`, whatever its case; every file for a query of nothing but spaces. */ +/** The files whose path holds `query`, whatever its case, as the tree's search narrows its rows; every file for a query of nothing but spaces. */ export function filterFiles( files: readonly CodingAgentChangedFile[], query: string, ): readonly CodingAgentChangedFile[] { const asked = query.trim().toLowerCase(); if (asked === "") return files; - return files.filter( - (file) => - file.path.toLowerCase().includes(asked) || - (file.previousPath?.toLowerCase().includes(asked) ?? false), - ); + return files.filter((file) => file.path.toLowerCase().includes(asked)); } /** One step of a path as the tree sorts its rows: folders before files, a dotted name before the rest, then by name whatever its case. */ From 5285846aa49d733f486492d5088435311d9c99ad Mon Sep 17 00:00:00 2001 From: Dean Stratakos <29683763+dastratakos@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:55:09 +0000 Subject: [PATCH 6/6] style(desktop): the inline thread rules read as #3245's token fix writes them Co-authored-by: luke-agent[bot] <339870089+luke-agent[bot]@users.noreply.github.com> --- apps/desktop/src/renderer/styles/tasks.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/renderer/styles/tasks.css b/apps/desktop/src/renderer/styles/tasks.css index 7752868f6f..1dbfd90da6 100644 --- a/apps/desktop/src/renderer/styles/tasks.css +++ b/apps/desktop/src/renderer/styles/tasks.css @@ -2336,7 +2336,7 @@ button.pull-request-check:hover { /* A review thread on its line in Files changed (agent-pull-request-tab.tsx's InlineThread): Pierre stands it under its line, in the diff's light DOM, so the window's own type and card draw it, inset from the code's edges - as GitHub's are; an outdated one stands in a list above the diff. */ + as GitHub's are; one the diff draws no row for stands in a list above it. */ .diff-note { padding: var(--luke-space-6) var(--luke-space-12); font-family: var(--luke-font-sans);