Archived
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
This commit is contained in:
@@ -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.
|
||||
@@ -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<string>();
|
||||
|
||||
protected override willUpdate(changedProperties: PropertyValues<this>): void {
|
||||
if (!changedProperties.has("context")) return;
|
||||
const previous = changedProperties.get("context");
|
||||
if (previous !== undefined && this.context !== undefined && gitPanelContextKey(previous) !== gitPanelContextKey(this.context)) {
|
||||
// Switching workspace/machine resets the ephemeral expand state.
|
||||
this.expandedDirectories = new Set();
|
||||
}
|
||||
}
|
||||
|
||||
override render(): TemplateResult {
|
||||
const context = this.context;
|
||||
if (context === undefined) return html`<p class="muted">Git unavailable.</p>`;
|
||||
const status = context.gitStatus;
|
||||
const treeState = this.computeTreeState(status);
|
||||
return html`
|
||||
<section class="toolbar">
|
||||
<strong>Git</strong>
|
||||
${context.gitStale ? html`<span class="stale">stale</span>` : null}
|
||||
<div class="toolbar-actions">
|
||||
${this.renderViewToggle()}
|
||||
${this.view === "tree" && treeState.directoryPaths.length > 0 ? this.renderExpandCollapseAll(treeState.directoryPaths) : null}
|
||||
<button type="button" @click=${context.onRefreshGit}>Refresh</button>
|
||||
</div>
|
||||
</section>
|
||||
<section class="split">
|
||||
<div class=${this.view === "tree" ? "list tree" : "list"}>
|
||||
${this.renderFileList(context, status, treeState.nodes)}
|
||||
</div>
|
||||
<div class="viewer">${renderDiffViewer(context)}</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderViewToggle(): TemplateResult {
|
||||
return html`
|
||||
<div class="view-toggle" role="group" aria-label="Changed files view">
|
||||
${this.renderViewToggleButton("list", "List")}
|
||||
${this.renderViewToggleButton("tree", "Tree")}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderViewToggleButton(view: GitFileView, label: string): TemplateResult {
|
||||
const active = this.view === view;
|
||||
return html`
|
||||
<button type="button" class=${active ? "selected" : ""} aria-pressed=${active ? "true" : "false"} @click=${() => { this.setView(view); }}>${label}</button>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderExpandCollapseAll(directoryPaths: readonly string[]): TemplateResult {
|
||||
const allExpanded = directoryPaths.every((path) => this.expandedDirectories.has(path));
|
||||
return html`
|
||||
<button type="button" @click=${() => { this.toggleExpandAll(directoryPaths, allExpanded); }}>${allExpanded ? "Collapse all" : "Expand all"}</button>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderFileList(context: WorkspacePanelContext, status: GitStatusResponse | undefined, nodes: readonly GitFileTreeNode[]): TemplateResult {
|
||||
if (status === undefined) return html`<p class="muted">No status loaded.</p>`;
|
||||
if (!status.isGitRepo) return html`<p class="muted">Not a git repository.</p>`;
|
||||
const summary = html`<p class="summary">${gitSummary(status)}</p>`;
|
||||
if (status.files.length === 0) return html`${summary}<p class="muted">No changes.</p>`;
|
||||
const body = this.view === "tree"
|
||||
? 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`
|
||||
<button type="button" class="row" style=${`--depth:${String(depth)}`} aria-expanded=${expanded ? "true" : "false"} @click=${() => { this.toggleDirectory(node.path); }}>
|
||||
<span class="twisty">${expanded ? "▾" : "▸"}</span>
|
||||
<span>${node.name}</span>
|
||||
</button>
|
||||
${expanded ? node.children.map((child) => this.renderTreeNode(context, child, depth + 1)) : null}
|
||||
`;
|
||||
}
|
||||
const selected = context.selectedDiffPath === node.path;
|
||||
return html`
|
||||
<button type="button" class=${selected ? "row selected" : "row"} style=${`--depth:${String(depth)}`} @click=${() => { context.onSelectDiff(node.path); }}>
|
||||
<span>${stateLabel(node.file.index, node.file.workingTree)}</span>
|
||||
<span>${node.name}</span>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderFileRow(context: WorkspacePanelContext, file: GitStatusFile): TemplateResult {
|
||||
const selected = context.selectedDiffPath === file.path;
|
||||
return html`
|
||||
<button type="button" class=${selected ? "row selected" : "row"} @click=${() => { context.onSelectDiff(file.path); }}>
|
||||
<span>${stateLabel(file.index, file.workingTree)}</span>
|
||||
<span>${file.path}</span>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
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`<p class="muted">Select a changed file.</p>`;
|
||||
const unstaged = context.selectedDiff;
|
||||
const staged = context.selectedStagedDiff;
|
||||
if (unstaged === undefined || staged === undefined) return html`<p class="muted">Loading diff…</p>`;
|
||||
const diffs = [staged, unstaged].filter((diff) => diff.diff !== "");
|
||||
if (diffs.length === 0) return html`<p class="muted">No staged or unstaged diff.</p>`;
|
||||
return html`
|
||||
<div class=${diffs.length === 1 ? "diffs single" : "diffs"}>
|
||||
${diffs.map((diff) => renderDiffSection(diff))}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderDiffSection(diff: GitDiffResponse): TemplateResult {
|
||||
loadUnifiedDiffViewer();
|
||||
return html`
|
||||
<section class="diff-section">
|
||||
<div class="viewer-header"><strong>${diff.path ?? "diff"}</strong><small>${diff.staged ? "staged" : "unstaged"}${diff.truncated ? " · truncated" : ""}</small></div>
|
||||
<unified-diff-viewer .diff=${diff.diff}></unified-diff-viewer>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
function loadUnifiedDiffViewer(): void {
|
||||
void import("./UnifiedDiffViewer");
|
||||
}
|
||||
|
||||
function gitSummary(status: GitStatusResponse): string {
|
||||
const branch = status.branch ?? "detached";
|
||||
const ahead = status.ahead ?? 0;
|
||||
const behind = status.behind ?? 0;
|
||||
return ahead === 0 && behind === 0 ? branch : `${branch} · ↑${String(ahead)} ↓${String(behind)}`;
|
||||
}
|
||||
|
||||
function stateLabel(index: string, workingTree: string): string {
|
||||
const label = workingTree !== "unmodified" ? workingTree : index;
|
||||
return label.slice(0, 1).toUpperCase();
|
||||
}
|
||||
|
||||
function gitPanelContextKey(context: WorkspacePanelContext): string {
|
||||
return `${context.machine.id}:${context.workspace.projectId}:${context.workspace.id}`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<string, DirectoryAccumulator>;
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
GIT_FILE_VIEW_STORAGE_KEY,
|
||||
parseGitFileView,
|
||||
readGitFileView,
|
||||
writeGitFileView,
|
||||
} from "./gitFileViewPreference";
|
||||
|
||||
describe("gitFileViewPreference", () => {
|
||||
it("parses stored values and defaults unknown/missing input to list", () => {
|
||||
expect(parseGitFileView("tree")).toBe("tree");
|
||||
expect(parseGitFileView("list")).toBe("list");
|
||||
expect(parseGitFileView(null)).toBe("list");
|
||||
expect(parseGitFileView("")).toBe("list");
|
||||
expect(parseGitFileView("grid")).toBe("list");
|
||||
});
|
||||
|
||||
it("defaults to list when nothing is stored yet", () => {
|
||||
expect(readGitFileView(new FakeStorage())).toBe("list");
|
||||
});
|
||||
|
||||
it("reads and writes the stored view mode", () => {
|
||||
const storage = new FakeStorage();
|
||||
|
||||
writeGitFileView("tree", storage);
|
||||
expect(storage.value(GIT_FILE_VIEW_STORAGE_KEY)).toBe("tree");
|
||||
expect(readGitFileView(storage)).toBe("tree");
|
||||
|
||||
writeGitFileView("list", storage);
|
||||
expect(storage.value(GIT_FILE_VIEW_STORAGE_KEY)).toBe("list");
|
||||
expect(readGitFileView(storage)).toBe("list");
|
||||
});
|
||||
|
||||
it("ignores storage failures and falls back to list", () => {
|
||||
const storage = new ThrowingStorage();
|
||||
|
||||
expect(readGitFileView(storage)).toBe("list");
|
||||
expect(() => { writeGitFileView("tree", storage); }).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
class FakeStorage {
|
||||
private readonly values = new Map<string, string>();
|
||||
|
||||
getItem(key: string): string | null {
|
||||
return this.values.get(key) ?? null;
|
||||
}
|
||||
|
||||
setItem(key: string, value: string): void {
|
||||
this.values.set(key, value);
|
||||
}
|
||||
|
||||
value(key: string): string | undefined {
|
||||
return this.values.get(key);
|
||||
}
|
||||
}
|
||||
|
||||
class ThrowingStorage {
|
||||
getItem(): string | null {
|
||||
throw new Error("blocked");
|
||||
}
|
||||
|
||||
setItem(): void {
|
||||
throw new Error("blocked");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
export const GIT_FILE_VIEW_STORAGE_KEY = "pi-web.gitFileView";
|
||||
|
||||
/**
|
||||
* How the Git panel lists changed files. `list` is the flat, full-path view
|
||||
* (the historical default); `tree` nests files under their directories.
|
||||
*/
|
||||
export type GitFileView = "list" | "tree";
|
||||
|
||||
export type GitFileViewStorage = Pick<Storage, "getItem" | "setItem">;
|
||||
|
||||
export function parseGitFileView(value: string | null): GitFileView {
|
||||
return value === "tree" ? "tree" : "list";
|
||||
}
|
||||
|
||||
export function readGitFileView(storage = browserStorage()): GitFileView {
|
||||
if (storage === undefined) return "list";
|
||||
try {
|
||||
return parseGitFileView(storage.getItem(GIT_FILE_VIEW_STORAGE_KEY));
|
||||
} catch {
|
||||
return "list";
|
||||
}
|
||||
}
|
||||
|
||||
export function writeGitFileView(view: GitFileView, storage = browserStorage()): void {
|
||||
if (storage === undefined) return;
|
||||
try {
|
||||
storage.setItem(GIT_FILE_VIEW_STORAGE_KEY, view);
|
||||
} catch {
|
||||
// Ignore localStorage quota/privacy errors; the chosen view still applies in memory for this tab.
|
||||
}
|
||||
}
|
||||
|
||||
function browserStorage(): GitFileViewStorage | undefined {
|
||||
if (typeof window === "undefined") return undefined;
|
||||
try {
|
||||
return window.localStorage;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { html, type TemplateResult } from "lit";
|
||||
import type { GitDiffResponse, GitStatusResponse } from "../../api";
|
||||
import { renderBuiltinTabIcon } from "../../components/tabIcons";
|
||||
import "../../components/WorkspaceFilesPanel";
|
||||
import "../../components/WorkspaceGitPanel";
|
||||
import type { WorkspacePanelContribution, WorkspacePanelContext } from "../types";
|
||||
|
||||
export function createCoreWorkspacePanels(): WorkspacePanelContribution[] {
|
||||
@@ -42,72 +42,9 @@ function renderTerminal(context: WorkspacePanelContext): TemplateResult {
|
||||
}
|
||||
|
||||
function renderGit(context: WorkspacePanelContext): TemplateResult {
|
||||
const status = context.gitStatus;
|
||||
return html`
|
||||
<section class="toolbar">
|
||||
<strong>Git</strong>
|
||||
${context.gitStale ? html`<span class="stale">stale</span>` : null}
|
||||
<button @click=${context.onRefreshGit}>Refresh</button>
|
||||
</section>
|
||||
<section class="split">
|
||||
<div class="list">
|
||||
${status === undefined ? html`<p class="muted">No status loaded.</p>` : !status.isGitRepo ? html`<p class="muted">Not a git repository.</p>` : html`
|
||||
<p class="summary">${gitSummary(status)}</p>
|
||||
${status.files.length === 0 ? html`<p class="muted">No changes.</p>` : status.files.map((file) => html`
|
||||
<button class="row ${context.selectedDiffPath === file.path ? "selected" : ""}" @click=${() => { context.onSelectDiff(file.path); }}>
|
||||
<span>${stateLabel(file.index, file.workingTree)}</span>
|
||||
<span>${file.path}</span>
|
||||
</button>
|
||||
`)}
|
||||
`}
|
||||
</div>
|
||||
<div class="viewer">
|
||||
${renderDiffViewer(context)}
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderDiffViewer(context: WorkspacePanelContext): TemplateResult {
|
||||
if (context.selectedDiffPath === undefined || context.selectedDiffPath === "") return html`<p class="muted">Select a changed file.</p>`;
|
||||
const unstaged = context.selectedDiff;
|
||||
const staged = context.selectedStagedDiff;
|
||||
if (unstaged === undefined || staged === undefined) return html`<p class="muted">Loading diff…</p>`;
|
||||
const diffs = [staged, unstaged].filter((diff) => diff.diff !== "");
|
||||
if (diffs.length === 0) return html`<p class="muted">No staged or unstaged diff.</p>`;
|
||||
return html`
|
||||
<div class=${diffs.length === 1 ? "diffs single" : "diffs"}>
|
||||
${diffs.map((diff) => renderDiffSection(diff))}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderDiffSection(diff: GitDiffResponse): TemplateResult {
|
||||
loadUnifiedDiffViewer();
|
||||
return html`
|
||||
<section class="diff-section">
|
||||
<div class="viewer-header"><strong>${diff.path ?? "diff"}</strong><small>${diff.staged ? "staged" : "unstaged"}${diff.truncated ? " · truncated" : ""}</small></div>
|
||||
<unified-diff-viewer .diff=${diff.diff}></unified-diff-viewer>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
function loadUnifiedDiffViewer(): void {
|
||||
void import("../../components/UnifiedDiffViewer");
|
||||
return html`<workspace-git-panel .context=${context}></workspace-git-panel>`;
|
||||
}
|
||||
|
||||
function loadTerminalPanel(): void {
|
||||
void import("../../components/TerminalPanel");
|
||||
}
|
||||
|
||||
function gitSummary(status: GitStatusResponse): string {
|
||||
const branch = status.branch ?? "detached";
|
||||
const ahead = status.ahead ?? 0;
|
||||
const behind = status.behind ?? 0;
|
||||
return ahead === 0 && behind === 0 ? branch : `${branch} · ↑${String(ahead)} ↓${String(behind)}`;
|
||||
}
|
||||
|
||||
function stateLabel(index: string, workingTree: string): string {
|
||||
const label = workingTree !== "unmodified" ? workingTree : index;
|
||||
return label.slice(0, 1).toUpperCase();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user