From 08e61a83cbfc6c91a1e4c7b7ee471bc3c00ef54b Mon Sep 17 00:00:00 2001 From: William Grant Date: Wed, 5 Aug 2026 16:23:53 -0500 Subject: [PATCH] Preserve repository status in narrow panes --- CHANGELOG.md | 15 ++- README.md | 1 + package.json | 2 +- plans/content-discovery-queue.md | 8 ++ src/extension/repoNavigator.ts | 52 ++++------ src/extension/repositoryRowPresentation.ts | 97 +++++++++++++++++++ .../repositoryRowPresentation.test.ts | 78 +++++++++++++++ 7 files changed, 220 insertions(+), 33 deletions(-) create mode 100644 src/extension/repositoryRowPresentation.ts create mode 100644 tests/extension/repositoryRowPresentation.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b813951..b287556 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ ## [Unreleased] +## [0.2.2] - 2026-08-05 + +### Added + +- Persistent right-edge repository badges showing a clean checkmark or dirty-file count + +### Changed + +- Repository rows now prioritize update age, compact branch names, and worktree count when the navigator is narrow +- Long repository and branch names use middle ellipses while their complete values remain in tooltips and accessibility labels +- Repository tree item IDs are stable across dirty-state changes so selection survives refreshes + ## [0.2.1] - 2026-08-05 ### Added @@ -60,7 +72,8 @@ The first Git Fleet release, forked from Neo Git Graph 0.5.0. Git Fleet began from Neo Git Graph 0.5.0. Its earlier changelog remains available in the [upstream repository](https://github.com/asispts/neo-git-graph/blob/main/CHANGELOG.md). -[Unreleased]: https://github.com/wrgrant/git-fleet/compare/v0.2.1...HEAD +[Unreleased]: https://github.com/wrgrant/git-fleet/compare/v0.2.2...HEAD +[0.2.2]: https://github.com/wrgrant/git-fleet/releases/tag/v0.2.2 [0.2.1]: https://github.com/wrgrant/git-fleet/releases/tag/v0.2.1 [0.2.0]: https://github.com/wrgrant/git-fleet/releases/tag/v0.2.0 [0.1.0]: https://github.com/wrgrant/git-fleet/releases/tag/v0.1.0 diff --git a/README.md b/README.md index ec729ea..245777d 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ Git Fleet is a VS Code extension for people working across more repositories and - Optional global search roots for repositories outside the current workspace - Independent list/folder-tree layouts with recent-activity, dirty-file, or alphabetical sorting - A persistent eye control that hides clean repositories +- Compact repository rows that preserve age and branch context, with clean/dirty status pinned at the right edge - A clickable Uncommitted Changes row with the same file tree and diff flow as a commit - A worktree rail showing each checkout's HEAD, dirty state, branch, and inferred base - Viewport-edge arrows when a worktree connector continues above or below the loaded history diff --git a/package.json b/package.json index d347c86..f49c9ff 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "git-fleet", "displayName": "Git Fleet", - "version": "0.2.1", + "version": "0.2.2", "description": "%description%", "categories": [ "SCM Providers", diff --git a/plans/content-discovery-queue.md b/plans/content-discovery-queue.md index 601c494..aaabe40 100644 --- a/plans/content-discovery-queue.md +++ b/plans/content-discovery-queue.md @@ -1,5 +1,13 @@ # Content discovery queue +## 2026-08-05 — Design native tree rows around information loss + +- Status: idea +- Why it matters: VS Code extensions cannot set custom flex behavior or read the width of a native TreeView, so ordinary responsive CSS is not available. +- Evidence: Git Fleet moves clean/dirty state into a native right-edge file decoration, caps it at two characters, and orders compact row metadata by importance: age, worktrees, then branch. Full values remain accessible by hover and screen reader. +- Files changed: `src/extension/repositoryRowPresentation.ts`, `src/extension/repoNavigator.ts` +- Suggested content angle: Responsive design in constrained extension APIs is less about pixels and more about deciding which information is allowed to disappear first. + ## 2026-08-05 — Keep portable settings, add a humane management surface - Status: idea diff --git a/src/extension/repoNavigator.ts b/src/extension/repoNavigator.ts index 4d8e37d..1c1fd00 100644 --- a/src/extension/repoNavigator.ts +++ b/src/extension/repoNavigator.ts @@ -5,6 +5,14 @@ import * as vscode from "vscode"; import { evalPromises } from "@/backend/utils/promise"; import { config } from "@/config"; +import { + createRepositoryRowUri, + formatRepositoryAge, + formatRepositoryDescription, + formatRepositoryLabel, + RepositoryRowDecorationProvider, + RepositoryRowSummary +} from "@/extension/repositoryRowPresentation"; import { getRepositorySearchRoots } from "@/extension/repositorySearchRoots"; import { ExtensionState } from "@/extensionState"; import { RepositoryNavigatorLayout, RepositoryNavigatorSort } from "@/types"; @@ -15,14 +23,8 @@ const VIEW_ID = "git-fleet.repositoryNavigator"; const HIDE_CLEAN_CONTEXT = "gitFleet.hideCleanRepositories"; const TREE_LAYOUT_CONTEXT = "gitFleet.repositoryLayoutTree"; -export type RepositorySummary = { - branch: string; - dirtyCount: number; - latestCommitAt: number; +export type RepositorySummary = RepositoryRowSummary & { latestCommitMessage: string; - name: string; - path: string; - worktreeCount: number; }; export function filterRepositorySummaries( @@ -76,25 +78,6 @@ export function repositoryPathFromCommandArgument(argument: unknown): string | u return undefined; } -function formatAge(timestamp: number): string { - if (timestamp === 0) { - return "no commits"; - } - const elapsedMinutes = Math.max(0, Math.floor((Date.now() - timestamp) / 60_000)); - if (elapsedMinutes < 60) { - return elapsedMinutes <= 1 ? "now" : `${elapsedMinutes}m`; - } - const elapsedHours = Math.floor(elapsedMinutes / 60); - if (elapsedHours < 24) { - return `${elapsedHours}h`; - } - const elapsedDays = Math.floor(elapsedHours / 24); - if (elapsedDays < 30) { - return `${elapsedDays}d`; - } - return `${Math.floor(elapsedDays / 30)}mo`; -} - async function loadRepositorySummary(repoPath: string): Promise { const git = simpleGit({ baseDir: repoPath, @@ -286,10 +269,13 @@ class RepositoryNavigatorProvider implements vscode.TreeDataProvider 1 ? ` · ${summary.worktreeCount} worktrees` : ""; - item.description = `${summary.branch} · ${state}${worktrees} · ${formatAge(summary.latestCommitAt)}`; + const item = new vscode.TreeItem( + formatRepositoryLabel(summary.name), + vscode.TreeItemCollapsibleState.None + ); + item.id = `repository:${summary.path}`; + item.description = formatRepositoryDescription(summary); + item.resourceUri = createRepositoryRowUri(summary); item.iconPath = summary.dirtyCount === 0 ? new vscode.ThemeIcon("repo") @@ -308,9 +294,12 @@ class RepositoryNavigatorProvider implements vscode.TreeDataProvider provider.refresh()); ctx.subscriptions.push( treeView, + vscode.window.registerFileDecorationProvider(new RepositoryRowDecorationProvider()), { dispose: deregisterRepoListener }, vscode.commands.registerCommand("git-fleet.refreshRepositoryNavigator", async () => { await rescanRepositories(); diff --git a/src/extension/repositoryRowPresentation.ts b/src/extension/repositoryRowPresentation.ts new file mode 100644 index 0000000..d2de75e --- /dev/null +++ b/src/extension/repositoryRowPresentation.ts @@ -0,0 +1,97 @@ +import * as vscode from "vscode"; + +export type RepositoryRowSummary = { + branch: string; + dirtyCount: number; + latestCommitAt: number; + name: string; + path: string; + worktreeCount: number; +}; + +const REPOSITORY_ROW_SCHEME = "git-fleet-repository"; + +export function formatRepositoryAge(timestamp: number): string { + if (timestamp === 0) { + return "no commits"; + } + const elapsedMinutes = Math.max(0, Math.floor((Date.now() - timestamp) / 60_000)); + if (elapsedMinutes < 60) { + return elapsedMinutes <= 1 ? "now" : `${elapsedMinutes}m`; + } + const elapsedHours = Math.floor(elapsedMinutes / 60); + if (elapsedHours < 24) { + return `${elapsedHours}h`; + } + const elapsedDays = Math.floor(elapsedHours / 24); + if (elapsedDays < 30) { + return `${elapsedDays}d`; + } + return `${Math.floor(elapsedDays / 30)}mo`; +} + +export function compactMiddle(value: string, maxLength: number): string { + if (value.length <= maxLength) { + return value; + } + const available = maxLength - 1; + const prefixLength = Math.ceil(available * 0.45); + return `${value.slice(0, prefixLength)}…${value.slice(-(available - prefixLength))}`; +} + +export function formatRepositoryLabel(name: string): string { + return compactMiddle(name, 22); +} + +export function formatRepositoryDescription(summary: RepositoryRowSummary): string { + const worktrees = summary.worktreeCount > 1 ? ` · ${summary.worktreeCount}wt` : ""; + return `${formatRepositoryAge(summary.latestCommitAt)}${worktrees} · ${compactMiddle(summary.branch, 22)}`; +} + +export function getRepositoryDecoration(summary: RepositoryRowSummary): { + badge: string; + colorId: string; + tooltip: string; +} { + if (summary.dirtyCount === 0) { + return { + badge: "✓", + colorId: "gitDecoration.addedResourceForeground", + tooltip: "Clean working tree" + }; + } + return { + badge: String(Math.min(summary.dirtyCount, 99)), + colorId: "gitDecoration.modifiedResourceForeground", + tooltip: `${summary.dirtyCount} dirty ${summary.dirtyCount === 1 ? "file" : "files"}` + }; +} + +export function createRepositoryRowUri(summary: RepositoryRowSummary): vscode.Uri { + const decoration = getRepositoryDecoration(summary); + return vscode.Uri.from({ + scheme: REPOSITORY_ROW_SCHEME, + path: summary.path, + query: new URLSearchParams({ + badge: decoration.badge, + color: decoration.colorId, + tooltip: decoration.tooltip + }).toString() + }); +} + +export class RepositoryRowDecorationProvider implements vscode.FileDecorationProvider { + public provideFileDecoration(uri: vscode.Uri): vscode.FileDecoration | undefined { + if (uri.scheme !== REPOSITORY_ROW_SCHEME) { + return undefined; + } + const values = new URLSearchParams(uri.query); + const badge = values.get("badge"); + const color = values.get("color"); + const tooltip = values.get("tooltip"); + if (!badge || !color || !tooltip) { + return undefined; + } + return new vscode.FileDecoration(badge, tooltip, new vscode.ThemeColor(color)); + } +} diff --git a/tests/extension/repositoryRowPresentation.test.ts b/tests/extension/repositoryRowPresentation.test.ts new file mode 100644 index 0000000..7d29d2f --- /dev/null +++ b/tests/extension/repositoryRowPresentation.test.ts @@ -0,0 +1,78 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + compactMiddle, + createRepositoryRowUri, + formatRepositoryDescription, + formatRepositoryLabel, + getRepositoryDecoration, + RepositoryRowDecorationProvider, + RepositoryRowSummary +} from "@/extension/repositoryRowPresentation"; + +const vscodeMock = vi.hoisted(() => ({ + FileDecoration: class FileDecoration { + constructor( + public readonly badge: string, + public readonly tooltip: string, + public readonly color: unknown + ) {} + }, + ThemeColor: class ThemeColor { + constructor(public readonly id: string) {} + }, + Uri: { from: (value: unknown) => value } +})); + +vi.mock("vscode", () => vscodeMock); + +function summary(overrides: Partial = {}): RepositoryRowSummary { + return { + branch: "codex/player-school-attendance-defaults", + dirtyCount: 43, + latestCommitAt: Date.now() - 48 * 60_000, + name: "team-builder-cloud", + path: "/repos/team-builder-cloud", + worktreeCount: 8, + ...overrides + }; +} + +afterEach(() => vi.useRealTimers()); + +describe("repository row presentation", () => { + it("compacts long labels and branches through the middle", () => { + expect(compactMiddle("codex/player-school-attendance-defaults", 22)).toHaveLength(22); + expect(compactMiddle("main", 22)).toBe("main"); + expect(formatRepositoryLabel("a-very-long-repository-name-that-keeps-going")).toContain("…"); + }); + + it("keeps age and worktree count ahead of a compact branch", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-08-05T20:00:00Z")); + const row = summary({ latestCommitAt: Date.now() - 48 * 60_000 }); + + expect(formatRepositoryDescription(row)).toBe("48m · 8wt · codex/play…ce-defaults"); + }); + + it("uses a right-edge clean or bounded dirty badge", () => { + expect(getRepositoryDecoration(summary({ dirtyCount: 0 }))).toMatchObject({ + badge: "✓", + tooltip: "Clean working tree" + }); + expect(getRepositoryDecoration(summary({ dirtyCount: 143 }))).toMatchObject({ + badge: "99", + tooltip: "143 dirty files" + }); + }); + + it("round-trips the status through the custom row URI decoration provider", () => { + const uri = createRepositoryRowUri(summary({ dirtyCount: 2 })); + const decoration = new RepositoryRowDecorationProvider().provideFileDecoration(uri); + + expect(decoration).toMatchObject({ badge: "2", tooltip: "2 dirty files" }); + expect((decoration!.color as { id: string }).id).toBe( + "gitDecoration.modifiedResourceForeground" + ); + }); +});