diff --git a/src/client/src/components/WorkspaceGitPanel.test.ts b/src/client/src/components/WorkspaceGitPanel.test.ts new file mode 100644 index 0000000..c87b216 --- /dev/null +++ b/src/client/src/components/WorkspaceGitPanel.test.ts @@ -0,0 +1,392 @@ +import type { TemplateResult } from "lit"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { GitStatusFile, GitStatusResponse } from "../api"; +import { initialAppState } from "../appState"; +import { GIT_FILE_VIEW_STORAGE_KEY } from "../gitFileViewPreference"; +import type { WorkspacePanelContext } from "../plugins/types"; +// Genuine Lit event-wiring extraction (view-toggle, expand/collapse-all, and +// row clicks) routes through the shared, type-guarded template-inspection +// escape hatch; see ../templateInspection.testSupport for the proportionality +// rationale. Vitest runs in the node environment (no DOM), so the few +// rendered-output assertions (toolbar order, badge, state labels) read the +// returned TemplateResult through the shared templateText/templateStrings/ +// templateValues primitives, anchored to stable user-facing labels and markup +// — kept narrow per the testing guide. +import { + findOptionalTemplateClickHandlerForText, + isTemplateResult, + templateClickHandlerForText, + templateStrings, + templateText, + templateValues, +} from "../templateInspection.testSupport"; +import { WorkspaceGitPanel } from "./WorkspaceGitPanel"; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("workspace-git-panel view toggle", () => { + it("switches between list and tree rendering and persists the choice", () => { + const { panel, storage } = newGitPanel(); + const onSelectDiff = vi.fn(); + panel.context = workspacePanelContext({ gitStatus: gitStatus({ files: [gitFile("src/main.ts")] }), onSelectDiff }); + + // List view (the default) renders full-path rows that load their diff. + templateClickHandlerForText(panel.render(), "src/main.ts")(new Event("click")); + expect(onSelectDiff).toHaveBeenCalledWith("src/main.ts"); + + templateClickHandlerForText(panel.render(), "Tree")(new Event("click")); + expect(storage.value(GIT_FILE_VIEW_STORAGE_KEY)).toBe("tree"); + + // Tree view nests the file under its (collapsed) directory instead. + let rendered = panel.render(); + expect(findOptionalTemplateClickHandlerForText(rendered, "src/main.ts")).toBeUndefined(); + templateClickHandlerForText(rendered, "src"); + + templateClickHandlerForText(panel.render(), "List")(new Event("click")); + expect(storage.value(GIT_FILE_VIEW_STORAGE_KEY)).toBe("list"); + rendered = panel.render(); + expect(findOptionalTemplateClickHandlerForText(rendered, "src/main.ts")).toBeDefined(); + }); + + it("restores the persisted view on construction", () => { + const { panel } = newGitPanel({ [GIT_FILE_VIEW_STORAGE_KEY]: "tree" }); + panel.context = workspacePanelContext({ gitStatus: gitStatus({ files: [gitFile("src/main.ts")] }) }); + + const rendered = panel.render(); + expect(findOptionalTemplateClickHandlerForText(rendered, "src/main.ts")).toBeUndefined(); + + templateClickHandlerForText(rendered, "src")(new Event("click")); + expect(findOptionalTemplateClickHandlerForText(panel.render(), "main.ts")).toBeDefined(); + }); +}); + +describe("workspace-git-panel toolbar order", () => { + it("renders expand/collapse-all before the view toggle, which stays anchored next to Refresh", () => { + // Pins the UX fix: the right-anchored view toggle and Refresh button must + // not move when the expand/collapse-all control appears or disappears. + const listPanel = newGitPanel().panel; + listPanel.context = workspacePanelContext({ gitStatus: gitStatus({ files: [gitFile("one.ts")] }) }); + const withoutExpandAll = toolbarSlots(listPanel.render()); + expect(withoutExpandAll.expandCollapseSlot).toBeNull(); + + const treePanel = newGitPanel({ [GIT_FILE_VIEW_STORAGE_KEY]: "tree" }).panel; + treePanel.context = workspacePanelContext({ gitStatus: treeStatus() }); + const withExpandAll = toolbarSlots(treePanel.render()); + const expandCollapse = withExpandAll.expandCollapseSlot; + if (!isTemplateResult(expandCollapse)) throw new Error("expand/collapse-all control missing in tree view"); + expect(templateText(expandCollapse)).toContain("Expand all"); + + // The toggle occupies the same template slot whether or not the + // expand/collapse-all control renders in the slot before it. + expect(withExpandAll.toggleIndex).toBe(withoutExpandAll.toggleIndex); + }); +}); + +describe("workspace-git-panel expand/collapse all", () => { + it("expands and collapses every directory, flipping its label", () => { + const { panel } = newGitPanel({ [GIT_FILE_VIEW_STORAGE_KEY]: "tree" }); + panel.context = workspacePanelContext({ gitStatus: treeStatus() }); + + expect(findOptionalTemplateClickHandlerForText(panel.render(), "b.ts")).toBeUndefined(); + + templateClickHandlerForText(panel.render(), "Expand all")(new Event("click")); + let rendered = panel.render(); + expect(findOptionalTemplateClickHandlerForText(rendered, "Expand all")).toBeUndefined(); + templateClickHandlerForText(rendered, "Collapse all"); + templateClickHandlerForText(rendered, "a.ts"); + templateClickHandlerForText(rendered, "b.ts"); + + templateClickHandlerForText(panel.render(), "Collapse all")(new Event("click")); + rendered = panel.render(); + templateClickHandlerForText(rendered, "Expand all"); + expect(findOptionalTemplateClickHandlerForText(rendered, "b.ts")).toBeUndefined(); + }); + + it("keeps the Expand all label while expansion is partial", () => { + const { panel } = newGitPanel({ [GIT_FILE_VIEW_STORAGE_KEY]: "tree" }); + panel.context = workspacePanelContext({ gitStatus: treeStatus() }); + + templateClickHandlerForText(panel.render(), "src")(new Event("click")); + + const rendered = panel.render(); + templateClickHandlerForText(rendered, "Expand all"); + templateClickHandlerForText(rendered, "a.ts"); + expect(findOptionalTemplateClickHandlerForText(rendered, "b.ts")).toBeUndefined(); + }); + + it("expands and collapses submodule groups in list view", () => { + const { panel } = newGitPanel(); + panel.context = workspacePanelContext({ gitStatus: submoduleStatus() }); + + expect(findOptionalTemplateClickHandlerForText(panel.render(), "abc1234 → def5678")).toBeUndefined(); + + templateClickHandlerForText(panel.render(), "Expand all")(new Event("click")); + let rendered = panel.render(); + templateClickHandlerForText(rendered, "abc1234 → def5678"); + templateClickHandlerForText(rendered, "lib.ts"); + templateClickHandlerForText(rendered, "Collapse all"); + + templateClickHandlerForText(panel.render(), "Collapse all")(new Event("click")); + rendered = panel.render(); + expect(findOptionalTemplateClickHandlerForText(rendered, "abc1234 → def5678")).toBeUndefined(); + expect(findOptionalTemplateClickHandlerForText(rendered, "lib.ts")).toBeUndefined(); + }); +}); + +describe("workspace-git-panel rows", () => { + it("toggles a directory's children when its row is clicked", () => { + const { panel } = newGitPanel({ [GIT_FILE_VIEW_STORAGE_KEY]: "tree" }); + panel.context = workspacePanelContext({ gitStatus: treeStatus() }); + + templateClickHandlerForText(panel.render(), "src")(new Event("click")); + expect(findOptionalTemplateClickHandlerForText(panel.render(), "a.ts")).toBeDefined(); + + templateClickHandlerForText(panel.render(), "src")(new Event("click")); + expect(findOptionalTemplateClickHandlerForText(panel.render(), "a.ts")).toBeUndefined(); + }); + + it("renders submodule groups with a basename header and badge, and pointer rows when expanded", () => { + const { panel } = newGitPanel(); + const onSelectDiff = vi.fn(); + panel.context = workspacePanelContext({ gitStatus: submoduleStatus(), onSelectDiff }); + + // Collapsed: only the basename header (with its submodule badge) shows. + const collapsed = panel.render(); + const headerText = rowTextFor(collapsed, "harl"); + expect(headerText).toContain("harl"); + expect(headerText).toContain("submodule"); + expect(findOptionalTemplateClickHandlerForText(collapsed, "abc1234 → def5678")).toBeUndefined(); + + templateClickHandlerForText(collapsed, "harl")(new Event("click")); + + // Expanded: the pointer row and inner files select diffs. + const rendered = panel.render(); + templateClickHandlerForText(rendered, "abc1234 → def5678")(new Event("click")); + expect(onSelectDiff).toHaveBeenCalledWith("vendor/harl"); + templateClickHandlerForText(rendered, "lib.ts")(new Event("click")); + expect(onSelectDiff).toHaveBeenCalledWith("vendor/harl/lib.ts"); + }); + + it("renders M/A/D/U state labels for changed files", () => { + const { panel } = newGitPanel(); + panel.context = workspacePanelContext({ + gitStatus: gitStatus({ + files: [ + gitFile("m.ts", { index: "unmodified", workingTree: "modified" }), + gitFile("a.ts", { index: "added", workingTree: "unmodified" }), + gitFile("d.ts", { index: "unmodified", workingTree: "deleted" }), + gitFile("u.ts", { index: "unmodified", workingTree: "untracked" }), + ], + }), + }); + + const rendered = panel.render(); + expect(rowTextFor(rendered, "m.ts")).toContain("M"); + expect(rowTextFor(rendered, "a.ts")).toContain("A"); + expect(rowTextFor(rendered, "d.ts")).toContain("D"); + expect(rowTextFor(rendered, "u.ts")).toContain("U"); + }); +}); + +describe("workspace-git-panel context reset", () => { + it("resets expansion state when the workspace context key changes", () => { + const { panel } = newGitPanel({ [GIT_FILE_VIEW_STORAGE_KEY]: "tree" }); + const context = workspacePanelContext({ gitStatus: treeStatus() }); + panel.context = context; + templateClickHandlerForText(panel.render(), "src")(new Event("click")); + expect(findOptionalTemplateClickHandlerForText(panel.render(), "a.ts")).toBeDefined(); + + const switched = workspacePanelContext({ + gitStatus: treeStatus(), + workspace: { id: "workspace-2", projectId: "project-1", path: "/tmp/project-2", label: "other", isMain: false, isGitRepo: true, isGitWorktree: false }, + }); + panel.context = switched; + callPanelWillUpdate(panel, context); + + const rendered = panel.render(); + templateClickHandlerForText(rendered, "Expand all"); + expect(findOptionalTemplateClickHandlerForText(rendered, "a.ts")).toBeUndefined(); + }); + + it("keeps expansion state across disconnect/reconnect with the same context key", () => { + const { panel } = newGitPanel({ [GIT_FILE_VIEW_STORAGE_KEY]: "tree" }); + const context = workspacePanelContext({ gitStatus: treeStatus() }); + panel.context = context; + templateClickHandlerForText(panel.render(), "src")(new Event("click")); + + panel.context = undefined; + callPanelWillUpdate(panel, context); + expect(templateText(panel.render())).toContain("Git unavailable."); + + const reconnected = workspacePanelContext({ gitStatus: treeStatus() }); + panel.context = reconnected; + callPanelWillUpdate(panel, undefined); + expect(findOptionalTemplateClickHandlerForText(panel.render(), "a.ts")).toBeDefined(); + + // A fresh context object with the same key (defined → defined) also keeps state. + const sameKey = workspacePanelContext({ gitStatus: treeStatus() }); + panel.context = sameKey; + callPanelWillUpdate(panel, reconnected); + expect(findOptionalTemplateClickHandlerForText(panel.render(), "a.ts")).toBeDefined(); + }); +}); + +function newGitPanel(storageValues: Record = {}): { panel: WorkspaceGitPanel; storage: FakeStorage } { + const storage = new FakeStorage(storageValues); + vi.stubGlobal("window", { localStorage: storage }); + return { panel: new WorkspaceGitPanel(), storage }; +} + +/** + * Structural pin for the toolbar layout contract: the expand/collapse-all + * slot precedes the view-toggle slot inside `.toolbar-actions`, and the + * toggle is always immediately followed by the Refresh button — so the + * right-anchored controls never move when expand/collapse-all appears. + */ +function toolbarSlots(rendered: TemplateResult): { expandCollapseSlot: unknown; toggleIndex: number } { + const strings = templateStrings(rendered); + const values = templateValues(rendered); + const actionsIndex = strings.findIndex((chunk) => chunk.includes("toolbar-actions")); + if (actionsIndex < 0) throw new Error("toolbar-actions markup missing from the rendered panel"); + const toggleIndex = actionsIndex + 1; + const toggle = values[toggleIndex]; + if (!isTemplateResult(toggle) || !templateStrings(toggle).some((chunk) => chunk.includes("view-toggle"))) { + throw new Error("view toggle is not the control immediately after the expand/collapse-all slot"); + } + const refreshOpenChunk = strings[toggleIndex + 1] ?? ""; + const refreshLabelChunk = strings[toggleIndex + 2] ?? ""; + if (!refreshOpenChunk.includes("Refresh")) { + throw new Error("Refresh button does not immediately follow the view toggle"); + } + return { expandCollapseSlot: values[actionsIndex], toggleIndex }; +} + +/** Flattened rendered text of the deepest template containing `anchor`. */ +function rowTextFor(rendered: TemplateResult, anchor: string): string { + const text = findDeepestTemplateText(rendered, anchor); + if (text === undefined) throw new Error(`Expected a rendered row containing ${anchor}`); + return text; +} + +function findDeepestTemplateText(value: unknown, anchor: string): string | undefined { + if (Array.isArray(value)) { + for (const item of value) { + const found = findDeepestTemplateText(item, anchor); + if (found !== undefined) return found; + } + return undefined; + } + if (!isTemplateResult(value)) return undefined; + for (const item of templateValues(value)) { + const found = findDeepestTemplateText(item, anchor); + if (found !== undefined) return found; + } + const text = templateText(value); + return text.includes(anchor) ? text : undefined; +} + +// Protected lifecycle methods are invoked through Reflect, matching the +// SettingsSessiondPanel.test.ts precedent. +function callPanelWillUpdate(panel: WorkspaceGitPanel, previous: WorkspacePanelContext | undefined): unknown { + const method: unknown = Reflect.get(panel, "willUpdate"); + if (typeof method !== "function") throw new Error("WorkspaceGitPanel.willUpdate is not callable"); + return Reflect.apply(method, panel, [new Map([["context", previous]])]); +} + +function treeStatus(): GitStatusResponse { + return gitStatus({ + files: [ + gitFile("src/a.ts"), + gitFile("src/nested/b.ts"), + gitFile("README.md"), + ], + }); +} + +function submoduleStatus(): GitStatusResponse { + return gitStatus({ + submodules: ["vendor/harl"], + files: [ + gitFile("vendor/harl", { submoduleFromCommit: "abc1234", submoduleToCommit: "def5678" }), + gitFile("vendor/harl/lib.ts"), + ], + }); +} + +function gitFile(path: string, patch: Partial = {}): GitStatusFile { + return { path, index: "unmodified", workingTree: "modified", ...patch }; +} + +function gitStatus(patch: Partial = {}): GitStatusResponse { + return { + isGitRepo: true, + hash: "hash-1", + branch: "main", + files: [], + submodules: [], + ...patch, + }; +} + +class FakeStorage { + private readonly values = new Map(); + + constructor(initial: Record = {}) { + for (const [key, value] of Object.entries(initial)) this.values.set(key, value); + } + + 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); + } +} + +function workspacePanelContext(patch: Partial = {}): WorkspacePanelContext { + const workspace = patch.workspace ?? { id: "workspace-1", projectId: "project-1", path: "/tmp/project", label: "main", isMain: true, isGitRepo: true, isGitWorktree: false }; + return { + machine: patch.machine ?? { id: "local", name: "Local", kind: "local" }, + workspace, + state: patch.state ?? { ...initialAppState(), workspaceUploadBatches: {} }, + files: patch.files ?? { + readFile: vi.fn(() => Promise.reject(new Error("not implemented"))), + writeFile: vi.fn(() => Promise.reject(new Error("not implemented"))), + deleteFile: vi.fn(() => Promise.reject(new Error("not implemented"))), + moveFile: vi.fn(() => Promise.reject(new Error("not implemented"))), + }, + prompt: patch.prompt ?? { insertText: vi.fn(), getText: vi.fn(() => ""), getSelection: vi.fn(() => null) }, + terminal: patch.terminal ?? { open: vi.fn(), runCommand: vi.fn(() => Promise.reject(new Error("not implemented"))) }, + host: patch.host ?? { requestRender: vi.fn() }, + fileTree: patch.fileTree ?? [], + expandedDirs: patch.expandedDirs ?? {}, + selectedFilePath: patch.selectedFilePath, + selectedFileContent: patch.selectedFileContent, + fileTreeStale: patch.fileTreeStale ?? false, + gitStatus: patch.gitStatus, + selectedDiffPath: patch.selectedDiffPath, + selectedDiff: patch.selectedDiff, + selectedStagedDiff: patch.selectedStagedDiff, + gitStale: patch.gitStale ?? false, + activeTerminalCount: patch.activeTerminalCount ?? 0, + selectedTerminalId: patch.selectedTerminalId, + terminalAutoStart: patch.terminalAutoStart ?? false, + workspaceUploadDefaultFolder: patch.workspaceUploadDefaultFolder ?? ".pi-web/uploads", + onRefreshFiles: patch.onRefreshFiles ?? vi.fn(), + onExpandDir: patch.onExpandDir ?? vi.fn(), + onSelectFile: patch.onSelectFile ?? vi.fn(), + onStartWorkspaceUpload: patch.onStartWorkspaceUpload ?? vi.fn(() => undefined), + onCancelWorkspaceUpload: patch.onCancelWorkspaceUpload ?? vi.fn(), + onClearWorkspaceUpload: patch.onClearWorkspaceUpload ?? vi.fn(), + onRefreshGit: patch.onRefreshGit ?? vi.fn(), + onSelectDiff: patch.onSelectDiff ?? vi.fn(), + onSelectTerminal: patch.onSelectTerminal ?? vi.fn(), + }; +}