Merge branch 'pr-92-review' into test/pr-92

This commit is contained in:
Federico Jaramillo Martinez
2026-07-24 10:21:29 +02:00
16 changed files with 1055 additions and 73 deletions
+22
View File
@@ -505,6 +505,28 @@ describe("API parsers", () => {
expect(() => parseGitStatusResponse({ isGitRepo: true, hash: "h", files: [{ path: "a", index: "weird", workingTree: "modified" }] })).toThrow("Invalid git file state");
});
it("parses submodule paths and pointer commit fields", () => {
const parsed = parseGitStatusResponse({
isGitRepo: true,
hash: "h",
branch: "main",
files: [
{ path: "HARL", index: "unmodified", workingTree: "modified", submoduleFromCommit: "1111111", submoduleToCommit: "2222222" },
{ path: "HARL/inner.txt", index: "modified", workingTree: "modified" },
],
submodules: ["HARL"],
});
expect(parsed.submodules).toEqual(["HARL"]);
expect(parsed.files[0]?.submoduleFromCommit).toBe("1111111");
expect(parsed.files[0]?.submoduleToCommit).toBe("2222222");
expect(parsed.files[1]?.submoduleFromCommit).toBeUndefined();
});
it("defaults submodules to an empty list when absent", () => {
const parsed = parseGitStatusResponse({ isGitRepo: true, hash: "h", files: [] });
expect(parsed.submodules).toEqual([]);
});
it("validates file content responses", () => {
const textFile = {
path: "README.md",
+2 -2
View File
@@ -818,12 +818,12 @@ function optionalFileMediaType(value: unknown): FileContentResponse["mediaType"]
export function parseGitStatusResponse(value: unknown): GitStatusResponse {
const record = requireRecord(value);
return { isGitRepo: requireBoolean(record, "isGitRepo"), hash: requireString(record, "hash"), ...optionalField("branch", optionalString(record, "branch")), ...optionalField("upstream", optionalString(record, "upstream")), ...optionalField("ahead", optionalNumber(record, "ahead")), ...optionalField("behind", optionalNumber(record, "behind")), files: arrayOf(parseGitStatusFile)(record["files"]) };
return { isGitRepo: requireBoolean(record, "isGitRepo"), hash: requireString(record, "hash"), ...optionalField("branch", optionalString(record, "branch")), ...optionalField("upstream", optionalString(record, "upstream")), ...optionalField("ahead", optionalNumber(record, "ahead")), ...optionalField("behind", optionalNumber(record, "behind")), files: arrayOf(parseGitStatusFile)(record["files"]), submodules: record["submodules"] === undefined ? [] : arrayOfString(record["submodules"], "submodules") };
}
function parseGitStatusFile(value: unknown): GitStatusFile {
const record = requireRecord(value);
return { path: requireString(record, "path"), ...optionalField("oldPath", optionalString(record, "oldPath")), index: parseGitFileState(record["index"]), workingTree: parseGitFileState(record["workingTree"]) };
return { path: requireString(record, "path"), ...optionalField("oldPath", optionalString(record, "oldPath")), index: parseGitFileState(record["index"]), workingTree: parseGitFileState(record["workingTree"]), ...optionalField("submoduleFromCommit", optionalString(record, "submoduleFromCommit")), ...optionalField("submoduleToCommit", optionalString(record, "submoduleToCommit")) };
}
function parseGitFileState(value: unknown): GitFileState {
@@ -0,0 +1,244 @@
import { css, html, LitElement, type PropertyValues, type TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import type { GitDiffResponse, GitStatusFile, GitStatusResponse } from "../api";
import { buildGitFileTree, collectGitFileTreeDirectoryPaths, type GitFileTreeNode } from "../gitFileTree";
import { buildGitFileList, type GitFileListModel, type GitFileListSubmoduleFile, type GitFileListSubmoduleGroup } from "../gitFileList";
import { readGitFileView, writeGitFileView, type GitFileView } from "../gitFileViewPreference";
import type { WorkspacePanelContext } from "../plugins/types";
import { workspacePanelStyles } from "./shared";
interface GitViewState {
readonly nodes: readonly GitFileTreeNode[];
readonly listModel: GitFileListModel;
readonly expandablePaths: readonly string[];
}
const EMPTY_LIST_MODEL: GitFileListModel = { submodules: [], files: [] };
const EMPTY_VIEW_STATE: GitViewState = { nodes: [], listModel: EMPTY_LIST_MODEL, expandablePaths: [] };
@customElement("workspace-git-panel")
export class WorkspaceGitPanel extends LitElement {
static override styles = [
workspacePanelStyles,
css`
:host { flex: 1 1 auto; }
.toolbar-actions { display: flex; align-items: center; gap: 8px; margin-left: auto; }
.toolbar .toolbar-actions button { margin-left: 0; }
.view-toggle { display: inline-flex; }
.view-toggle button { border-radius: 0; }
.view-toggle button:first-child { border-top-left-radius: 7px; border-bottom-left-radius: 7px; }
.view-toggle button:last-child { border-top-right-radius: 7px; border-bottom-right-radius: 7px; margin-left: -1px; }
.view-toggle button.selected { position: relative; z-index: 1; }
.row .twisty { color: var(--pi-dim, var(--pi-muted)); }
.submodule-badge { display: inline-block; margin-left: 6px; border: 1px solid var(--pi-border); border-radius: 999px; color: var(--pi-muted); padding: 0 5px; font-size: 11px; font-weight: 400; vertical-align: baseline; }
`,
];
@property({ attribute: false }) context: WorkspacePanelContext | undefined;
// Persisted across sessions via localStorage; only the mode is remembered.
@state() private view: GitFileView = readGitFileView();
// Ephemeral by design: the tree always opens fully collapsed, and expand
// state is intentionally not persisted. Reassigned (never mutated in place)
// so Lit observes the change. Shared by tree directories and, in list view,
// submodule groups, both keyed by their path.
@state() private expandedDirectories = new Set<string>();
protected override willUpdate(changedProperties: PropertyValues<this>): void {
if (!changedProperties.has("context")) return;
const previous = changedProperties.get("context");
if (previous !== undefined && this.context !== undefined && gitPanelContextKey(previous) !== gitPanelContextKey(this.context)) {
// Switching workspace/machine resets the ephemeral expand state.
this.expandedDirectories = new Set();
}
}
override render(): TemplateResult {
const context = this.context;
if (context === undefined) return html`<p class="muted">Git unavailable.</p>`;
const status = context.gitStatus;
const viewState = this.computeViewState(status);
return html`
<section class="toolbar">
<strong>Git</strong>
${context.gitStale ? html`<span class="stale">stale</span>` : null}
<div class="toolbar-actions">
${this.renderViewToggle()}
${viewState.expandablePaths.length > 0 ? this.renderExpandCollapseAll(viewState.expandablePaths) : null}
<button type="button" @click=${context.onRefreshGit}>Refresh</button>
</div>
</section>
<section class="split">
<div class=${this.view === "tree" ? "list tree" : "list"}>
${this.renderFileList(context, status, viewState)}
</div>
<div class="viewer">${renderDiffViewer(context)}</div>
</section>
`;
}
private renderViewToggle(): TemplateResult {
return html`
<div class="view-toggle" role="group" aria-label="Changed files view">
${this.renderViewToggleButton("list", "List")}
${this.renderViewToggleButton("tree", "Tree")}
</div>
`;
}
private renderViewToggleButton(view: GitFileView, label: string): TemplateResult {
const active = this.view === view;
return html`
<button type="button" class=${active ? "selected" : ""} aria-pressed=${active ? "true" : "false"} @click=${() => { this.setView(view); }}>${label}</button>
`;
}
private renderExpandCollapseAll(expandablePaths: readonly string[]): TemplateResult {
const allExpanded = expandablePaths.every((path) => this.expandedDirectories.has(path));
return html`
<button type="button" @click=${() => { this.toggleExpandAll(expandablePaths, allExpanded); }}>${allExpanded ? "Collapse all" : "Expand all"}</button>
`;
}
private renderFileList(context: WorkspacePanelContext, status: GitStatusResponse | undefined, viewState: GitViewState): TemplateResult {
if (status === undefined) return html`<p class="muted">No status loaded.</p>`;
if (!status.isGitRepo) return html`<p class="muted">Not a git repository.</p>`;
const summary = html`<p class="summary">${gitSummary(status)}</p>`;
if (status.files.length === 0) return html`${summary}<p class="muted">No changes.</p>`;
const body = this.view === "tree"
? viewState.nodes.map((node) => this.renderTreeNode(context, node, 0))
: this.renderListBody(context, viewState.listModel);
return html`${summary}${body}`;
}
private renderListBody(context: WorkspacePanelContext, model: GitFileListModel): TemplateResult {
return html`
${model.submodules.map((group) => this.renderSubmoduleGroup(context, group))}
${model.files.map((file) => this.renderFileRow(context, file))}
`;
}
private renderSubmoduleGroup(context: WorkspacePanelContext, group: GitFileListSubmoduleGroup): TemplateResult {
const expanded = this.expandedDirectories.has(group.path);
return html`
<button type="button" class="row" style="--depth:0" aria-expanded=${expanded ? "true" : "false"} @click=${() => { this.toggleDirectory(group.path); }}>
<span class="twisty">${expanded ? "▾" : "▸"}</span>
<span>${group.name}${submoduleBadge()}</span>
</button>
${expanded ? html`
${group.pointer === undefined ? null : this.renderSelectableRow(context, group.path, group.pointer.name, group.pointer.file, 1)}
${group.files.map((entry) => this.renderSubmoduleFileRow(context, entry))}
` : null}
`;
}
private renderSubmoduleFileRow(context: WorkspacePanelContext, entry: GitFileListSubmoduleFile): TemplateResult {
return this.renderSelectableRow(context, entry.path, entry.relativePath, entry.file, 1);
}
private renderTreeNode(context: WorkspacePanelContext, node: GitFileTreeNode, depth: number): TemplateResult {
if (node.kind === "directory") {
const expanded = this.expandedDirectories.has(node.path);
return html`
<button type="button" class="row" style=${`--depth:${String(depth)}`} aria-expanded=${expanded ? "true" : "false"} @click=${() => { this.toggleDirectory(node.path); }}>
<span class="twisty">${expanded ? "▾" : "▸"}</span>
<span>${node.name}${node.isSubmodule === true ? submoduleBadge() : null}</span>
</button>
${expanded ? node.children.map((child) => this.renderTreeNode(context, child, depth + 1)) : null}
`;
}
return this.renderSelectableRow(context, node.path, node.name, node.file, depth);
}
private renderFileRow(context: WorkspacePanelContext, file: GitStatusFile): TemplateResult {
return this.renderSelectableRow(context, file.path, file.path, file, 0);
}
private renderSelectableRow(context: WorkspacePanelContext, path: string, label: string, file: GitStatusFile, depth: number): TemplateResult {
const selected = context.selectedDiffPath === path;
return html`
<button type="button" class=${selected ? "row selected" : "row"} style=${`--depth:${String(depth)}`} @click=${() => { context.onSelectDiff(path); }}>
<span>${stateLabel(file.index, file.workingTree)}</span>
<span>${label}</span>
</button>
`;
}
private computeViewState(status: GitStatusResponse | undefined): GitViewState {
if (status === undefined || !status.isGitRepo || status.files.length === 0) return EMPTY_VIEW_STATE;
if (this.view === "tree") {
const nodes = buildGitFileTree(status.files, status.submodules);
return { nodes, listModel: EMPTY_LIST_MODEL, expandablePaths: collectGitFileTreeDirectoryPaths(nodes) };
}
const listModel = buildGitFileList(status.files, status.submodules);
return { nodes: [], listModel, expandablePaths: listModel.submodules.map((group) => group.path) };
}
private setView(view: GitFileView): void {
if (this.view === view) return;
this.view = view;
writeGitFileView(view);
// Entering either view starts fully collapsed.
this.expandedDirectories = new Set();
}
private toggleDirectory(path: string): void {
const next = new Set(this.expandedDirectories);
if (next.has(path)) next.delete(path);
else next.add(path);
this.expandedDirectories = next;
}
private toggleExpandAll(expandablePaths: readonly string[], allExpanded: boolean): void {
this.expandedDirectories = allExpanded ? new Set() : new Set(expandablePaths);
}
}
function submoduleBadge(): TemplateResult {
return html`<span class="submodule-badge">submodule</span>`;
}
function renderDiffViewer(context: WorkspacePanelContext): TemplateResult {
if (context.selectedDiffPath === undefined || context.selectedDiffPath === "") return html`<p class="muted">Select a changed file.</p>`;
const unstaged = context.selectedDiff;
const staged = context.selectedStagedDiff;
if (unstaged === undefined || staged === undefined) return html`<p class="muted">Loading diff…</p>`;
const diffs = [staged, unstaged].filter((diff) => diff.diff !== "");
if (diffs.length === 0) return html`<p class="muted">No staged or unstaged diff.</p>`;
return html`
<div class=${diffs.length === 1 ? "diffs single" : "diffs"}>
${diffs.map((diff) => renderDiffSection(diff))}
</div>
`;
}
function renderDiffSection(diff: GitDiffResponse): TemplateResult {
loadUnifiedDiffViewer();
return html`
<section class="diff-section">
<div class="viewer-header"><strong>${diff.path ?? "diff"}</strong><small>${diff.staged ? "staged" : "unstaged"}${diff.truncated ? " · truncated" : ""}</small></div>
<unified-diff-viewer .diff=${diff.diff}></unified-diff-viewer>
</section>
`;
}
function loadUnifiedDiffViewer(): void {
void import("./UnifiedDiffViewer");
}
function gitSummary(status: GitStatusResponse): string {
const branch = status.branch ?? "detached";
const ahead = status.ahead ?? 0;
const behind = status.behind ?? 0;
return ahead === 0 && behind === 0 ? branch : `${branch} · ↑${String(ahead)}${String(behind)}`;
}
function stateLabel(index: string, workingTree: string): string {
const label = workingTree !== "unmodified" ? workingTree : index;
return label.slice(0, 1).toUpperCase();
}
function gitPanelContextKey(context: WorkspacePanelContext): string {
return `${context.machine.id}:${context.workspace.projectId}:${context.workspace.id}`;
}
@@ -58,7 +58,7 @@ describe("MachineController", () => {
selectedSession: session,
fileTree: [{ name: "index.ts", path: "src/index.ts", type: "file" }],
selectedFilePath: "src/index.ts",
gitStatus: { isGitRepo: true, hash: "abc123", branch: "main", files: [{ path: "src/index.ts", index: "modified", workingTree: "modified" }] },
gitStatus: { isGitRepo: true, hash: "abc123", branch: "main", files: [{ path: "src/index.ts", index: "modified", workingTree: "modified" }], submodules: [] },
activeTerminalCount: 2,
error: "stale error",
};
+47
View File
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import type { GitStatusFile } from "./api";
import { buildGitFileList } from "./gitFileList";
describe("buildGitFileList", () => {
it("leaves non-submodule files flat in their original order", () => {
const model = buildGitFileList([changed("src/b.ts"), changed("a.txt"), changed("src/a.ts")], []);
expect(model.submodules).toEqual([]);
expect(model.files.map((file) => file.path)).toEqual(["src/b.ts", "a.txt", "src/a.ts"]);
});
it("groups submodule files flat with submodule-relative paths and the pointer first", () => {
const pointer = submodulePointer("HARL", "1111111", "2222222");
const model = buildGitFileList([changed("README.md"), pointer, changed("HARL/src/foo.ts"), changed("HARL/a.txt"), changed("HARL/b.txt")], ["HARL"]);
expect(model.files.map((file) => file.path)).toEqual(["README.md"]);
expect(model.submodules).toHaveLength(1);
const group = model.submodules[0];
expect(group?.path).toBe("HARL");
expect(group?.name).toBe("HARL");
expect(group?.pointer?.name).toBe("1111111 → 2222222");
expect(group?.pointer?.file).toBe(pointer);
// Flattened (no nesting) and sorted by relative path.
expect(group?.files.map((entry) => entry.relativePath)).toEqual(["a.txt", "b.txt", "src/foo.ts"]);
expect(group?.files.map((entry) => entry.path)).toEqual(["HARL/a.txt", "HARL/b.txt", "HARL/src/foo.ts"]);
});
it("places submodule groups first, sorted by name, ahead of the flat files", () => {
const model = buildGitFileList([changed("root.txt"), changed("Zsub/x"), changed("Asub/y")], ["Zsub", "Asub"]);
expect(model.submodules.map((group) => group.name)).toEqual(["Asub", "Zsub"]);
expect(model.files.map((file) => file.path)).toEqual(["root.txt"]);
});
it("omits the pointer for a dirty submodule whose commit did not move", () => {
const model = buildGitFileList([changed("HARL/x.txt")], ["HARL"]);
expect(model.submodules[0]?.pointer).toBeUndefined();
expect(model.submodules[0]?.files.map((entry) => entry.relativePath)).toEqual(["x.txt"]);
});
});
function changed(path: string): GitStatusFile {
return { path, index: "modified", workingTree: "modified" };
}
function submodulePointer(path: string, from: string, to: string): GitStatusFile {
return { path, index: "unmodified", workingTree: "modified", submoduleFromCommit: from, submoduleToCommit: to };
}
+84
View File
@@ -0,0 +1,84 @@
import type { GitStatusFile } from "./api";
/** A changed file inside a submodule, shown flat in list view. `path` is the
* full superproject-relative path (the diff key); `relativePath` is shown in
* the row and is relative to the submodule root (may still contain slashes). */
export interface GitFileListSubmoduleFile {
readonly path: string;
readonly relativePath: string;
readonly file: GitStatusFile;
}
/** An expandable submodule group in list view. Children are flat (no nesting),
* with the pointer row pinned first when present. The pointer is the
* submodule's own moved commit, keyed by the submodule path; its `name` is the
* `<old> → <new>` short-SHA summary. */
export interface GitFileListSubmoduleGroup {
readonly path: string;
readonly name: string;
readonly pointer?: { readonly name: string; readonly file: GitStatusFile };
readonly files: readonly GitFileListSubmoduleFile[];
}
/** List-view model: submodule groups first (alphabetical), then non-submodule
* files in their original Git status order. */
export interface GitFileListModel {
readonly submodules: readonly GitFileListSubmoduleGroup[];
readonly files: readonly GitStatusFile[];
}
/**
* Group the flat changed-file list for list view. Unlike the tree, submodule
* contents are flattened: each submodule becomes one expandable group holding
* its pointer row and its changed files, and everything else stays a flat list.
*/
export function buildGitFileList(files: readonly GitStatusFile[], submodules: readonly string[] = []): GitFileListModel {
const submoduleSet = new Set(submodules);
const pointers = new Map<string, { readonly name: string; readonly file: GitStatusFile }>();
const grouped = new Map<string, GitFileListSubmoduleFile[]>();
for (const submodule of submodules) grouped.set(submodule, []);
const flat: GitStatusFile[] = [];
for (const file of files) {
if (submoduleSet.has(file.path)) {
pointers.set(file.path, { name: pointerName(file), file });
continue;
}
const owner = ownerSubmodule(file.path, submodules);
if (owner !== undefined) {
grouped.get(owner)?.push({ path: file.path, relativePath: file.path.slice(owner.length + 1), file });
continue;
}
flat.push(file);
}
const groups: GitFileListSubmoduleGroup[] = submodules
.map((submodule): GitFileListSubmoduleGroup => {
const pointer = pointers.get(submodule);
const inner = grouped.get(submodule) ?? [];
return { path: submodule, name: segmentName(submodule), ...(pointer === undefined ? {} : { pointer }), files: [...inner].sort((left, right) => left.relativePath.localeCompare(right.relativePath)) };
})
.sort((left, right) => left.name.localeCompare(right.name));
return { submodules: groups, files: flat };
}
function ownerSubmodule(path: string, submodules: readonly string[]): string | undefined {
let best: string | undefined;
for (const submodule of submodules) {
if (submodule !== "" && path.startsWith(`${submodule}/`) && (best === undefined || submodule.length > best.length)) best = submodule;
}
return best;
}
function pointerName(file: GitStatusFile): string {
const from = file.submoduleFromCommit;
const to = file.submoduleToCommit;
return from !== undefined && to !== undefined ? `${from}${to}` : "commit";
}
function segmentName(path: string): string {
const segments = path.split("/");
const last = segments[segments.length - 1];
return last ?? path;
}
+117
View File
@@ -0,0 +1,117 @@
import { describe, expect, it } from "vitest";
import type { GitStatusFile } from "./api";
import {
buildGitFileTree,
collectGitFileTreeDirectoryPaths,
type GitFileTreeDirectoryNode,
type GitFileTreeFileNode,
type GitFileTreeNode,
} from "./gitFileTree";
describe("buildGitFileTree", () => {
it("returns an empty tree for no changes", () => {
expect(buildGitFileTree([])).toEqual([]);
expect(collectGitFileTreeDirectoryPaths([])).toEqual([]);
});
it("keeps root-level files at the root", () => {
const tree = buildGitFileTree([changed("b.txt"), changed("a.txt")]);
expect(tree.map((node) => node.kind)).toEqual(["file", "file"]);
expect(tree.map((node) => node.name)).toEqual(["a.txt", "b.txt"]);
expect(collectGitFileTreeDirectoryPaths(tree)).toEqual([]);
});
it("nests files under merged directories, directories before files, alphabetical", () => {
const tree = buildGitFileTree([
changed("src/a.ts"),
changed("src/b/c.ts"),
changed("README.md"),
changed("src/b/a.ts"),
]);
expect(tree).toHaveLength(2);
const src = expectDirectory(tree[0]);
expect(src.name).toBe("src");
expect(src.path).toBe("src");
expect(expectFile(tree[1]).name).toBe("README.md");
// src groups the shared "b" directory once, ordered before the loose file.
expect(src.children).toHaveLength(2);
const srcB = expectDirectory(src.children[0]);
expect(srcB.name).toBe("b");
expect(srcB.path).toBe("src/b");
expect(expectFile(src.children[1]).path).toBe("src/a.ts");
expect(srcB.children.map((node) => node.name)).toEqual(["a.ts", "c.ts"]);
expect(srcB.children.map((node) => expectFile(node).path)).toEqual(["src/b/a.ts", "src/b/c.ts"]);
});
it("shows the basename on leaves while keeping the full path and original file", () => {
const original = changed("deep/nested/file.ts");
const leaf = expectFile(expectDirectory(expectDirectory(buildGitFileTree([original])[0]).children[0]).children[0]);
expect(leaf.name).toBe("file.ts");
expect(leaf.path).toBe("deep/nested/file.ts");
expect(leaf.file).toBe(original);
});
it("collects every directory path in pre-order", () => {
const tree = buildGitFileTree([changed("src/b/c.ts"), changed("src/a.ts"), changed("docs/x.md")]);
expect(collectGitFileTreeDirectoryPaths(tree)).toEqual(["docs", "src", "src/b"]);
});
it("marks a submodule directory and pins its pointer as the first child", () => {
const pointer = submodulePointer("HARL", "1111111", "2222222");
const tree = buildGitFileTree([pointer, changed("HARL/tracked.txt"), changed("HARL/src/foo.ts"), changed("README.md")], ["HARL"]);
expect(tree.map((node) => node.name)).toEqual(["HARL", "README.md"]);
const harl = expectDirectory(tree[0]);
expect(harl.isSubmodule).toBe(true);
// Pointer first, then directories, then files (submodule-relative nesting kept).
const pointerRow = expectFile(harl.children[0]);
expect(pointerRow.isSubmodulePointer).toBe(true);
expect(pointerRow.name).toBe("1111111 → 2222222");
expect(pointerRow.path).toBe("HARL");
expect(pointerRow.file).toBe(pointer);
const src = expectDirectory(harl.children[1]);
expect(src.path).toBe("HARL/src");
expect(expectFile(src.children[0]).path).toBe("HARL/src/foo.ts");
expect(expectFile(harl.children[2]).path).toBe("HARL/tracked.txt");
});
it("creates a submodule node for a pointer-only change with no inner files", () => {
const tree = buildGitFileTree([submodulePointer("HARL", "aaaaaaa", "bbbbbbb")], ["HARL"]);
const harl = expectDirectory(tree[0]);
expect(harl.isSubmodule).toBe(true);
expect(harl.children).toHaveLength(1);
expect(expectFile(harl.children[0]).isSubmodulePointer).toBe(true);
expect(collectGitFileTreeDirectoryPaths(tree)).toEqual(["HARL"]);
});
it("groups a dirty submodule without a moved pointer and emits no pointer row", () => {
const tree = buildGitFileTree([changed("HARL/x.txt"), changed("HARL/nested/y.txt")], ["HARL"]);
const harl = expectDirectory(tree[0]);
expect(harl.isSubmodule).toBe(true);
expect(harl.children.every((node) => node.kind !== "file" || node.isSubmodulePointer !== true)).toBe(true);
expect(collectGitFileTreeDirectoryPaths(tree)).toEqual(["HARL", "HARL/nested"]);
});
});
function submodulePointer(path: string, from: string, to: string): GitStatusFile {
return { path, index: "unmodified", workingTree: "modified", submoduleFromCommit: from, submoduleToCommit: to };
}
function changed(path: string): GitStatusFile {
return { path, index: "modified", workingTree: "modified" };
}
function expectDirectory(node: GitFileTreeNode | undefined): GitFileTreeDirectoryNode {
if (node?.kind !== "directory") throw new Error(`expected a directory node, received ${node?.kind ?? "undefined"}`);
return node;
}
function expectFile(node: GitFileTreeNode | undefined): GitFileTreeFileNode {
if (node?.kind !== "file") throw new Error(`expected a file node, received ${node?.kind ?? "undefined"}`);
return node;
}
+133
View File
@@ -0,0 +1,133 @@
import type { GitStatusFile } from "./api";
/**
* A changed file placed at a leaf of the Git file tree. `path` is the full
* repository-relative path (used to load its diff); `name` is just the final
* segment shown in the tree row. A submodule commit-pointer row reuses this
* shape: `isSubmodulePointer` is set, `path` is the submodule path, and `name`
* is the `<old> → <new>` short-SHA summary.
*/
export interface GitFileTreeFileNode {
readonly kind: "file";
readonly name: string;
readonly path: string;
readonly file: GitStatusFile;
readonly isSubmodulePointer?: boolean;
}
/**
* A directory grouping in the Git file tree. `path` is the full directory path
* and doubles as the stable key used to track expand/collapse state.
* `isSubmodule` marks a directory that is actually a submodule root so the UI
* can label it and treat its contents as the submodule's own changes.
*/
export interface GitFileTreeDirectoryNode {
readonly kind: "directory";
readonly name: string;
readonly path: string;
readonly isSubmodule?: boolean;
readonly children: readonly GitFileTreeNode[];
}
export type GitFileTreeNode = GitFileTreeDirectoryNode | GitFileTreeFileNode;
interface DirectoryAccumulator {
readonly path: string;
readonly directories: Map<string, DirectoryAccumulator>;
readonly files: GitFileTreeFileNode[];
isSubmodule: boolean;
pointer?: GitFileTreeFileNode;
}
/**
* Build a nested directory/file tree from Git's flat changed-file list. The
* status response already carries every changed path, so this is a pure
* client-side transform (no lazy per-directory loading like the Files tab).
*
* `submodules` lists submodule roots: a directory matching one is marked as a
* submodule, a file whose path equals one becomes that submodule's pinned
* commit-pointer row, and files below one nest inside it. Directories sort
* before files (pointer row first within a submodule), both alphabetically.
*/
export function buildGitFileTree(files: readonly GitStatusFile[], submodules: readonly string[] = []): GitFileTreeNode[] {
const root = createDirectoryAccumulator("");
const submoduleSet = new Set(submodules);
// Ensure a node exists (and is marked) for every submodule, so a submodule
// whose only change is a moved pointer still renders as an expandable root.
for (const submodule of submodules) ensureDirectory(root, submodule).isSubmodule = true;
for (const file of files) {
if (submoduleSet.has(file.path)) {
const directory = ensureDirectory(root, file.path);
directory.isSubmodule = true;
directory.pointer = { kind: "file", name: pointerName(file), path: file.path, file, isSubmodulePointer: true };
continue;
}
const segments = file.path.split("/").filter((segment) => segment.length > 0);
const name = segments[segments.length - 1];
if (name === undefined) continue;
let directory = root;
for (let index = 0; index < segments.length - 1; index += 1) {
const segment = segments[index];
if (segment === undefined) continue;
directory = childDirectory(directory, segment);
}
directory.files.push({ kind: "file", name, path: file.path, file });
}
return finalizeChildren(root);
}
/**
* Every directory path in the tree, in a stable order. Used to drive the
* expand-all / collapse-all control and to decide whether the tree is fully
* expanded. Submodule roots are directories and are included.
*/
export function collectGitFileTreeDirectoryPaths(nodes: readonly GitFileTreeNode[]): string[] {
const paths: string[] = [];
for (const node of nodes) {
if (node.kind === "directory") {
paths.push(node.path);
paths.push(...collectGitFileTreeDirectoryPaths(node.children));
}
}
return paths;
}
function pointerName(file: GitStatusFile): string {
const from = file.submoduleFromCommit;
const to = file.submoduleToCommit;
return from !== undefined && to !== undefined ? `${from}${to}` : "commit";
}
function createDirectoryAccumulator(path: string): DirectoryAccumulator {
return { path, directories: new Map(), files: [], isSubmodule: false };
}
function childDirectory(parent: DirectoryAccumulator, segment: string): DirectoryAccumulator {
const childPath = parent.path.length === 0 ? segment : `${parent.path}/${segment}`;
const existing = parent.directories.get(childPath);
if (existing !== undefined) return existing;
const created = createDirectoryAccumulator(childPath);
parent.directories.set(childPath, created);
return created;
}
function ensureDirectory(root: DirectoryAccumulator, path: string): DirectoryAccumulator {
let directory = root;
for (const segment of path.split("/").filter((part) => part.length > 0)) directory = childDirectory(directory, segment);
return directory;
}
function finalizeChildren(directory: DirectoryAccumulator): GitFileTreeNode[] {
const directories: GitFileTreeDirectoryNode[] = [...directory.directories.values()]
.map((child): GitFileTreeDirectoryNode => ({ kind: "directory", name: segmentName(child.path), path: child.path, ...(child.isSubmodule ? { isSubmodule: true } : {}), children: finalizeChildren(child) }))
.sort((left, right) => left.name.localeCompare(right.name));
const files = [...directory.files].sort((left, right) => left.name.localeCompare(right.name));
return [...(directory.pointer === undefined ? [] : [directory.pointer]), ...directories, ...files];
}
function segmentName(path: string): string {
const segments = path.split("/");
const last = segments[segments.length - 1];
return last ?? path;
}
@@ -0,0 +1,66 @@
import { describe, expect, it } from "vitest";
import {
GIT_FILE_VIEW_STORAGE_KEY,
parseGitFileView,
readGitFileView,
writeGitFileView,
} from "./gitFileViewPreference";
describe("gitFileViewPreference", () => {
it("parses stored values and defaults unknown/missing input to list", () => {
expect(parseGitFileView("tree")).toBe("tree");
expect(parseGitFileView("list")).toBe("list");
expect(parseGitFileView(null)).toBe("list");
expect(parseGitFileView("")).toBe("list");
expect(parseGitFileView("grid")).toBe("list");
});
it("defaults to list when nothing is stored yet", () => {
expect(readGitFileView(new FakeStorage())).toBe("list");
});
it("reads and writes the stored view mode", () => {
const storage = new FakeStorage();
writeGitFileView("tree", storage);
expect(storage.value(GIT_FILE_VIEW_STORAGE_KEY)).toBe("tree");
expect(readGitFileView(storage)).toBe("tree");
writeGitFileView("list", storage);
expect(storage.value(GIT_FILE_VIEW_STORAGE_KEY)).toBe("list");
expect(readGitFileView(storage)).toBe("list");
});
it("ignores storage failures and falls back to list", () => {
const storage = new ThrowingStorage();
expect(readGitFileView(storage)).toBe("list");
expect(() => { writeGitFileView("tree", storage); }).not.toThrow();
});
});
class FakeStorage {
private readonly values = new Map<string, string>();
getItem(key: string): string | null {
return this.values.get(key) ?? null;
}
setItem(key: string, value: string): void {
this.values.set(key, value);
}
value(key: string): string | undefined {
return this.values.get(key);
}
}
class ThrowingStorage {
getItem(): string | null {
throw new Error("blocked");
}
setItem(): void {
throw new Error("blocked");
}
}
+40
View File
@@ -0,0 +1,40 @@
export const GIT_FILE_VIEW_STORAGE_KEY = "pi-web.gitFileView";
/**
* How the Git panel lists changed files. `list` is the flat, full-path view
* (the historical default); `tree` nests files under their directories.
*/
export type GitFileView = "list" | "tree";
export type GitFileViewStorage = Pick<Storage, "getItem" | "setItem">;
export function parseGitFileView(value: string | null): GitFileView {
return value === "tree" ? "tree" : "list";
}
export function readGitFileView(storage = browserStorage()): GitFileView {
if (storage === undefined) return "list";
try {
return parseGitFileView(storage.getItem(GIT_FILE_VIEW_STORAGE_KEY));
} catch {
return "list";
}
}
export function writeGitFileView(view: GitFileView, storage = browserStorage()): void {
if (storage === undefined) return;
try {
storage.setItem(GIT_FILE_VIEW_STORAGE_KEY, view);
} catch {
// Ignore localStorage quota/privacy errors; the chosen view still applies in memory for this tab.
}
}
function browserStorage(): GitFileViewStorage | undefined {
if (typeof window === "undefined") return undefined;
try {
return window.localStorage;
} catch {
return undefined;
}
}
+2 -65
View File
@@ -1,7 +1,7 @@
import { html, type TemplateResult } from "lit";
import type { GitDiffResponse, GitStatusResponse } from "../../api";
import { renderBuiltinTabIcon } from "../../components/tabIcons";
import "../../components/WorkspaceFilesPanel";
import "../../components/WorkspaceGitPanel";
import type { WorkspacePanelContribution, WorkspacePanelContext } from "../types";
export function createCoreWorkspacePanels(): WorkspacePanelContribution[] {
@@ -42,72 +42,9 @@ function renderTerminal(context: WorkspacePanelContext): TemplateResult {
}
function renderGit(context: WorkspacePanelContext): TemplateResult {
const status = context.gitStatus;
return html`
<section class="toolbar">
<strong>Git</strong>
${context.gitStale ? html`<span class="stale">stale</span>` : null}
<button @click=${context.onRefreshGit}>Refresh</button>
</section>
<section class="split">
<div class="list">
${status === undefined ? html`<p class="muted">No status loaded.</p>` : !status.isGitRepo ? html`<p class="muted">Not a git repository.</p>` : html`
<p class="summary">${gitSummary(status)}</p>
${status.files.length === 0 ? html`<p class="muted">No changes.</p>` : status.files.map((file) => html`
<button class="row ${context.selectedDiffPath === file.path ? "selected" : ""}" @click=${() => { context.onSelectDiff(file.path); }}>
<span>${stateLabel(file.index, file.workingTree)}</span>
<span>${file.path}</span>
</button>
`)}
`}
</div>
<div class="viewer">
${renderDiffViewer(context)}
</div>
</section>
`;
}
function renderDiffViewer(context: WorkspacePanelContext): TemplateResult {
if (context.selectedDiffPath === undefined || context.selectedDiffPath === "") return html`<p class="muted">Select a changed file.</p>`;
const unstaged = context.selectedDiff;
const staged = context.selectedStagedDiff;
if (unstaged === undefined || staged === undefined) return html`<p class="muted">Loading diff…</p>`;
const diffs = [staged, unstaged].filter((diff) => diff.diff !== "");
if (diffs.length === 0) return html`<p class="muted">No staged or unstaged diff.</p>`;
return html`
<div class=${diffs.length === 1 ? "diffs single" : "diffs"}>
${diffs.map((diff) => renderDiffSection(diff))}
</div>
`;
}
function renderDiffSection(diff: GitDiffResponse): TemplateResult {
loadUnifiedDiffViewer();
return html`
<section class="diff-section">
<div class="viewer-header"><strong>${diff.path ?? "diff"}</strong><small>${diff.staged ? "staged" : "unstaged"}${diff.truncated ? " · truncated" : ""}</small></div>
<unified-diff-viewer .diff=${diff.diff}></unified-diff-viewer>
</section>
`;
}
function loadUnifiedDiffViewer(): void {
void import("../../components/UnifiedDiffViewer");
return html`<workspace-git-panel .context=${context}></workspace-git-panel>`;
}
function loadTerminalPanel(): void {
void import("../../components/TerminalPanel");
}
function gitSummary(status: GitStatusResponse): string {
const branch = status.branch ?? "detached";
const ahead = status.ahead ?? 0;
const behind = status.behind ?? 0;
return ahead === 0 && behind === 0 ? branch : `${branch} · ↑${String(ahead)}${String(behind)}`;
}
function stateLabel(index: string, workingTree: string): string {
const label = workingTree !== "unmodified" ? workingTree : index;
return label.slice(0, 1).toUpperCase();
}