Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "git-fleet",
"displayName": "Git Fleet",
"version": "0.2.1",
"version": "0.2.2",
"description": "%description%",
"categories": [
"SCM Providers",
Expand Down
8 changes: 8 additions & 0 deletions plans/content-discovery-queue.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
52 changes: 21 additions & 31 deletions src/extension/repoNavigator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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(
Expand Down Expand Up @@ -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<RepositorySummary> {
const git = simpleGit({
baseDir: repoPath,
Expand Down Expand Up @@ -286,10 +269,13 @@ class RepositoryNavigatorProvider implements vscode.TreeDataProvider<NavigatorNo
}

const { summary } = node;
const item = new vscode.TreeItem(summary.name, vscode.TreeItemCollapsibleState.None);
const state = summary.dirtyCount === 0 ? "clean" : `${summary.dirtyCount} dirty`;
const worktrees = summary.worktreeCount > 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")
Expand All @@ -308,9 +294,12 @@ class RepositoryNavigatorProvider implements vscode.TreeDataProvider<NavigatorNo
tooltip.appendText(`${summary.path}\n`);
tooltip.appendMarkdown(`\n${summary.latestCommitMessage} \n`);
tooltip.appendText(
`${summary.branch} · ${summary.dirtyCount} dirty files · ${summary.worktreeCount} worktrees`
`${summary.branch} · ${summary.dirtyCount === 0 ? "clean" : `${summary.dirtyCount} dirty files`} · ${summary.worktreeCount} worktrees · ${formatRepositoryAge(summary.latestCommitAt)}`
);
item.tooltip = tooltip;
item.accessibilityInformation = {
label: `${summary.name}, ${summary.dirtyCount === 0 ? "clean" : `${summary.dirtyCount} dirty files`}, branch ${summary.branch}, last update ${formatRepositoryAge(summary.latestCommitAt)}, ${summary.worktreeCount} worktrees`
};
return item;
}

Expand Down Expand Up @@ -418,6 +407,7 @@ export function registerRepositoryNavigator(
const deregisterRepoListener = repoManager.registerViewCallback(() => provider.refresh());
ctx.subscriptions.push(
treeView,
vscode.window.registerFileDecorationProvider(new RepositoryRowDecorationProvider()),
{ dispose: deregisterRepoListener },
vscode.commands.registerCommand("git-fleet.refreshRepositoryNavigator", async () => {
await rescanRepositories();
Expand Down
97 changes: 97 additions & 0 deletions src/extension/repositoryRowPresentation.ts
Original file line number Diff line number Diff line change
@@ -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));
}
}
78 changes: 78 additions & 0 deletions tests/extension/repositoryRowPresentation.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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"
);
});
});