From 8a5aaf93a1f00781786c87d422af6bbc309cd794 Mon Sep 17 00:00:00 2001 From: lzr <43104914+lizhuoran1019@users.noreply.github.com> Date: Tue, 21 Jul 2026 14:28:38 +0000 Subject: [PATCH] feat(git): add list/tree toggle for changed files in the Git panel Extract the Git panel into a dedicated `workspace-git-panel` Lit component (mirroring the Files panel) and add a segmented List/Tree toggle next to Refresh. Tree view builds an in-memory, collapsible directory tree from the changed files, starts fully collapsed, and offers a single expand-all/collapse-all button (visible only in tree view). List view keeps the existing flat, full-path rows. The selected view mode persists in localStorage under `pi-web.gitFileView`; per-directory expand state is intentionally ephemeral. - gitFileViewPreference.ts: localStorage-backed view preference (+ test) - gitFileTree.ts: pure flat-paths -> nested-tree builder (+ test) - WorkspaceGitPanel.ts: the panel component with toggle + tree state --- .changeset/git-file-tree-view.md | 5 + .../src/components/WorkspaceGitPanel.ts | 208 ++++++++++++++++++ src/client/src/gitFileTree.test.ts | 75 +++++++ src/client/src/gitFileTree.ts | 97 ++++++++ src/client/src/gitFileViewPreference.test.ts | 66 ++++++ src/client/src/gitFileViewPreference.ts | 40 ++++ src/client/src/plugins/core/panels.ts | 67 +----- 7 files changed, 493 insertions(+), 65 deletions(-) create mode 100644 .changeset/git-file-tree-view.md create mode 100644 src/client/src/components/WorkspaceGitPanel.ts create mode 100644 src/client/src/gitFileTree.test.ts create mode 100644 src/client/src/gitFileTree.ts create mode 100644 src/client/src/gitFileViewPreference.test.ts create mode 100644 src/client/src/gitFileViewPreference.ts diff --git a/.changeset/git-file-tree-view.md b/.changeset/git-file-tree-view.md new file mode 100644 index 0000000..34c7dfa --- /dev/null +++ b/.changeset/git-file-tree-view.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Add a List/Tree toggle to the Git panel's changed-file list. Tree view groups changes by directory and opens fully collapsed, with a one-click expand-all/collapse-all control, and the chosen view is remembered across sessions. diff --git a/src/client/src/components/WorkspaceGitPanel.ts b/src/client/src/components/WorkspaceGitPanel.ts new file mode 100644 index 0000000..5cca4f8 --- /dev/null +++ b/src/client/src/components/WorkspaceGitPanel.ts @@ -0,0 +1,208 @@ +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 { readGitFileView, writeGitFileView, type GitFileView } from "../gitFileViewPreference"; +import type { WorkspacePanelContext } from "../plugins/types"; +import { workspacePanelStyles } from "./shared"; + +interface GitTreeState { + readonly nodes: readonly GitFileTreeNode[]; + readonly directoryPaths: readonly string[]; +} + +const EMPTY_TREE_STATE: GitTreeState = { nodes: [], directoryPaths: [] }; + +@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)); } + `, + ]; + + @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. + @state() private expandedDirectories = new Set(); + + protected override willUpdate(changedProperties: PropertyValues): 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`

Git unavailable.

`; + const status = context.gitStatus; + const treeState = this.computeTreeState(status); + return html` +
+ Git + ${context.gitStale ? html`stale` : null} +
+ ${this.renderViewToggle()} + ${this.view === "tree" && treeState.directoryPaths.length > 0 ? this.renderExpandCollapseAll(treeState.directoryPaths) : null} + +
+
+
+
+ ${this.renderFileList(context, status, treeState.nodes)} +
+
${renderDiffViewer(context)}
+
+ `; + } + + private renderViewToggle(): TemplateResult { + return html` +
+ ${this.renderViewToggleButton("list", "List")} + ${this.renderViewToggleButton("tree", "Tree")} +
+ `; + } + + private renderViewToggleButton(view: GitFileView, label: string): TemplateResult { + const active = this.view === view; + return html` + + `; + } + + private renderExpandCollapseAll(directoryPaths: readonly string[]): TemplateResult { + const allExpanded = directoryPaths.every((path) => this.expandedDirectories.has(path)); + return html` + + `; + } + + private renderFileList(context: WorkspacePanelContext, status: GitStatusResponse | undefined, nodes: readonly GitFileTreeNode[]): TemplateResult { + if (status === undefined) return html`

No status loaded.

`; + if (!status.isGitRepo) return html`

Not a git repository.

`; + const summary = html`

${gitSummary(status)}

`; + if (status.files.length === 0) return html`${summary}

No changes.

`; + const body = this.view === "tree" + ? nodes.map((node) => this.renderTreeNode(context, node, 0)) + : status.files.map((file) => this.renderFileRow(context, file)); + return html`${summary}${body}`; + } + + private renderTreeNode(context: WorkspacePanelContext, node: GitFileTreeNode, depth: number): TemplateResult { + if (node.kind === "directory") { + const expanded = this.expandedDirectories.has(node.path); + return html` + + ${expanded ? node.children.map((child) => this.renderTreeNode(context, child, depth + 1)) : null} + `; + } + const selected = context.selectedDiffPath === node.path; + return html` + + `; + } + + private renderFileRow(context: WorkspacePanelContext, file: GitStatusFile): TemplateResult { + const selected = context.selectedDiffPath === file.path; + return html` + + `; + } + + private computeTreeState(status: GitStatusResponse | undefined): GitTreeState { + if (this.view !== "tree" || status === undefined || !status.isGitRepo || status.files.length === 0) return EMPTY_TREE_STATE; + const nodes = buildGitFileTree(status.files); + return { nodes, directoryPaths: collectGitFileTreeDirectoryPaths(nodes) }; + } + + private setView(view: GitFileView): void { + if (this.view === view) return; + this.view = view; + writeGitFileView(view); + // The tree always starts fully collapsed when entered. + if (view === "tree") 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(directoryPaths: readonly string[], allExpanded: boolean): void { + this.expandedDirectories = allExpanded ? new Set() : new Set(directoryPaths); + } +} + +function renderDiffViewer(context: WorkspacePanelContext): TemplateResult { + if (context.selectedDiffPath === undefined || context.selectedDiffPath === "") return html`

Select a changed file.

`; + const unstaged = context.selectedDiff; + const staged = context.selectedStagedDiff; + if (unstaged === undefined || staged === undefined) return html`

Loading diff…

`; + const diffs = [staged, unstaged].filter((diff) => diff.diff !== ""); + if (diffs.length === 0) return html`

No staged or unstaged diff.

`; + return html` +
+ ${diffs.map((diff) => renderDiffSection(diff))} +
+ `; +} + +function renderDiffSection(diff: GitDiffResponse): TemplateResult { + loadUnifiedDiffViewer(); + return html` +
+
${diff.path ?? "diff"}${diff.staged ? "staged" : "unstaged"}${diff.truncated ? " · truncated" : ""}
+ +
+ `; +} + +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}`; +} diff --git a/src/client/src/gitFileTree.test.ts b/src/client/src/gitFileTree.test.ts new file mode 100644 index 0000000..efa1816 --- /dev/null +++ b/src/client/src/gitFileTree.test.ts @@ -0,0 +1,75 @@ +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"]); + }); +}); + +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; +} diff --git a/src/client/src/gitFileTree.ts b/src/client/src/gitFileTree.ts new file mode 100644 index 0000000..2d0f097 --- /dev/null +++ b/src/client/src/gitFileTree.ts @@ -0,0 +1,97 @@ +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. + */ +export interface GitFileTreeFileNode { + readonly kind: "file"; + readonly name: string; + readonly path: string; + readonly file: GitStatusFile; +} + +/** + * 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. + */ +export interface GitFileTreeDirectoryNode { + readonly kind: "directory"; + readonly name: string; + readonly path: string; + readonly children: readonly GitFileTreeNode[]; +} + +export type GitFileTreeNode = GitFileTreeDirectoryNode | GitFileTreeFileNode; + +interface DirectoryAccumulator { + readonly path: string; + readonly directories: Map; + readonly files: 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). + * Directories sort before files, and both sort alphabetically within a level. + */ +export function buildGitFileTree(files: readonly GitStatusFile[]): GitFileTreeNode[] { + const root = createDirectoryAccumulator(""); + for (const file of files) { + 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; + const childPath = directory.path.length === 0 ? segment : `${directory.path}/${segment}`; + const existing = directory.directories.get(childPath); + if (existing === undefined) { + const created = createDirectoryAccumulator(childPath); + directory.directories.set(childPath, created); + directory = created; + } else { + directory = existing; + } + } + 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. + */ +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 createDirectoryAccumulator(path: string): DirectoryAccumulator { + return { path, directories: new Map(), files: [] }; +} + +function finalizeChildren(directory: DirectoryAccumulator): GitFileTreeNode[] { + const directories: GitFileTreeDirectoryNode[] = [...directory.directories.values()] + .map((child): GitFileTreeDirectoryNode => ({ kind: "directory", name: segmentName(child.path), path: child.path, children: finalizeChildren(child) })) + .sort((left, right) => left.name.localeCompare(right.name)); + const files = [...directory.files].sort((left, right) => left.name.localeCompare(right.name)); + return [...directories, ...files]; +} + +function segmentName(path: string): string { + const segments = path.split("/"); + const last = segments[segments.length - 1]; + return last ?? path; +} diff --git a/src/client/src/gitFileViewPreference.test.ts b/src/client/src/gitFileViewPreference.test.ts new file mode 100644 index 0000000..470ac70 --- /dev/null +++ b/src/client/src/gitFileViewPreference.test.ts @@ -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(); + + 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"); + } +} diff --git a/src/client/src/gitFileViewPreference.ts b/src/client/src/gitFileViewPreference.ts new file mode 100644 index 0000000..5f7ebb7 --- /dev/null +++ b/src/client/src/gitFileViewPreference.ts @@ -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; + +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; + } +} diff --git a/src/client/src/plugins/core/panels.ts b/src/client/src/plugins/core/panels.ts index 4c2dca6..7d57e18 100644 --- a/src/client/src/plugins/core/panels.ts +++ b/src/client/src/plugins/core/panels.ts @@ -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` -
- Git - ${context.gitStale ? html`stale` : null} - -
-
-
- ${status === undefined ? html`

No status loaded.

` : !status.isGitRepo ? html`

Not a git repository.

` : html` -

${gitSummary(status)}

- ${status.files.length === 0 ? html`

No changes.

` : status.files.map((file) => html` - - `)} - `} -
-
- ${renderDiffViewer(context)} -
-
- `; -} - -function renderDiffViewer(context: WorkspacePanelContext): TemplateResult { - if (context.selectedDiffPath === undefined || context.selectedDiffPath === "") return html`

Select a changed file.

`; - const unstaged = context.selectedDiff; - const staged = context.selectedStagedDiff; - if (unstaged === undefined || staged === undefined) return html`

Loading diff…

`; - const diffs = [staged, unstaged].filter((diff) => diff.diff !== ""); - if (diffs.length === 0) return html`

No staged or unstaged diff.

`; - return html` -
- ${diffs.map((diff) => renderDiffSection(diff))} -
- `; -} - -function renderDiffSection(diff: GitDiffResponse): TemplateResult { - loadUnifiedDiffViewer(); - return html` -
-
${diff.path ?? "diff"}${diff.staged ? "staged" : "unstaged"}${diff.truncated ? " · truncated" : ""}
- -
- `; -} - -function loadUnifiedDiffViewer(): void { - void import("../../components/UnifiedDiffViewer"); + return html``; } 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(); -}