diff --git a/packages/panel/src/file-tree/contract.ts b/packages/panel/src/file-tree/contract.ts index c450ecd0a4..47d845583e 100644 --- a/packages/panel/src/file-tree/contract.ts +++ b/packages/panel/src/file-tree/contract.ts @@ -50,10 +50,25 @@ export const FILE_MARK = { export type FileMarkStatus = (typeof FILE_MARK)[keyof typeof FILE_MARK]; -/** One file's mark: its path and what became of it. */ +/** + * One file's mark: its path and what became of it, and, for a pull + * request's file, what Files changed knows of it beside: the lines it adds + * and removes, how many review threads stand on it, and whether the member + * marked it viewed, each drawn at the end of its row where given. + */ export interface FileMark { readonly path: string; readonly status: FileMarkStatus; + /** The lines it adds and removes, drawn compact where the row has room. */ + readonly lines?: { readonly additions: number; readonly deletions: number } | undefined; + /** How many review threads stand on it, drawn by a speech mark where any do. */ + readonly threads?: number | undefined; + /** + * Whether the member marked it viewed, which dims its row and draws its + * Viewed toggle ticked, the toggle drawn otherwise only while the row is + * hovered; none draws no toggle, as before the marks are read. + */ + readonly viewed?: boolean | undefined; } /** What the tab hands the tree when it mounts it. */ @@ -66,6 +81,13 @@ export interface FileTreeProps { readonly readFolder: ReadFolder; /** A file chosen in the tree, by a press or Return: open it, in a tab of its own where ⌘ was held. */ readonly onOpen: (path: string, newTab: boolean) => void; + /** + * A file's Viewed toggle pressed on its row, or V pressed on the row the + * tree's keyboard stands on: mark it viewed, or not, as its header's box + * does. The press opens nothing, and the tree's keyboard stays where it + * stood. + */ + readonly onViewed?: (path: string) => void; /** * How many rows the repository's tree shows, each time a folder listed, * opened, or closed changes it, which a breadcrumb's tree sizes its box @@ -104,8 +126,8 @@ export interface FileTreeHandle { readonly within: (entries: readonly FolderEntry[]) => void; /** * Marks each file's row with what became of it, as an editor's source - * control does, and each folder above one as holding a change; none - * marks nothing. + * control does, and each folder above one as holding a change, each file's + * row drawing too whatever else its mark carries; none marks nothing. */ readonly mark: (marks: readonly FileMark[]) => void; readonly unmount: () => void; diff --git a/packages/panel/src/file-tree/index.test.ts b/packages/panel/src/file-tree/index.test.ts new file mode 100644 index 0000000000..6781858249 --- /dev/null +++ b/packages/panel/src/file-tree/index.test.ts @@ -0,0 +1,157 @@ +// @vitest-environment jsdom + +import assert from "node:assert/strict"; +import { afterEach, test } from "vitest"; +import { FILE_MARK, type FileMark, type FileTreeHandle } from "./contract.js"; +import { fileTree } from "./index.js"; + +/** + * index.test.ts -- a change set's tree draws at each file's row's end what + * its mark carries past what became of it: how many review threads stand on + * it, the lines it adds and removes, and a Viewed toggle, ticked once the + * file is viewed; a folder's row says nothing more. A press on the toggle + * is the toggle's alone, the row neither opening its file nor taking the + * keyboard, while a press anywhere else on the row opens the file, and V + * on the row the tree's keyboard stands on presses its toggle. The + * tree is the bundle's own, Pierre drawing it in jsdom; synthetic paths. + */ + +const PATHS = ["src/seats.ts", "src/plan.md", "README.md"] as const; + +let mounted: { host: HTMLElement; handle: FileTreeHandle } | undefined; + +afterEach(() => { + mounted?.handle.unmount(); + mounted = undefined; + document.body.innerHTML = ""; +}); + +/** Lets the tree's listing land, which Pierre draws as it lands, on no clock. */ +async function settle(): Promise { + for (let tick = 0; tick < 12; tick += 1) await Promise.resolve(); +} + +/** The tree mounted over the change set's files alone, every folder open, marked as given, with what it opened and marked. */ +async function mountMarked(marks: readonly FileMark[]) { + const opened: string[] = []; + const viewed: string[] = []; + const host = document.body.appendChild(document.createElement("div")); + const handle = fileTree.mount(host, { + readFolder: () => Promise.resolve({ entries: [], truncated: false }), + onOpen: (path) => opened.push(path), + onViewed: (path) => viewed.push(path), + }); + mounted = { host, handle }; + await settle(); + handle.only(PATHS); + handle.mark(marks); + await settle(); + return { handle, opened, viewed }; +} + +/** The row the tree drew for a path, through its shadow root. */ +function row(path: string): HTMLElement { + for (const container of mounted?.host.querySelectorAll("file-tree-container") ?? []) { + const found = container.shadowRoot?.querySelector(`[data-item-path="${path}"]`); + if (found !== null && found !== undefined && container.closest("[hidden]") === null) { + return found; + } + } + return assert.fail(`the tree draws ${path}`); +} + +/** The words at the end of a row, part by part. */ +function parts(path: string): string[] { + const run = row(path).querySelector('[data-item-section="decoration"] > span'); + return [...(run?.children ?? [])].map((part) => part.textContent ?? ""); +} + +/** A row's Viewed toggle, the last part of its run. */ +function toggle(path: string): HTMLElement { + const run = row(path).querySelector('[data-item-section="decoration"] > span'); + const part = run?.children[4]; + return part instanceof HTMLElement ? part : assert.fail(`${path} draws a Viewed toggle`); +} + +const MARKS: readonly FileMark[] = [ + { + path: "src/seats.ts", + status: FILE_MARK.MODIFIED, + lines: { additions: 12, deletions: 4 }, + threads: 2, + viewed: false, + }, + { + path: "src/plan.md", + status: FILE_MARK.ADDED, + lines: { additions: 30, deletions: 0 }, + threads: 0, + viewed: true, + }, + { path: "README.md", status: FILE_MARK.DELETED }, +]; + +test("a file's row ends in its threads, its lines, and its Viewed toggle; a folder's and a bare mark's say nothing more", async () => { + await mountMarked(MARKS); + + assert.deepEqual(parts("src/seats.ts"), ["", "2", "+12", "−4", ""]); + assert.deepEqual(parts("src/plan.md"), ["", "", "+30", "", "Viewed"]); + assert.match( + row("src/seats.ts").querySelector("[title]")?.getAttribute("title") ?? "", + /2 comments/u, + ); + assert.deepEqual(parts("README.md"), []); + assert.deepEqual(parts("src/"), []); +}); + +test("a press on a row's Viewed toggle marks its file and opens nothing; a press on the rest of the row opens it", async () => { + const { opened, viewed } = await mountMarked(MARKS); + + toggle("src/seats.ts").dispatchEvent(new MouseEvent("click", { bubbles: true, composed: true })); + assert.deepEqual(viewed, ["src/seats.ts"]); + assert.deepEqual(opened, []); + + row("src/seats.ts").click(); + assert.deepEqual(opened, ["src/seats.ts"]); + assert.deepEqual(viewed, ["src/seats.ts"]); +}); + +test("V on the row the tree's keyboard stands on presses that row's toggle and goes no further than the tree", async () => { + const { viewed } = await mountMarked(MARKS); + let heard = 0; + document.body.addEventListener("keydown", () => { + heard += 1; + }); + + row("src/seats.ts").focus(); + row("src/seats.ts").dispatchEvent( + new KeyboardEvent("keydown", { key: "v", bubbles: true, composed: true }), + ); + row("src/seats.ts").dispatchEvent( + new KeyboardEvent("keydown", { key: "v", isComposing: true, bubbles: true, composed: true }), + ); + row("README.md").focus(); + row("README.md").dispatchEvent( + new KeyboardEvent("keydown", { key: "v", bubbles: true, composed: true }), + ); + + assert.deepEqual(viewed, ["src/seats.ts"]); + assert.equal(heard, 2, "a V composed, and V on a row with no toggle, are left to the view"); +}); + +test("marks handed again redraw the rows, a file marked viewed since ticked", async () => { + const { handle } = await mountMarked(MARKS); + + handle.mark( + MARKS.map((mark) => ({ ...mark, viewed: mark.viewed === undefined ? undefined : true })), + ); + await settle(); + + assert.equal(parts("src/seats.ts")[4], "Viewed"); +}); + +test("a mark that does not say whether its file is viewed draws no toggle", async () => { + await mountMarked([{ path: "src/seats.ts", status: FILE_MARK.MODIFIED, threads: 1 }]); + + assert.deepEqual(parts("src/seats.ts"), ["", "1", "", ""]); +}); diff --git a/packages/panel/src/file-tree/index.ts b/packages/panel/src/file-tree/index.ts index 4cd54e8996..c0873f9dc6 100644 --- a/packages/panel/src/file-tree/index.ts +++ b/packages/panel/src/file-tree/index.ts @@ -1,4 +1,9 @@ -import { createFileTreeIconResolver, FileTree, getBuiltInSpriteSheet } from "@pierre/trees"; +import { + createFileTreeIconResolver, + FileTree, + type FileTreeRowDecorationRenderer, + getBuiltInSpriteSheet, +} from "@pierre/trees"; import type { FileMark, FileTreeHandle, @@ -15,6 +20,7 @@ import { repositoryPath, treePath, } from "./folder-browser.js"; +import { dress, pressedToggle, rowDecoration } from "./row-marks.js"; /** * index.ts -- the file tree bundle: Pierre's tree mounted beside a File tab, listed a folder at a time, a file opened on its press. @@ -45,7 +51,10 @@ import { * open. Each stands in the repository's place while it is asked for, so the * folders opened there are as they were when it is let go. A change set's * tree marks each file's row with what became of it, through Pierre's own - * git status, in whichever of them stands. + * git status, in whichever of them stands, and a pull request's draws at + * each file's row's end its review threads, its lines, and its Viewed + * toggle (`row-marks.ts`), a press on which marks the file rather than + * opening it, as V does on the row the tree's keyboard stands on. * * A breadcrumb's tree is this same tree, mounted under the crumb over the * folder the crumb stands in, with its rows counted out as they change so @@ -63,18 +72,14 @@ const ROW = { } as const; /** - * How both trees draw: compact rows, each file's type in colour, a lone - * folder sharing its parent's row, and the window's thin scrollbar - * (`styles/desktop.css`), whose colour inherits into Pierre's shadow root - * and whose width does not, so it is set there on the scroller and on the - * box Pierre measures that scroller's gutter from. + * How both trees draw: compact rows, each file's type in colour, and a lone + * folder sharing its parent's row; the rest of their look is the sheet each + * is dressed in (`row-marks.ts`). */ const TREE_LOOK = { flattenEmptyDirectories: true, density: "compact", icons: "complete", - unsafeCSS: - "[data-file-tree-virtualized-scroll], [data-file-tree-scrollbar-measure] { scrollbar-width: thin; }", } as const; /** The slot Pierre resolves a file's type icon for, and the type of a file no rule names. */ @@ -83,6 +88,9 @@ const FILE_ICON = { DEFAULT_TYPE: "default", } as const; +/** The key that presses the Viewed toggle of the row the tree's keyboard stands on, as the list's V marks its file. */ +const VIEWED_KEY = "v"; + /** What the pane says while the root is listed. */ const READING = "Reading the files…"; @@ -158,9 +166,18 @@ interface View { readonly frame: HTMLElement; } -/** A tree drawn into a frame of its own over the paths given, Pierre's names for them. */ -function viewOf(paths: readonly string[], expansion: "open" | "closed"): View { - const tree = new FileTree({ ...TREE_LOOK, paths, initialExpansion: expansion }); +/** A tree drawn into a frame of its own over the paths given, Pierre's names for them, each row's end drawn by `decorate`. */ +function viewOf( + paths: readonly string[], + expansion: "open" | "closed", + decorate: FileTreeRowDecorationRenderer, +): View { + const tree = new FileTree({ + ...TREE_LOOK, + paths, + initialExpansion: expansion, + renderRowDecoration: decorate, + }); const frame = document.createElement("div"); frame.className = "file-tree-view"; tree.render({ containerWrapper: frame }); @@ -214,6 +231,7 @@ function mount(host: HTMLElement, props: FileTreeProps): FileTreeHandle { let query = ""; let marked: string | undefined; let marks: readonly FileMark[] = []; + let markOf = new Map(); let taken = false; // The reveal asked before the tree is drawn. let waiting: { path: string; options: RevealOptions } | undefined; @@ -222,6 +240,10 @@ function mount(host: HTMLElement, props: FileTreeProps): FileTreeHandle { const cut = notice(CUT_SHORT, undefined); cut.classList.add("file-tree-cut"); + // Each file's row ends as its mark says; a folder's says nothing more. + const decorate: FileTreeRowDecorationRenderer = ({ item }) => + item.kind === "file" ? rowDecoration(markOf.get(repositoryPath(item.path))) : null; + // The tree standing now, drawn with the search held: the files Luke // showed while the filter is on, the whole repository while a search // stands and it was handed, the folders listed so far otherwise; or the @@ -229,11 +251,11 @@ function mount(host: HTMLElement, props: FileTreeProps): FileTreeHandle { const standing = (): View | undefined => { if (browse === undefined) return undefined; if (only !== undefined) { - shown ??= framed(viewOf(only, "open")); + shown ??= framed(viewOf(only, "open", decorate)); return shown; } if (query.trim() === "" || everything === undefined) return browse; - whole ??= framed(viewOf(everything, "closed")); + whole ??= framed(viewOf(everything, "closed", decorate)); return whole; }; // A view made after the tree was drawn joins it, the tab's file marked. @@ -241,6 +263,7 @@ function mount(host: HTMLElement, props: FileTreeProps): FileTreeHandle { if (marked !== undefined) markAlone(view.tree, marked, false); view.tree.setGitStatus(marks); empty.before(view.frame); + dress(view.tree); return view; }; const present = () => { @@ -255,10 +278,11 @@ function mount(host: HTMLElement, props: FileTreeProps): FileTreeHandle { }; const draw = () => { - browse = viewOf([], "closed"); + browse = viewOf([], "closed", decorate); browse.tree.setGitStatus(marks); empty.hidden = true; host.replaceChildren(browse.frame, empty); + dress(browse.tree); // Note that a search stands over folders still being listed, so whether // it matches anything is asked again as each listing lands. const tree = browse.tree; @@ -293,11 +317,40 @@ function mount(host: HTMLElement, props: FileTreeProps): FileTreeHandle { if (row?.dataset.itemType !== ROW.FILE || path === undefined) return; props.onOpen(repositoryPath(path), event.metaKey || event.ctrlKey); }; + // Note that a press on a Viewed toggle is heard on its way down, before + // the row it stands in, so it goes no further: the row neither opens nor + // takes the keyboard. + const onToggle = (event: MouseEvent) => { + const path = pressedToggle(event); + if (path === undefined || props.onViewed === undefined) return; + event.preventDefault(); + event.stopPropagation(); + if (event.type === "click") props.onViewed(repositoryPath(path)); + }; + // Note that V on a row whose toggle is drawn is that row's toggle, as + // the list's own V is its file's, so it goes no further than the tree. const onKey = (event: KeyboardEvent) => { const path = standing()?.tree.getFocusedPath() ?? null; - if (event.key !== "Enter" || path === null || path.endsWith("/")) return; - props.onOpen(repositoryPath(path), event.metaKey || event.ctrlKey); + if (path === null || path.endsWith("/")) return; + if (event.key === "Enter") + return props.onOpen(repositoryPath(path), event.metaKey || event.ctrlKey); + const plain = + !event.isComposing && !(event.metaKey || event.ctrlKey || event.altKey || event.shiftKey); + const toggles = markOf.get(repositoryPath(path))?.viewed !== undefined; + if ( + !plain || + event.key.toLowerCase() !== VIEWED_KEY || + !toggles || + props.onViewed === undefined + ) { + return; + } + event.preventDefault(); + event.stopPropagation(); + props.onViewed(repositoryPath(path)); }; + host.addEventListener("mousedown", onToggle, true); + host.addEventListener("click", onToggle, true); host.addEventListener("click", onClick); host.addEventListener("keydown", onKey); list(); @@ -329,10 +382,20 @@ function mount(host: HTMLElement, props: FileTreeProps): FileTreeHandle { }, mark: (asked) => { marks = asked; - for (const view of [browse, shown, whole]) view?.tree.setGitStatus(marks); + markOf = new Map(asked.map((mark) => [mark.path, mark])); + // Note that Pierre draws its rows again for a change of status alone, + // so a change to what else a mark carries is drawn by asking it to. + for (const view of [browse, shown, whole]) { + if (view === undefined) continue; + view.tree.setGitStatus(marks); + view.tree.setComposition(view.tree.getComposition()); + dress(view.tree); + } }, unmount: () => { taken = true; + host.removeEventListener("mousedown", onToggle, true); + host.removeEventListener("click", onToggle, true); host.removeEventListener("click", onClick); host.removeEventListener("keydown", onKey); unheard?.(); diff --git a/packages/panel/src/file-tree/row-marks.ts b/packages/panel/src/file-tree/row-marks.ts new file mode 100644 index 0000000000..968f8ba0ec --- /dev/null +++ b/packages/panel/src/file-tree/row-marks.ts @@ -0,0 +1,201 @@ +import type { FileTree, FileTreeRowDecoration } from "@pierre/trees"; +import type { FileMark } from "./contract.js"; + +/** + * row-marks.ts -- what a pull request's file row says at its end, past what became of it: its review threads, the lines it adds and removes, and its Viewed toggle, as stage-cli's tree draws them; and the sheet the tree draws them by. + * + * Pierre draws one decoration in each row's lane, a run of words in the + * colours given, so a file's are drawn as one run of five parts in a fixed + * order (`PART`): a speech mark and the count of the threads standing on + * it; the lines it adds; the lines it removes; and its Viewed toggle. A + * part with nothing to say holds no words, and the sheet draws no room for + * it. The sheet draws the speech mark and the toggle's circle, ticked once + * the file is viewed, as Lucide's `MessageSquare`, `Circle`, and + * `CircleCheck`, and finds each part by its place in the run. + * + * The name comes first: the lane holds only the room the name leaves, as + * Pierre lays it out, so no mark ever cuts a name short. Where that room + * runs out the run gives up its parts from its end, one line of a run that + * wraps with its second line out of sight: the lines removed, then the + * lines added, then the count, then the speech mark. The toggle takes no + * room of its own while the row is hovered or holds the keyboard: it + * stands over the run's end, the row's ground behind it, in place of the + * counts. Once ticked it stands there always, in room the lane keeps for + * it, so a viewed file's name is cut short before it rather than under + * it, and the row is dimmed, as stage-cli dims a viewed file. A folder's + * row says nothing more. + * + * Note that the sheet is adopted by the tree's shadow root as a + * constructed sheet rather than handed to Pierre's `unsafeCSS`, because + * Pierre writes that into a `