Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
ceeafba
fix(desktop): minimize inline comments to one chip per line, as stage…
dastratakos Oct 19, 2026
92655cb
Merge branch 'main' into luke/files-changed-minimize-threads
dastratakos Oct 19, 2026
b5b312d
fix(desktop): keep a minimized line's fold mounted, and give threads …
dastratakos Oct 19, 2026
6eafcab
style(desktop): Files changed as cards that share one edge, with a qu…
dastratakos Oct 19, 2026
e454bc1
Merge branch 'main' into luke/files-changed-minimize-threads
dastratakos Oct 19, 2026
c20be68
Merge branch 'luke/files-changed-minimize-threads' into luke/files-ch…
dastratakos Oct 19, 2026
c1e257c
Merge main into luke/files-changed-minimize-threads
dastratakos Oct 19, 2026
c415bbb
Merge luke/files-changed-minimize-threads into luke/files-changed-design
dastratakos Oct 19, 2026
88a5818
Merge main into luke/files-changed-design
dastratakos Oct 19, 2026
39f3d70
feat(desktop): mark files viewed and count their threads and lines on…
dastratakos Oct 19, 2026
67f02a8
Merge remote-tracking branch 'origin/luke/files-changed-design' into …
dastratakos Oct 19, 2026
b479eef
fix(desktop): keep a tree row's toggle room only while the toggle is …
dastratakos Oct 19, 2026
cf1e3f3
fix(desktop): let a tree row's name win over its marks, the toggle st…
dastratakos Oct 19, 2026
25622f8
fix(desktop): let V press the focused tree row's toggle, and settle t…
dastratakos Oct 19, 2026
41a8c08
fix(desktop): leave a V typed through an input method to the view
dastratakos Oct 19, 2026
5653b55
Merge remote-tracking branch 'origin/main' into luke/files-changed-tr…
dastratakos Oct 19, 2026
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
28 changes: 25 additions & 3 deletions packages/panel/src/file-tree/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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
Expand Down Expand Up @@ -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;
Expand Down
157 changes: 157 additions & 0 deletions packages/panel/src/file-tree/index.test.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<HTMLElement>(`[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", "", ""]);
});
Loading
Loading