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/.changeset/git-submodule-changes.md b/.changeset/git-submodule-changes.md new file mode 100644 index 0000000..adb8277 --- /dev/null +++ b/.changeset/git-submodule-changes.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Expand a changed submodule in the Git panel to see the work inside it. Tree view nests the submodule's own modified and untracked files (keeping their folder structure) and list view flattens them into one group, with a moved commit pointer shown as `` when it changed. Selecting any inner file shows its real diff instead of the bare `Subproject commit` line. diff --git a/src/client/src/api/parsers.test.ts b/src/client/src/api/parsers.test.ts index 4c02766..2533399 100644 --- a/src/client/src/api/parsers.test.ts +++ b/src/client/src/api/parsers.test.ts @@ -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", diff --git a/src/client/src/api/parsers.ts b/src/client/src/api/parsers.ts index e9fb592..52ce3da 100644 --- a/src/client/src/api/parsers.ts +++ b/src/client/src/api/parsers.ts @@ -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 { diff --git a/src/client/src/components/WorkspaceGitPanel.ts b/src/client/src/components/WorkspaceGitPanel.ts new file mode 100644 index 0000000..aa93add --- /dev/null +++ b/src/client/src/components/WorkspaceGitPanel.ts @@ -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(); + + 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 viewState = this.computeViewState(status); + return html` +
+ Git + ${context.gitStale ? html`stale` : null} +
+ ${this.renderViewToggle()} + ${viewState.expandablePaths.length > 0 ? this.renderExpandCollapseAll(viewState.expandablePaths) : null} + +
+
+
+
+ ${this.renderFileList(context, status, viewState)} +
+
${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(expandablePaths: readonly string[]): TemplateResult { + const allExpanded = expandablePaths.every((path) => this.expandedDirectories.has(path)); + return html` + + `; + } + + private renderFileList(context: WorkspacePanelContext, status: GitStatusResponse | undefined, viewState: GitViewState): 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" + ? 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` + + ${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` + + ${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` + + `; + } + + 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`submodule`; +} + +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/controllers/machineController.test.ts b/src/client/src/controllers/machineController.test.ts index d8cca31..0078b37 100644 --- a/src/client/src/controllers/machineController.test.ts +++ b/src/client/src/controllers/machineController.test.ts @@ -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", }; diff --git a/src/client/src/gitFileList.test.ts b/src/client/src/gitFileList.test.ts new file mode 100644 index 0000000..60903b6 --- /dev/null +++ b/src/client/src/gitFileList.test.ts @@ -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 }; +} diff --git a/src/client/src/gitFileList.ts b/src/client/src/gitFileList.ts new file mode 100644 index 0000000..32faa6e --- /dev/null +++ b/src/client/src/gitFileList.ts @@ -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 + * `` 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(); + const grouped = new Map(); + 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; +} diff --git a/src/client/src/gitFileTree.test.ts b/src/client/src/gitFileTree.test.ts new file mode 100644 index 0000000..e5e0a04 --- /dev/null +++ b/src/client/src/gitFileTree.test.ts @@ -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; +} diff --git a/src/client/src/gitFileTree.ts b/src/client/src/gitFileTree.ts new file mode 100644 index 0000000..972f15e --- /dev/null +++ b/src/client/src/gitFileTree.ts @@ -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 `` 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; + 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; +} 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(); -} diff --git a/src/server/git/gitService.test.ts b/src/server/git/gitService.test.ts new file mode 100644 index 0000000..a5336bf --- /dev/null +++ b/src/server/git/gitService.test.ts @@ -0,0 +1,111 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync, renameSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, describe, expect, it } from "vitest"; +import { gitDiff, gitStatus } from "./gitService.js"; + +// Isolate from any global/system git config and force a deterministic identity; +// `protocol.file.allow` is required for `submodule add` from a local path. +const GIT_FLAGS = ["-c", "user.name=Test", "-c", "user.email=test@example.com", "-c", "protocol.file.allow=always", "-c", "commit.gpgsign=false"]; +const GIT_ENV = { ...process.env, GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_SYSTEM: "/dev/null", GIT_TERMINAL_PROMPT: "0" }; + +const created: string[] = []; +afterAll(() => { for (const dir of created) rmSync(dir, { recursive: true, force: true }); }); + +function git(cwd: string, args: string[]): string { + return execFileSync("git", [...GIT_FLAGS, ...args], { cwd, encoding: "utf8", env: GIT_ENV }); +} + +/** Superproject at `dir` with a submodule `HARL` recorded at commit `c2`; the + * submodule origin has two commits `c1` (a.txt=v1) then `c2` (a.txt=v2). */ +function createFixture(): { dir: string; c1: string; c2: string } { + const base = mkdtempSync(join(tmpdir(), "pi-web-sub-")); + created.push(base); + const origin = join(base, "origin"); + const sup = join(base, "sup"); + + git(base, ["init", "-b", "main", origin]); + writeFileSync(join(origin, "a.txt"), "v1\n"); + git(origin, ["add", "-A"]); + git(origin, ["commit", "-m", "c1"]); + const c1 = git(origin, ["rev-parse", "HEAD"]).trim(); + writeFileSync(join(origin, "a.txt"), "v2\n"); + git(origin, ["add", "-A"]); + git(origin, ["commit", "-m", "c2"]); + const c2 = git(origin, ["rev-parse", "HEAD"]).trim(); + + git(base, ["init", "-b", "main", sup]); + git(sup, ["submodule", "add", origin, "HARL"]); + writeFileSync(join(sup, "root.txt"), "root\n"); + git(sup, ["add", "-A"]); + git(sup, ["commit", "-m", "init"]); + return { dir: sup, c1, c2 }; +} + +describe("gitStatus with submodules", () => { + it("surfaces a moved commit pointer with short SHAs and no inner files", async () => { + const { dir, c1, c2 } = createFixture(); + git(join(dir, "HARL"), ["checkout", c1]); // move the pointer, leave the tree clean + + const status = await gitStatus(dir); + expect(status.submodules).toContain("HARL"); + const pointer = status.files.find((file) => file.path === "HARL"); + expect(pointer?.submoduleFromCommit).toBe(c2.slice(0, 7)); + expect(pointer?.submoduleToCommit).toBe(c1.slice(0, 7)); + expect(status.files.some((file) => file.path.startsWith("HARL/"))).toBe(false); + }); + + it("lists modified and untracked inner files and omits the pointer when the commit is unchanged", async () => { + const { dir } = createFixture(); + writeFileSync(join(dir, "HARL", "a.txt"), "v2\nchanged\n"); + writeFileSync(join(dir, "HARL", "new.txt"), "brand-new\n"); + + const status = await gitStatus(dir); + expect(status.submodules).toContain("HARL"); + expect(status.files.find((file) => file.path === "HARL")).toBeUndefined(); + const inner = status.files.filter((file) => file.path.startsWith("HARL/")).map((file) => file.path); + expect(inner).toContain("HARL/a.txt"); + expect(inner).toContain("HARL/new.txt"); + }); + + it("skips inner recursion without throwing when the submodule repo is unreadable", async () => { + const { dir } = createFixture(); + writeFileSync(join(dir, "HARL", "new.txt"), "brand-new\n"); // untracked → would trigger recursion + renameSync(join(dir, "HARL", ".git"), join(dir, "HARL", ".git.bak")); // break the inner repo + + const status = await gitStatus(dir); + expect(status.isGitRepo).toBe(true); + expect(status.files.some((file) => file.path.startsWith("HARL/"))).toBe(false); + }); +}); + +describe("gitDiff routing into submodules", () => { + it("returns real content for a tracked file inside the submodule", async () => { + const { dir } = createFixture(); + writeFileSync(join(dir, "HARL", "a.txt"), "v2\nchanged\n"); + + const diff = await gitDiff(dir, { path: "HARL/a.txt" }); + expect(diff.path).toBe("HARL/a.txt"); + expect(diff.diff).toContain("@@"); + expect(diff.diff).toContain("changed"); + }); + + it("produces an untracked-file diff inside the submodule via --no-index", async () => { + const { dir } = createFixture(); + writeFileSync(join(dir, "HARL", "new.txt"), "brand-new\n"); + + const diff = await gitDiff(dir, { path: "HARL/new.txt" }); + expect(diff.path).toBe("HARL/new.txt"); + expect(diff.diff).toContain("brand-new"); + }); + + it("diffs the submodule path itself against the superproject pointer", async () => { + const { dir, c1 } = createFixture(); + git(join(dir, "HARL"), ["checkout", c1]); + + const diff = await gitDiff(dir, { path: "HARL" }); + expect(diff.path).toBe("HARL"); + expect(diff.diff).toContain("Subproject commit"); + }); +}); diff --git a/src/server/git/gitService.ts b/src/server/git/gitService.ts index cfa3c4c..7113a44 100644 --- a/src/server/git/gitService.ts +++ b/src/server/git/gitService.ts @@ -1,15 +1,103 @@ import { createHash } from "node:crypto"; import { spawn } from "node:child_process"; +import { join } from "node:path"; import type { GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse } from "../../shared/apiTypes.js"; import { normalizeRelativePath } from "../workspaces/pathSafety.js"; import { sanitizedGitEnv } from "./gitEnv.js"; const MAX_OUTPUT = 2 * 1024 * 1024; +/** + * A submodule row parsed from the superproject status. `git status` reports a + * submodule as a single path with an `S` flag field (commit changed / + * modified tracked content / untracked content) but never lists the files that + * changed inside it, so we recurse in `expandSubmodules`. + */ +interface SubmoduleRecord { + path: string; + index: GitFileState; + workingTree: GitFileState; + commitChanged: boolean; + hasModifiedContent: boolean; + hasUntrackedContent: boolean; + headOid: string; + indexOid: string; +} + +interface ParsedStatus { + isGitRepo: true; + branch?: string; + upstream?: string; + ahead?: number; + behind?: number; + files: GitStatusFile[]; + submodules: SubmoduleRecord[]; +} + export async function gitStatus(cwd: string): Promise { const result = await runGit(cwd, ["status", "--porcelain=v2", "--branch", "--untracked-files=all", "-z"]); - if (result.code !== 0) return { isGitRepo: false, hash: hash(result.stdout + result.stderr), files: [] }; - return parseStatus(result.stdout); + if (result.code !== 0) return { isGitRepo: false, hash: hash(result.stdout + result.stderr), files: [], submodules: [] }; + const parsed = parseStatus(result.stdout, { deferSubmodules: true }); + return expandSubmodules(cwd, parsed, result.stdout); +} + +/** + * Merge each dirty submodule's own changes into the flat file list. A moved + * commit pointer becomes a single entry keyed by the submodule path (carrying + * the short SHAs for display); modified/untracked content is listed as regular + * entries under `/`. A plain `-dirty` pointer (commit + * unchanged) is intentionally not surfaced as a pointer entry. + */ +async function expandSubmodules(cwd: string, parsed: ParsedStatus, topRaw: string): Promise { + const files: GitStatusFile[] = [...parsed.files]; + const submodulePaths: string[] = []; + let extraForHash = ""; + + for (const sub of parsed.submodules) { + submodulePaths.push(sub.path); + if (sub.commitChanged) { + files.push({ + path: sub.path, + index: sub.index, + workingTree: sub.workingTree, + submoduleFromCommit: short(sub.headOid), + submoduleToCommit: short(await resolveSubmoduleToCommit(cwd, sub)), + }); + } + if (sub.hasModifiedContent || sub.hasUntrackedContent) { + const inner = await runGit(join(cwd, sub.path), ["status", "--porcelain=v2", "--untracked-files=all", "-z"]); + if (inner.code !== 0) continue; // uninitialized / unreadable submodule: skip silently + extraForHash += `\0${sub.path}\0${inner.stdout}`; + const innerFiles = parseStatus(inner.stdout, { deferSubmodules: false }).files; + for (const file of innerFiles) { + files.push({ + ...file, + path: `${sub.path}/${file.path}`, + ...(file.oldPath === undefined ? {} : { oldPath: `${sub.path}/${file.oldPath}` }), + }); + } + } + } + + return { + isGitRepo: true, + hash: hash(topRaw + extraForHash), + ...(parsed.branch === undefined ? {} : { branch: parsed.branch }), + ...(parsed.upstream === undefined ? {} : { upstream: parsed.upstream }), + ...(parsed.ahead === undefined ? {} : { ahead: parsed.ahead }), + ...(parsed.behind === undefined ? {} : { behind: parsed.behind }), + files, + submodules: submodulePaths, + }; +} + +async function resolveSubmoduleToCommit(cwd: string, sub: SubmoduleRecord): Promise { + // Staged pointer moves already expose the new commit as the index OID; an + // unstaged move only records the old OID, so read the submodule's HEAD. + if (sub.indexOid !== sub.headOid) return sub.indexOid; + const head = await runGit(join(cwd, sub.path), ["rev-parse", "HEAD"]); + const resolved = head.stdout.trim(); + return head.code === 0 && resolved !== "" ? resolved : sub.indexOid; } export async function gitDiff(cwd: string, options: { path?: string; staged?: boolean }): Promise { @@ -17,6 +105,11 @@ export async function gitDiff(cwd: string, options: { path?: string; staged?: bo let path: string | undefined; if (options.path !== undefined && options.path !== "") path = normalizeRelativePath(options.path); + if (path !== undefined) { + const owner = await submoduleForPath(cwd, path); + if (owner !== undefined) return submoduleDiff(cwd, owner, path, staged); + } + const args = ["diff", "--no-ext-diff", "--color=never"]; if (staged) args.push("--cached"); if (path !== undefined) args.push("--", path); @@ -31,14 +124,64 @@ export async function gitDiff(cwd: string, options: { path?: string; staged?: bo return { ...(path === undefined ? {} : { path }), staged, hash: hash(result.stdout), diff: result.stdout, truncated: result.truncated }; } +/** + * Run the diff inside the owning submodule's working tree, since `git diff` at + * the superproject root never shows content changes below a submodule boundary. + * The response path stays the full superproject-relative path so the viewer and + * the selected row line up. + */ +async function submoduleDiff(cwd: string, owner: string, path: string, staged: boolean): Promise { + const subCwd = join(cwd, owner); + const rel = normalizeRelativePath(path.slice(owner.length + 1)); + + const args = ["diff", "--no-ext-diff", "--color=never"]; + if (staged) args.push("--cached"); + args.push("--", rel); + + const result = await runGit(subCwd, args); + if (result.code !== 0) throw new Error(result.stderr.trim() || "git diff failed"); + if (!staged && result.stdout === "" && await isUntracked(subCwd, rel)) { + const untracked = await runGit(subCwd, ["diff", "--no-ext-diff", "--color=never", "--no-index", "/dev/null", "--", rel]); + if (untracked.code !== 0 && untracked.code !== 1) throw new Error(untracked.stderr.trim() || "git diff failed"); + return { path, staged, hash: hash(untracked.stdout), diff: untracked.stdout, truncated: untracked.truncated }; + } + return { path, staged, hash: hash(result.stdout), diff: result.stdout, truncated: result.truncated }; +} + async function isUntracked(cwd: string, path: string): Promise { const result = await runGit(cwd, ["ls-files", "--others", "--exclude-standard", "-z", "--", path]); return result.code === 0 && result.stdout.split("\0").includes(path); } -function parseStatus(raw: string): GitStatusResponse { +/** Configured direct-submodule paths (depth 1), read from `.gitmodules`. */ +async function submodulePaths(cwd: string): Promise { + const result = await runGit(cwd, ["config", "--file", ".gitmodules", "--get-regexp", "^submodule\\..+\\.path$"]); + if (result.code !== 0) return []; + const paths: string[] = []; + for (const line of result.stdout.split("\n")) { + const trimmed = line.trim(); + if (trimmed === "") continue; + const spaceAt = trimmed.indexOf(" "); + if (spaceAt === -1) continue; + paths.push(trimmed.slice(spaceAt + 1)); + } + return paths; +} + +/** The submodule that strictly contains `path`, if any (longest match wins). */ +async function submoduleForPath(cwd: string, path: string): Promise { + const subs = await submodulePaths(cwd); + let best: string | undefined; + for (const sub of subs) { + if (sub !== "" && path.startsWith(`${sub}/`) && (best === undefined || sub.length > best.length)) best = sub; + } + return best; +} + +function parseStatus(raw: string, options: { deferSubmodules: boolean }): ParsedStatus { const records = raw.split("\0").filter((record) => record !== ""); const files: GitStatusFile[] = []; + const submodules: SubmoduleRecord[] = []; let branch: string | undefined; let upstream: string | undefined; let ahead: number | undefined; @@ -56,7 +199,22 @@ function parseStatus(raw: string): GitStatusResponse { else if (record.startsWith("! ")) files.push({ path: record.slice(2), index: "ignored", workingTree: "ignored" }); else if (record.startsWith("1 ")) { const parts = record.split(" "); - files.push({ path: parts.slice(8).join(" "), index: stateFor(parts[1]?.[0]), workingTree: stateFor(parts[1]?.[1]) }); + const sub = parts[2]; + const path = parts.slice(8).join(" "); + if (options.deferSubmodules && sub?.startsWith("S") === true) { + submodules.push({ + path, + index: stateFor(parts[1]?.[0]), + workingTree: stateFor(parts[1]?.[1]), + commitChanged: sub[1] === "C", + hasModifiedContent: sub[2] === "M", + hasUntrackedContent: sub[3] === "U", + headOid: parts[6] ?? "", + indexOid: parts[7] ?? "", + }); + } else { + files.push({ path, index: stateFor(parts[1]?.[0]), workingTree: stateFor(parts[1]?.[1]) }); + } } else if (record.startsWith("2 ")) { const parts = record.split(" "); const path = parts.slice(9).join(" "); @@ -69,7 +227,7 @@ function parseStatus(raw: string): GitStatusResponse { } } - return { isGitRepo: true, hash: hash(raw), ...(branch === undefined ? {} : { branch }), ...(upstream === undefined ? {} : { upstream }), ...(ahead === undefined ? {} : { ahead }), ...(behind === undefined ? {} : { behind }), files }; + return { isGitRepo: true, ...(branch === undefined ? {} : { branch }), ...(upstream === undefined ? {} : { upstream }), ...(ahead === undefined ? {} : { ahead }), ...(behind === undefined ? {} : { behind }), files, submodules }; } function stateFor(code: string | undefined): GitFileState { @@ -90,6 +248,10 @@ function normalizeBranch(value: string): string | undefined { return value === "(detached)" ? undefined : value; } +function short(oid: string): string { + return oid.slice(0, 7); +} + function hash(value: string): string { return createHash("sha1").update(value).digest("hex"); } diff --git a/src/shared/apiTypes.ts b/src/shared/apiTypes.ts index 32c4f9a..4070e3c 100644 --- a/src/shared/apiTypes.ts +++ b/src/shared/apiTypes.ts @@ -681,6 +681,10 @@ export interface GitStatusFile { oldPath?: string; index: GitFileState; workingTree: GitFileState; + // Set only on a submodule commit-pointer entry (path equals the submodule's + // superproject-relative path). Short SHAs of the recorded and current commit. + submoduleFromCommit?: string; + submoduleToCommit?: string; } export interface GitStatusResponse { @@ -691,6 +695,11 @@ export interface GitStatusResponse { ahead?: number; behind?: number; files: GitStatusFile[]; + // Superproject-relative paths of submodules that carry a change. Files inside + // a submodule appear in `files` under `/`; the client + // uses this list to group and label them and to distinguish a submodule root + // from an ordinary directory with the same name. + submodules: string[]; } export interface GitDiffResponse {