refactor(ui): dedupe git file helpers and memoize git view model

- N1: extract the copy-pasted pointerName/segmentName helpers from
  gitFileList.ts and gitFileTree.ts into gitFileShared.ts.
- N3: drop the dead conditional "tree" class (no CSS rule exists).
- N4 (P3): memoize computeViewState on (status, view) identity so renders
  from expand/collapse or diff selection skip the full model rebuild;
  expand state is read live at render time, never cached.
This commit is contained in:
Federico Jaramillo Martinez
2026-07-24 12:09:55 +02:00
parent 10ee3daa0c
commit 6db065e984
5 changed files with 75 additions and 25 deletions
+20 -1
View File
@@ -13,6 +13,12 @@ interface GitViewState {
readonly expandablePaths: readonly string[]; readonly expandablePaths: readonly string[];
} }
interface GitViewStateCache {
readonly status: GitStatusResponse | undefined;
readonly view: GitFileView;
readonly viewState: GitViewState;
}
const EMPTY_LIST_MODEL: GitFileListModel = { submodules: [], files: [] }; const EMPTY_LIST_MODEL: GitFileListModel = { submodules: [], files: [] };
const EMPTY_VIEW_STATE: GitViewState = { nodes: [], listModel: EMPTY_LIST_MODEL, expandablePaths: [] }; const EMPTY_VIEW_STATE: GitViewState = { nodes: [], listModel: EMPTY_LIST_MODEL, expandablePaths: [] };
@@ -45,6 +51,11 @@ export class WorkspaceGitPanel extends LitElement {
// submodule groups, both keyed by their path. // submodule groups, both keyed by their path.
@state() private expandedDirectories = new Set<string>(); @state() private expandedDirectories = new Set<string>();
// Memoized on (status, view) identity: renders triggered by expand/collapse
// or diff selection reuse the model, while a new poll or view switch rebuilds
// it. Expand state is read live at render time, never baked into the model.
private viewStateCache: GitViewStateCache | undefined;
protected override willUpdate(changedProperties: PropertyValues<this>): void { protected override willUpdate(changedProperties: PropertyValues<this>): void {
if (!changedProperties.has("context")) return; if (!changedProperties.has("context")) return;
const previous = changedProperties.get("context"); const previous = changedProperties.get("context");
@@ -70,7 +81,7 @@ export class WorkspaceGitPanel extends LitElement {
</div> </div>
</section> </section>
<section class="split"> <section class="split">
<div class=${this.view === "tree" ? "list tree" : "list"}> <div class="list">
${this.renderFileList(context, status, viewState)} ${this.renderFileList(context, status, viewState)}
</div> </div>
<div class="viewer">${renderDiffViewer(context)}</div> <div class="viewer">${renderDiffViewer(context)}</div>
@@ -166,6 +177,14 @@ export class WorkspaceGitPanel extends LitElement {
} }
private computeViewState(status: GitStatusResponse | undefined): GitViewState { private computeViewState(status: GitStatusResponse | undefined): GitViewState {
const cached = this.viewStateCache;
if (cached !== undefined && cached.status === status && cached.view === this.view) return cached.viewState;
const viewState = this.buildViewState(status);
this.viewStateCache = { status, view: this.view, viewState };
return viewState;
}
private buildViewState(status: GitStatusResponse | undefined): GitViewState {
if (status === undefined || !status.isGitRepo || status.files.length === 0) return EMPTY_VIEW_STATE; if (status === undefined || !status.isGitRepo || status.files.length === 0) return EMPTY_VIEW_STATE;
if (this.view === "tree") { if (this.view === "tree") {
const nodes = buildGitFileTree(status.files, status.submodules); const nodes = buildGitFileTree(status.files, status.submodules);
+1 -12
View File
@@ -1,4 +1,5 @@
import type { GitStatusFile } from "./api"; import type { GitStatusFile } from "./api";
import { pointerName, segmentName } from "./gitFileShared";
/** A changed file inside a submodule, shown flat in list view. `path` is the /** 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 * full superproject-relative path (the diff key); `relativePath` is shown in
@@ -70,15 +71,3 @@ function ownerSubmodule(path: string, submodules: readonly string[]): string | u
} }
return best; 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;
}
+35
View File
@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import type { GitStatusFile } from "./api";
import { pointerName, segmentName } from "./gitFileShared";
describe("pointerName", () => {
it("renders the moved pointer as <from> → <to>", () => {
expect(pointerName(pointer("1111111", "2222222"))).toBe("1111111 → 2222222");
});
it("falls back to a plain label when either end is unresolved", () => {
expect(pointerName(pointer(undefined, "2222222"))).toBe("commit");
expect(pointerName(pointer("1111111", undefined))).toBe("commit");
expect(pointerName(pointer(undefined, undefined))).toBe("commit");
});
});
describe("segmentName", () => {
it("returns the final path segment", () => {
expect(segmentName("deps/sub/module")).toBe("module");
});
it("returns the whole path when there is no separator", () => {
expect(segmentName("HARL")).toBe("HARL");
});
});
function pointer(from: string | undefined, to: string | undefined): GitStatusFile {
return {
path: "HARL",
index: "unmodified",
workingTree: "modified",
...(from === undefined ? {} : { submoduleFromCommit: from }),
...(to === undefined ? {} : { submoduleToCommit: to }),
};
}
+18
View File
@@ -0,0 +1,18 @@
import type { GitStatusFile } from "./api";
/**
* Display label for a submodule commit-pointer row: the `<old> → <new>`
* short-SHA summary, or "commit" when the server did not resolve both ends.
*/
export function pointerName(file: GitStatusFile): string {
const from = file.submoduleFromCommit;
const to = file.submoduleToCommit;
return from !== undefined && to !== undefined ? `${from}${to}` : "commit";
}
/** Final segment of a path, used as the display name for tree/list rows. */
export function segmentName(path: string): string {
const segments = path.split("/");
const last = segments[segments.length - 1];
return last ?? path;
}
+1 -12
View File
@@ -1,4 +1,5 @@
import type { GitStatusFile } from "./api"; import type { GitStatusFile } from "./api";
import { pointerName, segmentName } from "./gitFileShared";
/** /**
* A changed file placed at a leaf of the Git file tree. `path` is the full * A changed file placed at a leaf of the Git file tree. `path` is the full
@@ -93,12 +94,6 @@ export function collectGitFileTreeDirectoryPaths(nodes: readonly GitFileTreeNode
return paths; 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 { function createDirectoryAccumulator(path: string): DirectoryAccumulator {
return { path, directories: new Map(), files: [], isSubmodule: false }; return { path, directories: new Map(), files: [], isSubmodule: false };
} }
@@ -125,9 +120,3 @@ function finalizeChildren(directory: DirectoryAccumulator): GitFileTreeNode[] {
const files = [...directory.files].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]; 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;
}