diff --git a/packages/panel/src/desktop/desktop-settings.test.ts b/packages/panel/src/desktop/desktop-settings.test.ts index 211fc91a10..130deb67d8 100644 --- a/packages/panel/src/desktop/desktop-settings.test.ts +++ b/packages/panel/src/desktop/desktop-settings.test.ts @@ -446,6 +446,7 @@ test("Keyboard shortcuts lists Files changed's own keys under a group of their o ["Clear file focus", "Esc"], ["Toggle viewed and go to next file", "V"], ["Minimize or show inline comments", "I"], + ["Show or hide file tree", "⇧F"], ], ); assert.deepEqual(foundUnder(page, "Collapse or expand all files"), ["Keyboard shortcuts"]); diff --git a/packages/panel/src/desktop/folded-groups.ts b/packages/panel/src/desktop/folded-groups.ts index a603d3fdc8..f546ab19e3 100644 --- a/packages/panel/src/desktop/folded-groups.ts +++ b/packages/panel/src/desktop/folded-groups.ts @@ -1,5 +1,5 @@ import { Option, Schema } from "effect"; -import { useState } from "react"; +import { useCallback, useState } from "react"; import { EVERYONES, INBOX_GROUPING, @@ -36,7 +36,8 @@ import { * Everything where it was not, which is what that list then held; a fixture run draws no inbox, so it has nothing staged to * keep apart. An automation's Runs panel keeps its width and whether it * stands on the same terms, one for every automation, as the side panel - * keeps one for every plan. + * keeps one for every plan, and so does the tree beside every File and + * Changes tab, one width for them all. */ /** Where the folds are kept in the renderer's storage. */ @@ -59,9 +60,21 @@ const STORAGE_KEY = { INBOX_MEDIA_PREVIEWS: "luke.inboxMediaPreviews", INBOX_LIST_WIDTH: "luke.inboxListWidth", RUNS_PANEL: "luke.automationRunsPanel", + FILE_TREE_WIDTH: "luke.fileTreeWidth", } as const; /** How wide an automation's Runs panel may stand, as the side panel is bounded. */ +/** + * How wide the tree beside a File or Changes tab may be dragged, in CSS + * pixels, and where it starts, as stage-cli's file picker is: wide enough + * for a nested name, never so wide that it takes the diff's room. + */ +export const FILE_TREE_WIDTH = { + MIN: 220, + MAX: 480, + DEFAULT: 264, +} as const; + export const RUNS_PANEL_WIDTH = { MIN: 280, MAX: 560, @@ -406,6 +419,33 @@ export function useInboxListWidth(): InboxListWidth { }; } +/** How wide the tree beside File and Changes tabs stands, and the drag that asks for another width. */ +export interface FileTreeWidth { + /** Always within {@link FILE_TREE_WIDTH}. */ + width: number; + /** Asks for a width; it is clamped to the bounds before it is kept. */ + onResize: (width: number) => void; +} + +/** A tree width held within its bounds, in whole pixels. */ +function clampTreeWidth(width: number): number { + return Math.min(FILE_TREE_WIDTH.MAX, Math.max(FILE_TREE_WIDTH.MIN, Math.round(width))); +} + +/** The tree's width, the default until first dragged, and kept as it is. */ +export function useFileTreeWidth(): FileTreeWidth { + const [width, setWidth] = useState(() => + clampTreeWidth(readKept(STORAGE_KEY.FILE_TREE_WIDTH, storedWidth, FILE_TREE_WIDTH.DEFAULT)), + ); + // Note that the drag's door is the same door across draws, so the tree the panel hands every tab is the same tree while nothing changed. + const onResize = useCallback((asked: number) => { + const next = clampTreeWidth(asked); + setWidth(next); + writeKept(STORAGE_KEY.FILE_TREE_WIDTH, storedWidth, next); + }, []); + return { width, onResize }; +} + /** A Runs panel width held within its bounds, in whole pixels. */ function clampRunsWidth(width: number): number { return Math.min(RUNS_PANEL_WIDTH.MAX, Math.max(RUNS_PANEL_WIDTH.MIN, Math.round(width))); diff --git a/packages/panel/src/desktop/side-panel.tsx b/packages/panel/src/desktop/side-panel.tsx index 370ca11e43..d991ea6ef7 100644 --- a/packages/panel/src/desktop/side-panel.tsx +++ b/packages/panel/src/desktop/side-panel.tsx @@ -87,7 +87,7 @@ import { CodePane, centreWithin } from "../tasks/code-pane.js"; import { agentGlyphState, agentStateWords, agentTabLabel } from "../tasks/coding-agent-model.js"; import { FileCard, fileLook, newestFileNote } from "../tasks/file-ref.js"; import { snippetOf } from "../tasks/file-tab-model.js"; -import { FileView } from "../tasks/file-view.js"; +import { FileView, type TreeShown } from "../tasks/file-view.js"; import { MacTerminalTab } from "../tasks/mac-terminal-tab.js"; import { PageCard } from "../tasks/page-card.js"; import { PagePane } from "../tasks/page-pane.js"; @@ -138,6 +138,7 @@ import { } from "../tasks/work-model.js"; import { Tooltip } from "../tooltip.js"; import { useCapability, useExperiment } from "../use-app-state.js"; +import { useFileTreeWidth } from "./folded-groups.js"; import { paneTiming } from "./pane-motion.js"; import { type AddEntry, @@ -1379,7 +1380,7 @@ function TabContent({ /** Opens a file of the task's repository in a tab of its own, the reference's lines lit. */ onOpenFile: (ref: CodeRef, commit?: string) => void; /** Whether a File or Changes tab's tree is open, which holds for every one of them alike, and its toggle. */ - fileTree: { readonly open: boolean; readonly onToggle: () => void }; + fileTree: TreeShown; /** Shows a file browsed to from a File tab's tree or breadcrumbs (`SidePanelControl.onBrowseFile`). */ onBrowseFile: (from: string, path: string, newTab: boolean) => void; /** Walks a File tab's history a step (`SidePanelControl.onFileHistory`). */ @@ -1491,6 +1492,7 @@ function TabContent({ openGitHub={(url) => appPlatform().openGitHub(url)} treeOpen={fileTree.open} onToggleTree={fileTree.onToggle} + treeWidth={fileTree.width} onBrowse={(path, newTab) => onBrowseFile(file.path, path, newTab)} history={{ canGoBack: (file.back?.length ?? 0) > 0, @@ -1871,9 +1873,16 @@ export function SidePanel({ // alike and for the window's life, as an editor's explorer stands across // its tabs. const [fileTreeOpen, setFileTreeOpen] = useState(true); + // How wide the tree stands, kept across launches as the Inbox's list's width is. + const fileTreeWidth = useFileTreeWidth(); + const { width: treeWidth, onResize: onTreeResize } = fileTreeWidth; const fileTree = useMemo( - () => ({ open: fileTreeOpen, onToggle: () => setFileTreeOpen((was) => !was) }), - [fileTreeOpen], + () => ({ + open: fileTreeOpen, + onToggle: () => setFileTreeOpen((was) => !was), + width: { width: treeWidth, onResize: onTreeResize }, + }), + [fileTreeOpen, treeWidth, onTreeResize], ); // Whether a Terminal tab's shells list is open, for every one of them alike // and for the window's life, as the File tabs' tree is. diff --git a/packages/panel/src/styles/tasks.css b/packages/panel/src/styles/tasks.css index 966e9731c4..43764dfc48 100644 --- a/packages/panel/src/styles/tasks.css +++ b/packages/panel/src/styles/tasks.css @@ -1170,10 +1170,14 @@ display: flex; } -/* The tree beside a File tab, its search level with the tab's heading. */ +/* The tree beside a File tab, its search level with the tab's heading, as + wide as it was last dragged (`useTreeEdge`), never so wide that it takes + the tab's room, and dragged by the edge on its left, which straddles its + border as the side panel's does. */ .file-tree-pane { + position: relative; display: flex; - width: min(264px, 40%); + width: min(var(--file-tree-width, 264px), max(40%, 100% - 320px)); min-width: 0; flex: 0 0 auto; flex-direction: column; @@ -1204,6 +1208,100 @@ display: none; } +.file-tree-pane[data-leaving] > .side-panel-resize { + display: none; +} + +/* While Files changed's tree is hidden, a strip stands in its place, one + mark for each changed file, coloured as its letter is in the tree, dimmed + once viewed, the file the bar names standing out (changes-strip.tsx); + the pointer resting on it peeks the tree out over the list, as a pane + floats where the panel is too narrow for it (below). */ +.file-tree-strip { + display: none; +} + +.file-view[data-pane-beside="false"] > .file-view-body > .file-tree-strip { + display: flex; + width: var(--luke-space-12); + flex: none; + flex-direction: column; + align-items: center; + padding-block: var(--luke-space-12); + border-left: 1px solid var(--luke-color-border); +} + +/* Each mark is a share of the strip's height, at most a line's mark, its + colour filling the top of its share so the gap between marks shrinks + with them: a pull request of thousands of files draws every mark, however + thin, and the file the bar names never thinner than a line. */ +.file-tree-strip-mark { + --strip-mark-colour: var(--luke-color-attention); + width: var(--luke-space-6); + min-height: 0; + max-height: var(--luke-space-6); + flex: 1 1 0; + background: linear-gradient(var(--strip-mark-colour) 0 66%, transparent 66%); +} + +.file-tree-strip-mark[data-status="added"] { + --strip-mark-colour: var(--luke-color-complete); +} + +.file-tree-strip-mark[data-status="deleted"] { + --strip-mark-colour: var(--luke-color-danger-text); +} + +.file-tree-strip-mark[data-status="renamed"] { + --strip-mark-colour: var(--luke-color-working); +} + +.file-tree-strip-mark[data-viewed] { + opacity: 0.35; +} + +.file-tree-strip-mark[data-current] { + width: var(--luke-space-12); + min-height: var(--luke-space-2); + opacity: 1; + box-shadow: 0 0 0 1px var(--luke-color-text); +} + +/* The tree peeked from the strip, or opened over the list, floats over the + list's right edge, as it does in a narrow panel, on the window's floating + surface: a menu's ground, edge, and shadow, so it reads as standing above + the list rather than as the list cut short. */ +.file-view:is([data-pane-over="peek"], [data-pane-over="open"]) .file-tree-pane { + border-left-color: var(--luke-color-border-strong); + background: var(--luke-color-overlay); +} + +.file-view:is([data-pane-over="peek"], [data-pane-over="open"]) .file-tree-host { + --trees-bg-override: var(--luke-color-overlay); +} + +.file-view[data-pane-beside="false"]:is([data-pane-over="peek"], [data-pane-over="open"]) + .file-tree-pane { + position: absolute; + z-index: 1; + inset-block: 0; + right: 0; + display: flex; + width: min(var(--file-tree-width, 264px), calc(100% - var(--luke-space-48))); + box-shadow: var(--luke-menu-shadow); + animation: pane-over-in var(--duration-pane) var(--motion-pane) backwards; +} + +.file-view[data-pane-beside="false"][data-pane-over="peek"] .file-tree-pane { + animation-delay: var(--duration-quick); +} + +.file-view[data-pane-beside="false"]:is([data-pane-over="peek"], [data-pane-over="open"]) + .file-tree-pane + > .side-panel-resize { + display: none; +} + .file-tree-toggle[data-place="over"] { display: none; } @@ -1225,11 +1323,16 @@ z-index: 1; inset-block: 0; right: 0; - width: min(264px, calc(100% - var(--luke-space-48))); + width: min(var(--file-tree-width, 264px), calc(100% - var(--luke-space-48))); background: var(--luke-color-well); box-shadow: var(--luke-menu-shadow); } + .file-view .file-tree-pane > .side-panel-resize, + .file-view[data-pane-beside="false"] > .file-view-body > .file-tree-strip { + display: none; + } + .file-view:is([data-pane-over="peek"], [data-pane-over="open"]) .file-tree-pane { display: flex; animation: pane-over-in var(--duration-pane) var(--motion-pane) backwards; @@ -1260,6 +1363,15 @@ } } +/* A panel with no room for the tree's least width beside the tab's own + keeps the tree at its share of the panel, with no edge to drag, since + any width a drag asked for would be drawn no wider (`useTreeEdge`). */ +@container side-panel (max-width: 539px) { + .file-view .file-tree-pane > .side-panel-resize { + display: none; + } +} + /* A Terminal tab's shells float the same way below a 480px panel, so the screen beside them never falls under forty columns: at the panel's default width the screen has the whole tab, the shells a hover away. */ diff --git a/packages/panel/src/tasks/agent-changes-tab.test.ts b/packages/panel/src/tasks/agent-changes-tab.test.ts index 4fedec1a0b..388a84e07a 100644 --- a/packages/panel/src/tasks/agent-changes-tab.test.ts +++ b/packages/panel/src/tasks/agent-changes-tab.test.ts @@ -51,6 +51,7 @@ import { landedBundles, onSideBundles } from "../testing/side-bundles.js"; import { codingAgentsControl } from "../testing/tasks-control.js"; import type { ChangesThreads } from "./agent-changes-tab.js"; import { blobShaOf } from "./changes-model.js"; +import type { TreeShown } from "./file-view.js"; /** * agent-changes-tab.test.ts -- a Files changed view hands the diff bundle @@ -251,6 +252,8 @@ let registry = AtomRegistry.make(); interface DrawOptions { readonly threads?: ChangesThreads; readonly treeOpen?: boolean; + /** The tree as the panel holds it, in `treeOpen`'s place: its toggle and its width. */ + readonly tree?: TreeShown; readonly beside?: ReactElement; readonly repository?: string; readonly control?: typeof control; @@ -268,7 +271,7 @@ async function redraw(options: DrawOptions = {}): Promise { control: options.control ?? control, shown: options.shown ?? true, repository: options.repository ?? "acme/relay", - tree: { open: options.treeOpen ?? false, onToggle: () => undefined }, + tree: options.tree ?? { open: options.treeOpen ?? false, onToggle: () => undefined }, threads: options.threads, }); act(() => @@ -525,6 +528,78 @@ test("a file the filter leaves out, chosen in a tree of every changed file, clea assert.match(document.querySelector(".file-view-bar")?.textContent ?? "", /README\.md/u); }); +/** A tree as the panel holds it, open as given, counting its toggles and keeping the widths asked for. */ +function heldTree(open: boolean, width = 300) { + const widths: number[] = []; + const held = { toggles: 0, widths }; + const tree: TreeShown = { + open, + onToggle: () => { + held.toggles += 1; + }, + width: { width, onResize: (asked) => held.widths.push(asked) }, + }; + return { tree, held }; +} + +test("⇧F inside the view shows or hides the tree, as its toggle does, and is no key while typing", async () => { + files = [changed("a.ts"), changed("b.ts")]; + const { tree, held } = heldTree(true); + await drawTab({ tree }); + + assert.equal(press(listBox(), { key: "F", shiftKey: true }), true); + const field = document.querySelector(".file-tree-search input"); + assert.equal( + press(field ?? assert.fail("the search field is drawn"), { key: "F", shiftKey: true }), + false, + ); + assert.equal(held.toggles, 1); +}); + +test("the tree stands as wide as the panel keeps it, and its edge asks for another width", async () => { + files = [changed("a.ts")]; + const { tree, held } = heldTree(true, 360); + await drawTab({ tree }); + + const pane = document.querySelector(".file-tree-pane") ?? assert.fail("a tree"); + assert.equal(pane.style.getPropertyValue("--file-tree-width"), "360px"); + const edge = pane.querySelector('[role="separator"]') ?? assert.fail("an edge"); + assert.equal(edge.getAttribute("aria-label"), "Resize files"); + press(edge, { key: "Enter" }); + assert.deepEqual(held.widths, [264], "Enter puts back the tree's own width"); +}); + +test("while the tree is hidden a strip stands in its place, a mark for each file, and resting on it peeks the tree", async () => { + viewedAnswer = MARKED; + files = [ + changed("c.ts"), + changed("a.ts", { status: CHANGED_FILE_STATUS.ADDED }), + changed("b.ts", { status: CHANGED_FILE_STATUS.DELETED }), + ]; + const { tree } = heldTree(false); + await drawTab({ tree }); + + const strip = document.querySelector(".file-tree-strip") ?? assert.fail("a strip"); + assert.deepEqual( + [...strip.children].map((mark) => [ + mark.getAttribute("data-status"), + mark.hasAttribute("data-viewed"), + mark.hasAttribute("data-current"), + ]), + [ + ["added", true, true], + ["deleted", false, false], + ["modified", false, false], + ], + ); + assert.equal(document.querySelector(".file-tree-pane"), null); + + act(() => strip.dispatchEvent(new PointerEvent("pointerover", { bubbles: true }))); + await flush(); + assert.equal(document.querySelector(".file-view")?.getAttribute("data-pane-over"), "peek"); + assert.notEqual(document.querySelector(".file-tree-pane"), null, "the tree peeks out"); +}); + 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(); @@ -707,6 +782,7 @@ test("? inside the view opens a sheet of its keys by group, which Escape or ? ag ["Minimize or show inline comments", "I"], ], ], + ["Panels", [["Show or hide file tree", "⇧F"]]], ]); press(sheet, { key: "Escape" }); diff --git a/packages/panel/src/tasks/agent-changes-tab.tsx b/packages/panel/src/tasks/agent-changes-tab.tsx index e01b454af0..45872fc33e 100644 --- a/packages/panel/src/tasks/agent-changes-tab.tsx +++ b/packages/panel/src/tasks/agent-changes-tab.tsx @@ -83,14 +83,16 @@ import { suggestedLines, textsOf, threadsOn, + treeOrder, } from "./changes-model.js"; +import { ChangesStrip } from "./changes-strip.js"; import { agentStillWriting, pullRequestNumberLabel } from "./coding-agent-model.js"; import { type DiffListEntry, type DiffNote, PierreDiffList } from "./diff-list.js"; import { DiffSettingsButton } from "./diff-settings.js"; import { useDiffLook, useSplitRoom } from "./file-diff.js"; import { LinesMenu, useSaveText } from "./file-menu.js"; import { FileTreePane } from "./file-tree-pane.js"; -import { FileViewBar, usePaneRoom } from "./file-view.js"; +import { FileViewBar, type TreeShown, usePaneRoom, useTreeEdge } from "./file-view.js"; import { useFileViewSettings } from "./file-view-settings.js"; import { FilesChangedKeysSheet } from "./files-changed-keys.js"; import { ImageDiff, type ImageSides, imageDrawable } from "./image-diff.js"; @@ -1077,7 +1079,7 @@ export function AgentChangesTab({ /** The task's repository, which the agent works in and the breadcrumbs name; none names the path plain. */ repository: string | null; /** Whether the tree beside File and Changes tabs is open, and its toggle. */ - tree: { readonly open: boolean; readonly onToggle: () => void }; + tree: TreeShown; /** The pull request's review threads, drawn on their lines in each file's diff; none draws none. */ threads?: ChangesThreads | undefined; /** How a comment on lines is made, where the tab lets one be; none offers no comment. */ @@ -1093,6 +1095,7 @@ export function AgentChangesTab({ }); if (reads.current.of !== answer) reads.current = { of: answer, count: reads.current.count + 1 }; const room = usePaneRoom(tree); + const edge = useTreeEdge(tree); const [diffBox, setDiffBox] = useState(null); const roomy = useSplitRoom(diffBox); const pullRequest = answer !== undefined && "pullRequest" in answer ? answer.pullRequest : null; @@ -1101,7 +1104,7 @@ export function AgentChangesTab({ const changed = useMemo(() => { const files = pullRequest?.files ?? []; const paths = files.map((file) => file.path); - return { files, paths, folders: changedFolders(paths) }; + return { files, paths, ordered: treeOrder(files), folders: changedFolders(paths) }; }, [pullRequest]); // Note that the marks are read with each answer the changes read lands with, so the two describe the same push. const viewedFiles = useViewedFiles( @@ -1112,7 +1115,7 @@ export function AgentChangesTab({ const viewSettings = useFileViewSettings(); const minimize = () => viewSettings.change("diffMinimizeComments", !viewSettings.settings.diffMinimizeComments); - const list = useChangesList(changed.files, viewedFiles.marks, minimize); + const list = useChangesList(changed.files, viewedFiles.marks, minimize, room.toggle); const marks = useMemo( () => changeMarks(changed.files, { threads: threads?.list, viewed: list.viewed }), [changed.files, threads?.list, list.viewed], @@ -1248,8 +1251,18 @@ export function AgentChangesTab({ pane={room.beside.pane} float={room.pane} leaving={room.beside.leaving} + width={tree.width?.width} + edge={edge} /> ) : null} + {tree.open ? null : ( + + )} {/* The count stands under the tree, and under the list while the tree stands nowhere beside it, which the stylesheet says (tasks.css). */} diff --git a/packages/panel/src/tasks/agent-pull-request-tab.tsx b/packages/panel/src/tasks/agent-pull-request-tab.tsx index 10523867fc..b656ace51e 100644 --- a/packages/panel/src/tasks/agent-pull-request-tab.tsx +++ b/packages/panel/src/tasks/agent-pull-request-tab.tsx @@ -103,6 +103,7 @@ import { useConversationFilters, } from "./conversation-filters.js"; import { PierreDiff, useDiffLook } from "./file-diff.js"; +import type { TreeShown } from "./file-view.js"; import type { PendingReview } from "./line-comment.js"; import { blockedWords, MergeActions } from "./merge-box.js"; import { @@ -1641,7 +1642,7 @@ export function AgentPullRequestTab({ /** The plan's repository, which the files' breadcrumbs name; none names the path plain. */ repository: string | null; /** Whether the tree beside File tabs and the files is open, and its toggle. */ - tree: { readonly open: boolean; readonly onToggle: () => void }; + tree: TreeShown; view: PullRequestView; onView: (view: PullRequestView) => void; }): React.JSX.Element { diff --git a/packages/panel/src/tasks/changes-list.tsx b/packages/panel/src/tasks/changes-list.tsx index ddd0fa0c71..86fca26d49 100644 --- a/packages/panel/src/tasks/changes-list.tsx +++ b/packages/panel/src/tasks/changes-list.tsx @@ -40,7 +40,8 @@ import type { DiffListDoor } from "./diff-list.js"; * ⇧; collapses every file the filter keeps, or expands them all once all * are collapsed, V marks the file stood on viewed on GitHub, or unmarks it, * and steps on to the next, I turns Minimize inline comments on or off, as - * stage-cli's does, Escape lets go of the file stood on, and `?` + * stage-cli's does, ⇧F shows or hides the file tree beside the list, + * Escape lets go of the file stood on, and `?` * opens the sheet that lists them all (`files-changed-keys.tsx`). Each is a * row of `@luke/settings/shortcuts`'s Files changed table, which the press is * matched against and the sheet and the Keyboard shortcuts page list, so a @@ -181,6 +182,8 @@ export function useChangesList( marks?: ViewedMarks | undefined, /** Turns Minimize inline comments on or off, as I does; none leaves I to the window. */ onMinimize?: (() => void) | undefined, + /** Shows or hides the file tree beside the list, as ⇧F does; none leaves ⇧F to the window. */ + onToggleTree?: (() => void) | undefined, ): ChangesList { const [query, onQuery] = useState(""); const [chosen, setChosen] = useState>(new Map()); @@ -301,8 +304,8 @@ export function useChangesList( const onFile = (run: (path: string) => void) => current === undefined ? undefined : () => run(current); // What each of the view's keys does as the list stands, none where it has - // nothing to act on: every key but `?` needs a file to stand on, V the - // marks read, and I a Minimize inline comments to turn. Note that the record is total over the table, so a key + // nothing to act on: every key but `?` and ⇧F needs a file to stand on, V + // the marks read, I a Minimize inline comments to turn, and ⇧F a tree. Note that the record is total over the table, so a key // added to the table does not build until it is handled here. const keyRuns = () => ({ @@ -325,6 +328,7 @@ export function useChangesList( [FILES_CHANGED_KEY.MINIMIZE_COMMENTS]: onMinimize === undefined ? undefined : onFile(() => onMinimize()), [FILES_CHANGED_KEY.SHOW_SHORTCUTS]: () => setKeysShown(true), + [FILES_CHANGED_KEY.TOGGLE_TREE]: onToggleTree, }) satisfies Record void) | undefined>; const onKeyDown = (event: React.KeyboardEvent) => { const key = fileKeyOf(event, keptStanding !== undefined); diff --git a/packages/panel/src/tasks/changes-strip.tsx b/packages/panel/src/tasks/changes-strip.tsx new file mode 100644 index 0000000000..1b55ed217c --- /dev/null +++ b/packages/panel/src/tasks/changes-strip.tsx @@ -0,0 +1,58 @@ +import type { CodingAgentChangedFile } from "@luke/features/coding-agent-wire"; + +/** + * changes-strip.tsx -- the thin strip Files changed leaves in its tree's place while the tree is hidden: one mark for each changed file, in the tree's order, the pointer resting on it peeking the tree out over the list. + * + * As stage-cli's collapsed picker does, the strip keeps the shape of the + * change set in sight: each file's mark is coloured by what became of it, + * as its letter is in the tree, dimmed once the member marked it viewed, + * and the file the bar names stands out. The marks share the strip's + * height, each no taller than a line's mark, so a large pull request draws + * them thinner rather than out of sight. The pointer resting on the strip + * peeks the tree over the list's right edge, as the right edge itself does + * where the panel is too narrow for the tree beside it (`usePaneRoom`), and + * leaving puts it away. The strip stands only where the tree would stand + * beside the list (tasks.css); it is a picture of the files, read in the + * tree it peeks, so it is hidden from assistive technology, which reaches + * the tree through its toggle and ⇧F. + */ + +/** What the strip wears, from its view's room: the pointer resting on it peeks the tree. */ +export interface StripPeek { + readonly onPointerEnter: () => void; + readonly onPointerLeave: (event: React.PointerEvent) => void; +} + +export function ChangesStrip({ + files, + current, + viewed, + peek, +}: { + /** The changed files, in the tree's order. */ + files: readonly CodingAgentChangedFile[]; + /** The file the bar names, which stands out. */ + current: string | undefined; + /** Whether a file is marked viewed; none while the marks are not read. */ + viewed: ((path: string) => boolean) | undefined; + peek: StripPeek; +}): React.JSX.Element { + return ( + + ); +} diff --git a/packages/panel/src/tasks/file-tree-pane.tsx b/packages/panel/src/tasks/file-tree-pane.tsx index dfc41bda05..2b6291157f 100644 --- a/packages/panel/src/tasks/file-tree-pane.tsx +++ b/packages/panel/src/tasks/file-tree-pane.tsx @@ -1,11 +1,12 @@ import { useAtomValue } from "@effect/atom-react"; import type { TasksTreeAnswer } from "@luke/features/tasks-view"; -import { IconButton, SearchField } from "@luke/ui"; +import { cssCustomProperties, IconButton, SearchField } from "@luke/ui"; import * as AsyncResult from "effect/reactivity/AsyncResult"; import { ListFilterIcon } from "lucide-react"; import { type ReactNode, useEffect, useRef, useState } from "react"; import { TREE_LOAD_FAILED, useSideBundles } from "../bundle-loader.js"; import type { FloatingBox } from "../desktop/hover-pane.js"; +import type { ResizableEdgeProps } from "../desktop/use-resizable-edge.js"; import type { FileMark, FileTreeHandle, FolderEntry, ReadFolder } from "../file-tree/contract.js"; import { Tooltip } from "../tooltip.js"; import { entriesOf } from "./folder-listings.js"; @@ -99,6 +100,8 @@ export function FileTreePane({ pane, float, leaving = false, + width, + edge, }: { /** The File tab's file, revealed in the tree. */ file: string; @@ -127,6 +130,10 @@ export function FileTreePane({ float?: FloatingBox | undefined; /** Whether it is on its way out: drawn over the file's edge rather than beside it, and out of reach. */ leaving?: boolean; + /** How wide it stands, as the panel keeps it for every tree; none stands at its default width. */ + width?: number | undefined; + /** The edge on its left it is dragged wider or narrower by; none draws none. */ + edge?: ResizableEdgeProps | undefined; }): React.JSX.Element { const loaded = useAtomValue(useSideBundles().fileTree); const module = AsyncResult.isSuccess(loaded) ? loaded.value : undefined; @@ -216,6 +223,9 @@ export function FileTreePane({ aria-label={label} data-leaving={leaving ? "" : undefined} inert={leaving} + style={ + width === undefined ? undefined : cssCustomProperties({ "--file-tree-width": `${width}px` }) + } onPointerDown={float?.onPointerDown} onPointerLeave={float?.onPointerLeave} onKeyDown={(event) => { @@ -229,6 +239,7 @@ export function FileTreePane({ field.current?.select(); }} > + {edge === undefined ? null :
} void; + /** Shows or hides the pane where it is seen, as its toggle there does: beside the tab, or over it. */ + readonly toggle: () => void; + /** What a strip standing in the closed pane's place wears: the pointer resting on it peeks the pane over the tab. */ + readonly strip: { + readonly onPointerEnter: () => void; + readonly onPointerLeave: (event: React.PointerEvent) => void; + }; /** What the view's root wears: the marks the stylesheet places the pane by, and Escape anywhere in the view, its bar's toggle included, taking down the pane floating over the tab. */ readonly root: { + readonly ref: React.RefCallback; readonly "data-pane-beside": string; readonly "data-pane-over": PaneFloat; readonly onKeyDown: (event: React.KeyboardEvent) => void; @@ -204,9 +218,15 @@ export interface PaneRoom { /** How near the body's right edge, in CSS pixels, the pointer peeks the pane: the sidebar's own strip at the window's left, `--luke-space-6`. */ const PEEK_EDGE = 6; +/** The toggle that opens the pane beside the tab, which the stylesheet draws only where the pane can stand there. */ +const BESIDE_TOGGLE = '.file-tree-toggle[data-place="beside"]'; + /** The toggle that floats the pane over the tab, which the stylesheet draws only where the pane cannot stand beside it. */ const OVER_TOGGLE = '.file-tree-toggle[data-place="over"]'; +/** The strip a closed pane may leave in its place, which peeks the pane over the tab (`changes-strip.tsx`). */ +const PANE_STRIP = ".file-tree-strip"; + /** What takes the keyboard in a pane opened over the tab: its chosen row, else its first field or button. */ function paneFocus(pane: HTMLElement): HTMLElement | null { return ( @@ -215,10 +235,19 @@ function paneFocus(pane: HTMLElement): HTMLElement | null { ); } -/** Whether the view floats its pane over the tab rather than beside it, which is the stylesheet's to say: its over toggle is drawn. */ +/** Whether an element of the view is drawn, which is the stylesheet's to say. */ +function drawnIn(view: Element | null, selector: string): boolean { + const element = view?.querySelector(selector); + return element !== null && element !== undefined && getComputedStyle(element).display !== "none"; +} + +/** + * Whether the view floats its pane over the tab rather than beside it, + * which is the stylesheet's to say: its over toggle is drawn, or a strip + * stands in the closed pane's place. + */ function floats(view: Element | null): boolean { - const toggle = view?.querySelector(OVER_TOGGLE); - return toggle !== null && toggle !== undefined && getComputedStyle(toggle).display !== "none"; + return drawnIn(view, OVER_TOGGLE) || drawnIn(view, PANE_STRIP); } /** Whether the pane's own view floats it now, a panel widened past its line having docked it. */ @@ -255,8 +284,14 @@ export function usePaneRoom(shown: { floatsNow: floatsAround, }); const closeOver = pane.close; + const view = useRef(null); const peek = () => setOver((was) => (was === PANE_FLOAT.CLOSED ? PANE_FLOAT.PEEK : was)); const unpeek = () => setOver((was) => (was === PANE_FLOAT.PEEK ? PANE_FLOAT.CLOSED : was)); + const toggleOver = () => (over === PANE_FLOAT.OPEN ? closeOver() : pane.open()); + // A pane opened beside the tab floats over it no more. + useEffect(() => { + if (shown.open) setOver(PANE_FLOAT.CLOSED); + }, [shown.open]); return { beside, drawn: beside.drawn || pane.floating, @@ -264,7 +299,7 @@ export function usePaneRoom(shown: { ...shown, over: pane.floating, peeked: over === PANE_FLOAT.PEEK, - onToggleOver: () => (over === PANE_FLOAT.OPEN ? closeOver() : pane.open()), + onToggleOver: toggleOver, float: { onPointerEnter: peek, onPointerLeave: (event) => { @@ -276,7 +311,21 @@ export function usePaneRoom(shown: { }, }, closeOver, + // Note that the pane is shown beside the tab wherever it has room there, + // a strip in its place being no float of its own. + toggle: () => (drawnIn(view.current, BESIDE_TOGGLE) ? shown.onToggle() : toggleOver()), + strip: { + onPointerEnter: peek, + onPointerLeave: (event) => { + const to = event.relatedTarget; + if (to instanceof Node && beside.pane.current?.contains(to)) return; + unpeek(); + }, + }, root: { + ref: (element) => { + view.current = element; + }, "data-pane-beside": String(beside.drawn), "data-pane-over": over, onKeyDown: (event) => { @@ -293,12 +342,15 @@ export function usePaneRoom(shown: { // held, as a drag holds one, peeks nothing. const box = event.currentTarget.getBoundingClientRect(); const atEdge = event.clientX >= box.right - PEEK_EDGE; - const onPane = event.target instanceof Node && beside.pane.current?.contains(event.target); + const onPane = + event.target instanceof Element && + (beside.pane.current?.contains(event.target) === true || + event.target.closest(PANE_STRIP) !== null); if (over === PANE_FLOAT.CLOSED && atEdge && event.buttons === 0) { if (floats(event.currentTarget.parentElement)) peek(); return; } - if (over === PANE_FLOAT.PEEK && !atEdge && onPane !== true) unpeek(); + if (over === PANE_FLOAT.PEEK && !atEdge && !onPane) unpeek(); }, }, pane: { @@ -310,6 +362,37 @@ export function usePaneRoom(shown: { }; } +/** How much of the box the tree stands in a drag leaves the tab beside it, in CSS pixels. */ +const TAB_RESERVE = 320; + +/** The tree as the panel holds it for every File and Changes tab: open or not, and how wide. */ +export interface TreeShown { + readonly open: boolean; + readonly onToggle: () => void; + /** Its width and the drag that asks for another; none draws it at its default width with no edge. */ + readonly width?: FileTreeWidth | undefined; +} + +/** + * The edge the tree beside a tab is dragged by, on its left: between its + * bounds, leaving the tab its room, shut by a drag past its least width, + * reset by a double-click; none where the panel keeps no width for it. + * Note that it is called by the view, which outlives the tree a drag shuts. + */ +export function useTreeEdge(tree: TreeShown): ResizableEdgeProps | undefined { + const width = tree.width; + const edge = useResizableEdge({ + side: EDGE_SIDE.LEFT, + width: width?.width ?? FILE_TREE_WIDTH.DEFAULT, + bounds: FILE_TREE_WIDTH, + reserve: () => TAB_RESERVE, + label: "Resize files", + onResize: (asked) => width?.onResize(asked), + onToggleCollapsed: tree.onToggle, + }); + return width === undefined ? undefined : edge; +} + /** A File tab's history as its bar walks it: whether each way holds a file, and the step. */ export interface FileHistory { readonly canGoBack: boolean; @@ -443,6 +526,7 @@ export function FileView({ openGitHub, treeOpen, onToggleTree, + treeWidth, onBrowse, history, onFoldNote, @@ -464,6 +548,8 @@ export function FileView({ openGitHub: (url: string) => void; treeOpen: boolean; onToggleTree: () => void; + /** How wide the tree stands, and the drag that asks for another width; none draws it at its default width. */ + treeWidth?: FileTreeWidth | undefined; /** A file chosen in the tree or the breadcrumbs, to show in this tab, or in a tab of its own where `newTab` asks. */ onBrowse: (path: string, newTab: boolean) => void; history: FileHistory; @@ -532,6 +618,7 @@ export function FileView({ ); const browsing = repository !== null; const tree = usePaneRoom({ open: browsing && treeOpen, onToggle: onToggleTree }); + const edge = useTreeEdge({ open: treeOpen, onToggle: onToggleTree, width: treeWidth }); // A file chosen in the tree floating over the tab takes the tree down, to show the file. const browse = (path: string, newTab: boolean) => { tree.closeOver(); @@ -583,6 +670,8 @@ export function FileView({ pane={tree.beside.pane} float={tree.pane} leaving={tree.beside.leaving} + width={treeWidth?.width} + edge={edge} /> ) : null}
diff --git a/packages/settings/src/shortcuts.ts b/packages/settings/src/shortcuts.ts index 20a03aeee5..12cc45b528 100644 --- a/packages/settings/src/shortcuts.ts +++ b/packages/settings/src/shortcuts.ts @@ -592,6 +592,7 @@ export const FILES_CHANGED_KEY = { MARK_VIEWED: "files-changed-mark-viewed", MINIMIZE_COMMENTS: "files-changed-minimize-comments", SHOW_SHORTCUTS: "files-changed-show-shortcuts", + TOGGLE_TREE: "files-changed-toggle-tree", } as const; export type FilesChangedKey = (typeof FILES_CHANGED_KEY)[keyof typeof FILES_CHANGED_KEY]; @@ -607,8 +608,9 @@ interface FilesChangedShortcut { * against, and its `?` sheet and the Keyboard shortcuts page list, so what * is listed is what answers. Escape lets go of the file stood on only while * one is, since otherwise it is the window's layered Escape, V answers - * only once the member's viewed marks are read, and I only where Minimize - * inline comments can be turned. + * only once the member's viewed marks are read, I only where Minimize + * inline comments can be turned, and ⇧F shows or hides the file tree + * beside the list, as its bar's toggle does. */ export const FILES_CHANGED_SHORTCUTS = { [FILES_CHANGED_KEY.NEXT_FILE]: { @@ -643,6 +645,10 @@ export const FILES_CHANGED_SHORTCUTS = { label: "Show keyboard shortcuts", chord: { key: SHORTCUT_KEY.SLASH, plain: true, shift: true }, }, + [FILES_CHANGED_KEY.TOGGLE_TREE]: { + label: "Show or hide file tree", + chord: { key: SHORTCUT_KEY.F, plain: true, shift: true }, + }, } as const satisfies Record; /** @@ -666,6 +672,7 @@ export const FILES_CHANGED_SHORTCUT_GROUPS = [ title: "Review", keys: [FILES_CHANGED_KEY.MARK_VIEWED, FILES_CHANGED_KEY.MINIMIZE_COMMENTS], }, + { title: "Panels", keys: [FILES_CHANGED_KEY.TOGGLE_TREE] }, ] as const satisfies readonly { title: string; keys: readonly FilesChangedKey[] }[]; /** The Keyboard shortcuts page's group of Files changed's keys, and its settings search's words for them. */