diff --git a/packages/panel/src/styles/tasks.css b/packages/panel/src/styles/tasks.css index 9ad5acee14..966e9731c4 100644 --- a/packages/panel/src/styles/tasks.css +++ b/packages/panel/src/styles/tasks.css @@ -1572,6 +1572,28 @@ font-family: var(--luke-font-mono); } +/* Each path in a header copies itself on a press (changes-list.tsx), laid + out as the path it names; the path a file moved from is quiet whole. */ +.diff-list-path-part { + display: flex; + min-width: 0; +} + +.diff-list-path-part > .luke-text-button { + display: flex; + min-width: 0; +} + +.diff-list-path-part[data-moved] { + flex: 0 1 auto; + color: var(--luke-color-text-muted); +} + +.diff-list-path-part[data-moved] .diff-list-name { + color: var(--luke-color-text-muted); + font-weight: inherit; +} + .diff-list-folder { min-width: 0; overflow: hidden; diff --git a/packages/panel/src/tasks/agent-changes-tab.test.ts b/packages/panel/src/tasks/agent-changes-tab.test.ts index 580f28ff63..ca8845ee68 100644 --- a/packages/panel/src/tasks/agent-changes-tab.test.ts +++ b/packages/panel/src/tasks/agent-changes-tab.test.ts @@ -43,6 +43,7 @@ import type { DiffListProps, DiffViewModule } from "../diff-view/contract.js"; import type { FileTreeModule } from "../file-tree/contract.js"; import { findRanges, reveal } from "../find-matches.js"; import { fakeAppBridge, fakeAppBridgeCalls } from "../testing/app-bridge.js"; +import { copyingInto, fakeAppPlatform } from "../testing/app-platform.js"; import { handSettings } from "../testing/app-state.js"; import { onRegistry } from "../testing/copy-feedback.js"; import { relayout } from "../testing/resize-observer.js"; @@ -1564,6 +1565,65 @@ test("minimized, the threads on no line take a row of their own for their chip, assert.equal(chipOf(current?.key)?.hasAttribute("data-above"), false, "on its line"); }); +test("a header copies the path it names on a press, the path a file moved from apart from the one it moved to", async () => { + files = [ + changed("src/seats.ts"), + changed("src/plan.ts", { + status: CHANGED_FILE_STATUS.RENAMED, + previousPath: "src/old/plan.ts", + }), + ]; + const copied: string[] = []; + fakeAppPlatform(copyingInto(copied)); + await drawTab(); + const copyButton = (path: string, name: string) => + [...header(path).querySelectorAll("button")].find((button) => + button.getAttribute("aria-label")?.startsWith(name), + ) ?? assert.fail(`${path} offers ${name}`); + act(() => copyButton("src/seats.ts", "Copy path").click()); + act(() => copyButton("src/plan.ts", "Copy old path").click()); + act(() => copyButton("src/plan.ts", "Copy new path").click()); + await flush(); + assert.deepEqual(copied, ["src/seats.ts", "src/old/plan.ts", "src/plan.ts"]); + assert.deepEqual( + folds().map(([, collapsed]) => collapsed), + [false, false], + "a copy folds nothing", + ); +}); + +test("a press anywhere on a header's row folds its file, and \u2325 with a press on the fold folds every file", async () => { + files = [changed("a.ts"), changed("b.ts"), changed("c.ts")]; + await drawTab(); + const row = (path: string) => + header(path).querySelector(".diff-list-head-row") ?? assert.fail("the row stands"); + act(() => row("b.ts").dispatchEvent(new MouseEvent("click", { bubbles: true }))); + await flush(); + assert.deepEqual(folds(), [ + ["a.ts", false], + ["b.ts", true], + ["c.ts", false], + ]); + + const fold = + header("a.ts").querySelector("button") ?? assert.fail("the fold stands"); + act(() => fold.dispatchEvent(new MouseEvent("click", { bubbles: true, altKey: true }))); + await flush(); + assert.ok( + folds().every(([, collapsed]) => collapsed), + "every file folds", + ); + act(() => { + const again = header("a.ts").querySelector("button"); + again?.dispatchEvent(new MouseEvent("click", { bubbles: true, altKey: true })); + }); + await flush(); + assert.ok( + folds().every(([, collapsed]) => !collapsed), + "and every file unfolds", + ); +}); + /** The seats file at the pull request's head, ten lines, its fifth changed, and its patch as GitHub sends it. */ const HEAD_TEXT = `${["1", "2", "3", "4", "five", "6", "7", "8", "9", "10"].join("\n")}\n`; const BASE_TEXT = HEAD_TEXT.replace("five", "5"); diff --git a/packages/panel/src/tasks/agent-changes-tab.tsx b/packages/panel/src/tasks/agent-changes-tab.tsx index 4c550347d1..693aaac0bb 100644 --- a/packages/panel/src/tasks/agent-changes-tab.tsx +++ b/packages/panel/src/tasks/agent-changes-tab.tsx @@ -517,6 +517,7 @@ function entryOf( collapsed={collapsed} standing={list.standing === path} onToggle={() => list.toggle(path)} + onToggleAll={list.toggleAll} onStand={() => list.stand(path)} /> ), @@ -541,6 +542,7 @@ function entryInputs( list.viewed?.(path), list.asSource(path), list.toggle, + list.toggleAll, list.stand, list.toggleSource, list.markViewed, diff --git a/packages/panel/src/tasks/changes-list.tsx b/packages/panel/src/tasks/changes-list.tsx index 2b60d19c73..ddd0fa0c71 100644 --- a/packages/panel/src/tasks/changes-list.tsx +++ b/packages/panel/src/tasks/changes-list.tsx @@ -4,11 +4,13 @@ import { FILES_CHANGED_SHORTCUTS, type FilesChangedKey, filesChangedKeyFor, + type ShortcutChord, shortcutAria, } from "@luke/settings/shortcuts"; -import { BUTTON_SIZE, IconButton } from "@luke/ui"; +import { BUTTON_SIZE, IconButton, TextButton } from "@luke/ui"; import { ChevronRightIcon } from "lucide-react"; import { + type ComponentProps, type ReactNode, type RefObject, useCallback, @@ -18,6 +20,7 @@ import { useRef, useState, } from "react"; +import { useCopyToast } from "../copy-feedback.js"; import { Tooltip } from "../tooltip.js"; import { LinesChanged } from "./agent-published.js"; import { @@ -265,10 +268,14 @@ export function useChangesList( ); const asSource = useCallback((path: string) => sources.has(path), [sources]); const toggleSource = useCallback((path: string) => setSources((was) => turned(was, path)), []); + const latestAll = useRef({ kept, allCollapsed }); + latestAll.current = { kept, allCollapsed }; + // Note that the door stays the same door across draws, as a header's others do, reading the files kept and their folds as they stand. const toggleAll = useCallback(() => { + const { kept, allCollapsed } = latestAll.current; choose(kept.map((file) => file.path)); setChosen((was) => foldedAs(was, kept, !allCollapsed)); - }, [kept, allCollapsed, choose]); + }, [choose]); // 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( @@ -353,6 +360,20 @@ export function useChangesList( }; } +/** What a file's header says and hints. */ +const HEAD_WORDS = { + EXPAND: "Expand file", + COLLAPSE: "Collapse file", + ALL_HINT: "\u2325-click for all files", + COPY_PATH: "Copy path", + COPY_OLD_PATH: "Copy old path", + COPY_NEW_PATH: "Copy new path", + PATH_COPIED: "Path copied", +} as const; + +/** What on a header's row keeps its press for itself rather than folding the file. */ +const HEAD_CONTROLS = "button, a, input, label, summary, [role='button'], [role='checkbox']"; + /** A path as a header names it: its folder, quiet, then its name. */ function PathParts({ path }: { path: string }): React.JSX.Element { const cut = path.lastIndexOf("/") + 1; @@ -395,6 +416,7 @@ export function FileHead({ collapsed, standing, onToggle, + onToggleAll, onStand, }: { file: CodingAgentChangedFile; @@ -404,38 +426,76 @@ export function FileHead({ /** Whether the keys stand on the file, which outlines it. */ standing: boolean; onToggle: () => void; + /** Folds every file the filter keeps, or unfolds them all, as ⌥ and a press on the fold do. */ + onToggleAll: () => void; onStand: () => void; }): React.JSX.Element { + const copy = useCopyToast(); const [near, setNear] = useState(false); - const foldRef = useRef(null); - const letterRef = useRef(null); - // What brought the hints: the keyboard's focus on the fold, or the pointer on the fold or the letter. - const arrived = useRef({ focused: false, overFold: false, overLetter: false }); + // The header's hinted parts, in order: its fold, its letter, the path it moved from, and its path. + const parts = useRef<(HTMLElement | null)[]>([]); + const partAt = (at: number) => (element: HTMLElement | null) => { + parts.current[at] = element; + }; + // Which part brought the hints: the keyboard's focus on one, or the pointer over one. + const arrived = useRef<{ readonly focused: number; readonly over: number }>({ + focused: -1, + over: -1, + }); useLayoutEffect(() => { if (!near) return; - const { focused, overFold, overLetter } = arrived.current; - if (focused) foldRef.current?.focus(); - // Note that the control the pointer is already on is told so again, because the one drawn in its place has heard no arrival. - if (overFold) foldRef.current?.dispatchEvent(pointerOver()); - if (overLetter) letterRef.current?.dispatchEvent(pointerOver()); + const { focused, over } = arrived.current; + const focusable = (at: number) => { + const part = parts.current[at]; + return part?.matches("button") === true ? part : part?.querySelector("button"); + }; + if (focused !== -1) focusable(focused)?.focus(); + // Note that the part the pointer is already on is told so again, because the one drawn in its place has heard no arrival. + if (over !== -1) (focusable(over) ?? parts.current[over])?.dispatchEvent(pointerOver()); }, [near]); - const label = collapsed ? "Expand file" : "Collapse file"; + const partOf = (target: EventTarget) => + target instanceof Node + ? parts.current.findIndex((part) => part?.contains(target) === true) + : -1; + const label = collapsed ? HEAD_WORDS.EXPAND : HEAD_WORDS.COLLAPSE; + const hinted = ( + hint: string, + part: ComponentProps["children"], + chord?: ShortcutChord, + ) => + near ? ( + + {part} + + ) : ( + part + ); const fold = (