Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions packages/panel/src/styles/tasks.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment on lines +1587 to +1589

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Current filename clips before old path

When a renamed file has a long old path, .diff-list-path-part shrinks both paths instead of yielding old-path space first. The current filename becomes truncated while part of the old path remains visible.

Learn more

A renamed header displays the old and current paths as separate flex items within the width-limited path container. Both items can shrink, so a long old path consumes width that the current filename needs. The current file name was previously a direct child of the path container, where the preceding old-path text could shrink first.

Example: With a long previousPath such as packages/legacy/generated/deeply-nested/report.ts and a short current path src/report.ts, narrowing the panel can ellipsize report.ts while still displaying report.ts from the old path.

Recommended fix: Give the current path priority when the renamed header shrinks, allowing the old path's folder and name to truncate first. Verify both names on a narrow panel, and retain truncation for current paths that cannot fit even after the old path yields.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

}

.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;
Expand Down
60 changes: 60 additions & 0 deletions packages/panel/src/tasks/agent-changes-tab.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<HTMLButtonElement>("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<HTMLElement>(".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<HTMLButtonElement>("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<HTMLButtonElement>("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");
Expand Down
2 changes: 2 additions & 0 deletions packages/panel/src/tasks/agent-changes-tab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -517,6 +517,7 @@ function entryOf(
collapsed={collapsed}
standing={list.standing === path}
onToggle={() => list.toggle(path)}
onToggleAll={list.toggleAll}
onStand={() => list.stand(path)}
/>
),
Expand All @@ -541,6 +542,7 @@ function entryInputs(
list.viewed?.(path),
list.asSource(path),
list.toggle,
list.toggleAll,
list.stand,
list.toggleSource,
list.markViewed,
Expand Down
148 changes: 106 additions & 42 deletions packages/panel/src/tasks/changes-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 {
Expand Down Expand Up @@ -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<string | undefined>(undefined);
const go = useCallback(
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -395,6 +416,7 @@ export function FileHead({
collapsed,
standing,
onToggle,
onToggleAll,
onStand,
}: {
file: CodingAgentChangedFile;
Expand All @@ -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<HTMLButtonElement>(null);
const letterRef = useRef<HTMLSpanElement>(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<typeof Tooltip>["children"],
chord?: ShortcutChord,
) =>
near ? (
<Tooltip label={hint} chord={chord}>
{part}
</Tooltip>
) : (
part
);
const fold = (
<IconButton
ref={foldRef}
ref={partAt(0)}
icon={<ChevronRightIcon className="diff-list-fold" aria-hidden="true" />}
label={label}
size={BUTTON_SIZE.COMPACT}
aria-expanded={!collapsed}
aria-keyshortcuts={shortcutAria(FOLD_CHORD.ONE)}
onClick={onToggle}
onClick={(event) => (event.altKey ? onToggleAll() : onToggle())}
/>
);
const letter = (
<span ref={letterRef} className="file-view-status" data-status={file.status}>
<span ref={partAt(1)} className="file-view-status" data-status={file.status}>
{STATUS_LETTER[file.status]}
</span>
);
const pathCopy = (path: string, at: number, words: string, hint: string, moved: boolean) => (
<span ref={partAt(at)} className="diff-list-path-part" data-moved={moved ? "" : undefined}>
{hinted(
hint,
<TextButton aria-label={`${hint}: ${path}`} onClick={() => copy(path, words)}>
<PathParts path={path} />
</TextButton>,
)}
</span>
);
return (
<div
className="diff-list-head"
Expand All @@ -444,42 +504,46 @@ export function FileHead({
onPointerDown={onStand}
onPointerEnter={(event) => {
if (near) return;
const { target } = event;
const on = (control: HTMLElement | null) =>
target instanceof Node && control?.contains(target) === true;
arrived.current = {
focused: false,
overFold: on(foldRef.current),
overLetter: on(letterRef.current),
};
arrived.current = { focused: -1, over: partOf(event.target) };
setNear(true);
}}
onFocusCapture={(event) => {
if (near) return;
arrived.current = {
focused: foldRef.current?.contains(event.target) === true,
overFold: false,
overLetter: false,
};
arrived.current = { focused: partOf(event.target), over: -1 };
setNear(true);
}}
>
<div className="diff-list-head-row">
{near ? (
<Tooltip label={label} chord={FOLD_CHORD.ONE}>
{fold}
</Tooltip>
) : (
fold
)}
{near ? <Tooltip label={STATUS_WORDS[file.status]}>{letter}</Tooltip> : letter}
{/* biome-ignore lint/a11y/noStaticElementInteractions: a press anywhere on the row folds the file as its fold does, which is the control the keyboard reaches. */}
{/* biome-ignore lint/a11y/useKeyWithClickEvents: the fold beside it is the row's keyboard control, and ; folds the file stood on. */}
<div
className="diff-list-head-row"
onClick={(event) => {
if (event.target instanceof Element && event.target.closest(HEAD_CONTROLS) !== null) {
return;
}
onToggle();
}}
>
{hinted(`${label} \u00b7 ${HEAD_WORDS.ALL_HINT}`, fold, FOLD_CHORD.ONE)}
{hinted(STATUS_WORDS[file.status], letter)}
<span className="diff-list-path">
{file.previousPath === null ? null : (
<span className="diff-list-folder">
<bdi>{file.previousPath}</bdi> →{"\u00a0"}
</span>
{file.previousPath === null ? (
pathCopy(file.path, 3, HEAD_WORDS.PATH_COPIED, HEAD_WORDS.COPY_PATH, false)
) : (
<>
{pathCopy(
file.previousPath,
2,
HEAD_WORDS.PATH_COPIED,
HEAD_WORDS.COPY_OLD_PATH,
true,
)}
<span className="diff-list-folder" aria-hidden="true">
{"\u00a0\u2192\u00a0"}
</span>
{pathCopy(file.path, 3, HEAD_WORDS.PATH_COPIED, HEAD_WORDS.COPY_NEW_PATH, false)}
</>
)}
<PathParts path={file.path} />
</span>
<LinesChanged additions={file.additions} deletions={file.deletions} />
{controls}
Expand Down
Loading