mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-19 10:02:04 +08:00
feat(vscode): show deterministic worktree diff and origin commit stats (#6383)
* tmp * fix(vscode): prevent overlapping stats polling and localize hover labels * fix(vscode): harden worktree stats polling and i18n lint * refactor(vscode): extract worktree stats poller
This commit is contained in:
@@ -5,6 +5,7 @@ import { KiloProvider } from "../KiloProvider"
|
||||
import { buildWebviewHtml } from "../utils"
|
||||
import { WorktreeManager, type CreateWorktreeResult } from "./WorktreeManager"
|
||||
import { WorktreeStateManager } from "./WorktreeStateManager"
|
||||
import { WorktreeStatsPoller } from "./WorktreeStatsPoller"
|
||||
import { versionedName } from "./branch-name"
|
||||
import { normalizePath } from "./git-import"
|
||||
import { SetupScriptService } from "./SetupScriptService"
|
||||
@@ -40,6 +41,7 @@ export class AgentManagerProvider implements vscode.Disposable {
|
||||
private diffInterval: ReturnType<typeof setInterval> | undefined
|
||||
private diffSessionId: string | undefined
|
||||
private lastDiffHash: string | undefined
|
||||
private statsPoller: WorktreeStatsPoller
|
||||
private cachedDiffTarget: { directory: string; baseBranch: string } | undefined
|
||||
|
||||
constructor(
|
||||
@@ -50,6 +52,14 @@ export class AgentManagerProvider implements vscode.Disposable {
|
||||
this.terminalManager = new SessionTerminalManager((msg) =>
|
||||
this.outputChannel.appendLine(`[SessionTerminal] ${msg}`),
|
||||
)
|
||||
this.statsPoller = new WorktreeStatsPoller({
|
||||
getWorktrees: () => this.state?.getWorktrees() ?? [],
|
||||
getHttpClient: () => this.connectionService.getHttpClient(),
|
||||
onStats: (stats) => {
|
||||
this.postToWebview({ type: "agentManager.worktreeStats", stats })
|
||||
},
|
||||
log: (...args) => this.log(...args),
|
||||
})
|
||||
}
|
||||
|
||||
private log(...args: unknown[]) {
|
||||
@@ -95,6 +105,8 @@ export class AgentManagerProvider implements vscode.Disposable {
|
||||
|
||||
this.panel.onDidDispose(() => {
|
||||
this.log("Panel disposed")
|
||||
this.statsPoller.stop()
|
||||
this.stopDiffPolling()
|
||||
this.provider?.dispose()
|
||||
this.provider = undefined
|
||||
this.panel = undefined
|
||||
@@ -1212,6 +1224,10 @@ export class AgentManagerProvider implements vscode.Disposable {
|
||||
sessionsCollapsed: state.getSessionsCollapsed(),
|
||||
isGitRepo: true,
|
||||
})
|
||||
|
||||
// Keep stats polling in sync with worktree count
|
||||
const worktrees = state.getWorktrees()
|
||||
this.statsPoller.setEnabled(worktrees.length > 0)
|
||||
}
|
||||
|
||||
/** Push empty state when the workspace is not a git repo or has no workspace folder. */
|
||||
@@ -1463,6 +1479,7 @@ export class AgentManagerProvider implements vscode.Disposable {
|
||||
|
||||
public dispose(): void {
|
||||
this.stopDiffPolling()
|
||||
this.statsPoller.stop()
|
||||
this.terminalManager.dispose()
|
||||
this.provider?.dispose()
|
||||
this.panel?.dispose()
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
import * as cp from "child_process"
|
||||
import * as nodePath from "path"
|
||||
import type { HttpClient } from "../services/cli-backend"
|
||||
import type { Worktree } from "./WorktreeStateManager"
|
||||
|
||||
export interface WorktreeStats {
|
||||
worktreeId: string
|
||||
additions: number
|
||||
deletions: number
|
||||
commits: number
|
||||
}
|
||||
|
||||
interface WorktreeStatsPollerOptions {
|
||||
getWorktrees: () => Worktree[]
|
||||
getHttpClient: () => HttpClient
|
||||
onStats: (stats: WorktreeStats[]) => void
|
||||
log: (...args: unknown[]) => void
|
||||
intervalMs?: number
|
||||
refreshMs?: number
|
||||
runGit?: (args: string[], cwd: string) => Promise<string>
|
||||
}
|
||||
|
||||
export class WorktreeStatsPoller {
|
||||
private timer: ReturnType<typeof setTimeout> | undefined
|
||||
private active = false
|
||||
private busy = false
|
||||
private lastHash: string | undefined
|
||||
private lastStats: Record<string, { additions: number; deletions: number; commits: number }> = {}
|
||||
private lastFetch = new Map<string, number>()
|
||||
private inflightFetch = new Map<string, Promise<void>>()
|
||||
private readonly intervalMs: number
|
||||
private readonly refreshMs: number
|
||||
private readonly runGit: (args: string[], cwd: string) => Promise<string>
|
||||
|
||||
constructor(private readonly options: WorktreeStatsPollerOptions) {
|
||||
this.intervalMs = options.intervalMs ?? 5000
|
||||
this.refreshMs = options.refreshMs ?? 120000
|
||||
this.runGit =
|
||||
options.runGit ??
|
||||
((args, cwd) =>
|
||||
new Promise((resolve, reject) => {
|
||||
cp.execFile("git", args, { cwd, timeout: 10000 }, (err, stdout) => {
|
||||
if (err) reject(err)
|
||||
else resolve(stdout.trim())
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
setEnabled(enabled: boolean): void {
|
||||
if (enabled) {
|
||||
if (this.active) return
|
||||
this.start()
|
||||
return
|
||||
}
|
||||
this.stop()
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.active = false
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer)
|
||||
this.timer = undefined
|
||||
}
|
||||
this.busy = false
|
||||
this.lastHash = undefined
|
||||
this.lastStats = {}
|
||||
}
|
||||
|
||||
private start(): void {
|
||||
this.stop()
|
||||
this.active = true
|
||||
void this.poll()
|
||||
}
|
||||
|
||||
private schedule(delay: number): void {
|
||||
if (!this.active) return
|
||||
this.timer = setTimeout(() => {
|
||||
void this.poll()
|
||||
}, delay)
|
||||
}
|
||||
|
||||
private poll(): Promise<void> {
|
||||
if (!this.active) return Promise.resolve()
|
||||
if (this.busy) return Promise.resolve()
|
||||
this.busy = true
|
||||
return this.fetch().finally(() => {
|
||||
this.busy = false
|
||||
this.schedule(this.intervalMs)
|
||||
})
|
||||
}
|
||||
|
||||
private async fetch(): Promise<void> {
|
||||
const worktrees = this.options.getWorktrees()
|
||||
if (worktrees.length === 0) return
|
||||
|
||||
const client = (() => {
|
||||
try {
|
||||
return this.options.getHttpClient()
|
||||
} catch (err) {
|
||||
this.options.log("Failed to get HTTP client for worktree stats:", err)
|
||||
return undefined
|
||||
}
|
||||
})()
|
||||
if (!client) return
|
||||
|
||||
const stats = (
|
||||
await Promise.all(
|
||||
worktrees.map(async (wt) => {
|
||||
try {
|
||||
const diffs = await client.getWorktreeDiff(wt.path, wt.parentBranch)
|
||||
const additions = diffs.reduce((sum, diff) => sum + diff.additions, 0)
|
||||
const deletions = diffs.reduce((sum, diff) => sum + diff.deletions, 0)
|
||||
const commits = await this.countMissingOriginCommits(wt.path, wt.parentBranch)
|
||||
return { worktreeId: wt.id, additions, deletions, commits }
|
||||
} catch (err) {
|
||||
this.options.log(`Failed to fetch worktree stats for ${wt.branch} (${wt.path}):`, err)
|
||||
const prev = this.lastStats[wt.id]
|
||||
if (!prev) return undefined
|
||||
return {
|
||||
worktreeId: wt.id,
|
||||
additions: prev.additions,
|
||||
deletions: prev.deletions,
|
||||
commits: prev.commits,
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
).filter((item): item is WorktreeStats => !!item)
|
||||
|
||||
if (stats.length === 0) return
|
||||
|
||||
const hash = stats.map((item) => `${item.worktreeId}:${item.additions}:${item.deletions}:${item.commits}`).join("|")
|
||||
if (hash === this.lastHash) return
|
||||
this.lastHash = hash
|
||||
this.lastStats = stats.reduce(
|
||||
(acc, item) => {
|
||||
acc[item.worktreeId] = {
|
||||
additions: item.additions,
|
||||
deletions: item.deletions,
|
||||
commits: item.commits,
|
||||
}
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, { additions: number; deletions: number; commits: number }>,
|
||||
)
|
||||
|
||||
this.options.onStats(stats)
|
||||
}
|
||||
|
||||
private gitExec(args: string[], cwd: string): Promise<string> {
|
||||
return this.runGit(args, cwd)
|
||||
}
|
||||
|
||||
private hasRemoteRef(cwd: string, ref: string): Promise<boolean> {
|
||||
return this.gitExec(["rev-parse", "--verify", "--quiet", `refs/remotes/${ref}`], cwd)
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
}
|
||||
|
||||
private async refreshRemote(cwd: string, remote: string): Promise<void> {
|
||||
if (!remote) return
|
||||
|
||||
const commonRaw = await this.gitExec(["rev-parse", "--git-common-dir"], cwd).catch(() => cwd)
|
||||
const common = nodePath.isAbsolute(commonRaw) ? commonRaw : nodePath.resolve(cwd, commonRaw)
|
||||
const key = `${common}:${remote}`
|
||||
|
||||
const existing = this.inflightFetch.get(key)
|
||||
if (existing) return existing
|
||||
|
||||
const prev = this.lastFetch.get(key) ?? 0
|
||||
const now = Date.now()
|
||||
if (now - prev < this.refreshMs) return
|
||||
this.lastFetch.set(key, now)
|
||||
|
||||
const job = this.gitExec(["fetch", "--quiet", "--no-tags", remote], cwd)
|
||||
.catch((err) => {
|
||||
this.options.log(`Failed to refresh remote refs for ${cwd}:`, err)
|
||||
})
|
||||
.then(() => undefined)
|
||||
.finally(() => {
|
||||
this.inflightFetch.delete(key)
|
||||
})
|
||||
this.inflightFetch.set(key, job)
|
||||
return job
|
||||
}
|
||||
|
||||
private async countMissingOriginCommits(cwd: string, parentBranch: string): Promise<number> {
|
||||
const upstream = await this.gitExec(
|
||||
["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"],
|
||||
cwd,
|
||||
).catch(() => "")
|
||||
|
||||
const branch = await this.gitExec(["branch", "--show-current"], cwd).catch(() => "")
|
||||
const branchRemote = branch ? await this.gitExec(["config", `branch.${branch}.remote`], cwd).catch(() => "") : ""
|
||||
const upstreamRemote = upstream.includes("/") ? upstream.split("/")[0] : ""
|
||||
const remote = upstreamRemote || branchRemote || "origin"
|
||||
await this.refreshRemote(cwd, remote)
|
||||
|
||||
if (upstream) {
|
||||
const count = await this.gitExec(["rev-list", "--count", `${upstream}..HEAD`], cwd).catch(() => "0")
|
||||
return parseInt(count, 10) || 0
|
||||
}
|
||||
|
||||
const remoteBranch = branch ? `${remote}/${branch}` : ""
|
||||
const hasRemoteBranch = remoteBranch ? await this.hasRemoteRef(cwd, remoteBranch) : false
|
||||
|
||||
const remoteParent = `${remote}/${parentBranch}`
|
||||
const hasRemoteParent = await this.hasRemoteRef(cwd, remoteParent)
|
||||
|
||||
const ref = hasRemoteBranch ? remoteBranch : hasRemoteParent ? remoteParent : parentBranch
|
||||
const count = await this.gitExec(["rev-list", "--count", `${ref}..HEAD`], cwd).catch(() => "0")
|
||||
return parseInt(count, 10) || 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { describe, it, expect } from "bun:test"
|
||||
import type { HttpClient } from "../../src/services/cli-backend"
|
||||
import { WorktreeStatsPoller } from "../../src/agent-manager/WorktreeStatsPoller"
|
||||
import type { Worktree } from "../../src/agent-manager/WorktreeStateManager"
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
async function waitFor(check: () => boolean, timeout = 500): Promise<void> {
|
||||
const start = Date.now()
|
||||
while (!check()) {
|
||||
if (Date.now() - start > timeout) throw new Error("timed out waiting for condition")
|
||||
await sleep(5)
|
||||
}
|
||||
}
|
||||
|
||||
function worktree(id: string): Worktree {
|
||||
return {
|
||||
id,
|
||||
branch: `branch-${id}`,
|
||||
path: `/tmp/${id}`,
|
||||
parentBranch: "main",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
}
|
||||
}
|
||||
|
||||
function diff(additions: number, deletions: number) {
|
||||
return [{ file: "file.ts", before: "", after: "", additions, deletions, status: "modified" as const }]
|
||||
}
|
||||
|
||||
describe("WorktreeStatsPoller", () => {
|
||||
it("does not overlap polling runs", async () => {
|
||||
let running = 0
|
||||
let max = 0
|
||||
let calls = 0
|
||||
|
||||
const client = {
|
||||
getWorktreeDiff: async () => {
|
||||
calls += 1
|
||||
running += 1
|
||||
max = Math.max(max, running)
|
||||
await sleep(40)
|
||||
running -= 1
|
||||
return diff(2, 1)
|
||||
},
|
||||
} as unknown as HttpClient
|
||||
|
||||
const poller = new WorktreeStatsPoller({
|
||||
getWorktrees: () => [worktree("a")],
|
||||
getHttpClient: () => client,
|
||||
onStats: () => undefined,
|
||||
log: () => undefined,
|
||||
intervalMs: 5,
|
||||
runGit: async (args) => {
|
||||
if (args[0] === "rev-parse" && args[1] === "--git-common-dir") return ".git"
|
||||
if (args[0] === "rev-parse" && args[3] === "@{upstream}") return "origin/main"
|
||||
if (args[0] === "fetch") return ""
|
||||
if (args[0] === "rev-list") return "1"
|
||||
return ""
|
||||
},
|
||||
})
|
||||
|
||||
poller.setEnabled(true)
|
||||
await waitFor(() => calls >= 2)
|
||||
poller.stop()
|
||||
|
||||
expect(max).toBe(1)
|
||||
})
|
||||
|
||||
it("keeps last-known stats when a later poll fails", async () => {
|
||||
let calls = 0
|
||||
const emitted: Array<Array<{ worktreeId: string; additions: number; deletions: number; commits: number }>> = []
|
||||
|
||||
const client = {
|
||||
getWorktreeDiff: async () => {
|
||||
calls += 1
|
||||
if (calls === 1) return diff(7, 3)
|
||||
throw new Error("transient backend failure")
|
||||
},
|
||||
} as unknown as HttpClient
|
||||
|
||||
const poller = new WorktreeStatsPoller({
|
||||
getWorktrees: () => [worktree("a")],
|
||||
getHttpClient: () => client,
|
||||
onStats: (stats) => emitted.push(stats),
|
||||
log: () => undefined,
|
||||
intervalMs: 5,
|
||||
runGit: async (args) => {
|
||||
if (args[0] === "rev-parse" && args[1] === "--git-common-dir") return ".git"
|
||||
if (args[0] === "rev-parse" && args[3] === "@{upstream}") return "origin/main"
|
||||
if (args[0] === "fetch") return ""
|
||||
if (args[0] === "rev-list") return "2"
|
||||
return ""
|
||||
},
|
||||
})
|
||||
|
||||
poller.setEnabled(true)
|
||||
await waitFor(() => calls >= 2)
|
||||
poller.stop()
|
||||
|
||||
expect(emitted.length).toBeGreaterThan(0)
|
||||
const first = emitted[0]
|
||||
if (!first) throw new Error("expected emitted stats")
|
||||
expect(first[0]).toEqual({ worktreeId: "a", additions: 7, deletions: 3, commits: 2 })
|
||||
const hasZeros = emitted.some((batch) =>
|
||||
batch.some((item) => item.additions === 0 && item.deletions === 0 && item.commits === 0),
|
||||
)
|
||||
expect(hasZeros).toBe(false)
|
||||
})
|
||||
|
||||
it("refreshes upstream remote once for concurrent worktrees", async () => {
|
||||
const commands: string[][] = []
|
||||
const emitted: Array<Array<{ worktreeId: string; additions: number; deletions: number; commits: number }>> = []
|
||||
|
||||
const client = {
|
||||
getWorktreeDiff: async () => diff(0, 0),
|
||||
} as unknown as HttpClient
|
||||
|
||||
const poller = new WorktreeStatsPoller({
|
||||
getWorktrees: () => [worktree("a"), worktree("b")],
|
||||
getHttpClient: () => client,
|
||||
onStats: (stats) => emitted.push(stats),
|
||||
log: () => undefined,
|
||||
intervalMs: 500,
|
||||
runGit: async (args) => {
|
||||
commands.push(args)
|
||||
if (args[0] === "rev-parse" && args[1] === "--git-common-dir") return "/repo/.git"
|
||||
if (args[0] === "rev-parse" && args[3] === "@{upstream}") return "upstream/main"
|
||||
if (args[0] === "branch") return "feature"
|
||||
if (args[0] === "config") return "origin"
|
||||
if (args[0] === "fetch") return ""
|
||||
if (args[0] === "rev-list") return "0"
|
||||
return ""
|
||||
},
|
||||
})
|
||||
|
||||
poller.setEnabled(true)
|
||||
await waitFor(() => emitted.length >= 1)
|
||||
poller.stop()
|
||||
|
||||
const fetches = commands.filter((cmd) => cmd[0] === "fetch")
|
||||
expect(fetches.length).toBe(1)
|
||||
const fetch = fetches[0]
|
||||
if (!fetch) throw new Error("expected fetch command")
|
||||
expect(fetch[3]).toBe("upstream")
|
||||
})
|
||||
})
|
||||
@@ -23,7 +23,9 @@ import type {
|
||||
AgentManagerImportResultMessage,
|
||||
AgentManagerWorktreeDiffMessage,
|
||||
AgentManagerWorktreeDiffLoadingMessage,
|
||||
AgentManagerWorktreeStatsMessage,
|
||||
WorktreeFileDiff,
|
||||
WorktreeGitStats,
|
||||
WorktreeState,
|
||||
ManagedSessionState,
|
||||
SessionInfo,
|
||||
@@ -304,6 +306,9 @@ const AgentManagerContent: Component = () => {
|
||||
const [diffLoading, setDiffLoading] = createSignal(false)
|
||||
const [diffWidth, setDiffWidth] = createSignal(Math.round(window.innerWidth * 0.5))
|
||||
|
||||
// Per-worktree git stats (diff additions/deletions, commits missing from origin)
|
||||
const [worktreeStats, setWorktreeStats] = createSignal<Record<string, WorktreeGitStats>>({})
|
||||
|
||||
// Pending local tab counter for generating unique IDs
|
||||
let pendingCounter = 0
|
||||
const PENDING_PREFIX = "pending:"
|
||||
@@ -842,6 +847,13 @@ const AgentManagerContent: Component = () => {
|
||||
const ev = msg as AgentManagerWorktreeDiffLoadingMessage
|
||||
setDiffLoading(ev.loading)
|
||||
}
|
||||
|
||||
if (msg.type === "agentManager.worktreeStats") {
|
||||
const ev = msg as AgentManagerWorktreeStatsMessage
|
||||
const map: Record<string, WorktreeGitStats> = {}
|
||||
for (const s of ev.stats) map[s.worktreeId] = s
|
||||
setWorktreeStats(map)
|
||||
}
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
@@ -1378,13 +1390,38 @@ const AgentManagerContent: Component = () => {
|
||||
</Show>
|
||||
{(() => {
|
||||
const num = idx() + 2
|
||||
const stats = () => worktreeStats()[wt.id]
|
||||
return (
|
||||
<Show when={num <= MAX_JUMP_INDEX}>
|
||||
<span class="am-shortcut-badge">
|
||||
{isMac ? "⌘" : "Ctrl+"}
|
||||
{num}
|
||||
</span>
|
||||
</Show>
|
||||
<>
|
||||
<Show when={num <= MAX_JUMP_INDEX}>
|
||||
<span class="am-shortcut-badge">
|
||||
{isMac ? "⌘" : "Ctrl+"}
|
||||
{num}
|
||||
</span>
|
||||
</Show>
|
||||
<Show
|
||||
when={
|
||||
stats() &&
|
||||
(stats()!.additions > 0 || stats()!.deletions > 0 || stats()!.commits > 0)
|
||||
}
|
||||
>
|
||||
<div class="am-worktree-stats">
|
||||
<Show when={stats()!.additions > 0 || stats()!.deletions > 0}>
|
||||
<span class="am-worktree-diff-stats">
|
||||
<Show when={stats()!.additions > 0}>
|
||||
<span class="am-stat-additions">+{stats()!.additions}</span>
|
||||
</Show>
|
||||
<Show when={stats()!.deletions > 0}>
|
||||
<span class="am-stat-deletions">−{stats()!.deletions}</span>
|
||||
</Show>
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={stats()!.commits > 0}>
|
||||
<span class="am-worktree-commits">↑{stats()!.commits}</span>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
<Show when={!busyWorktrees().has(wt.id)}>
|
||||
@@ -1434,6 +1471,44 @@ const AgentManagerContent: Component = () => {
|
||||
<span class="am-hover-card-row-label">{t("agentManager.hoverCard.sessions")}</span>
|
||||
<span class="am-hover-card-row-value">{sessions().length}</span>
|
||||
</div>
|
||||
{(() => {
|
||||
const hoverStats = () => worktreeStats()[wt.id]
|
||||
return (
|
||||
<Show
|
||||
when={
|
||||
hoverStats() &&
|
||||
(hoverStats()!.additions > 0 ||
|
||||
hoverStats()!.deletions > 0 ||
|
||||
hoverStats()!.commits > 0)
|
||||
}
|
||||
>
|
||||
<div class="am-hover-card-divider" />
|
||||
<Show when={hoverStats()!.additions > 0 || hoverStats()!.deletions > 0}>
|
||||
<div class="am-hover-card-row">
|
||||
<span class="am-hover-card-row-label">
|
||||
{t("agentManager.hoverCard.changes")}
|
||||
</span>
|
||||
<span class="am-hover-card-row-value am-hover-card-diff-stats">
|
||||
<Show when={hoverStats()!.additions > 0}>
|
||||
<span class="am-stat-additions">+{hoverStats()!.additions}</span>
|
||||
</Show>
|
||||
<Show when={hoverStats()!.deletions > 0}>
|
||||
<span class="am-stat-deletions">−{hoverStats()!.deletions}</span>
|
||||
</Show>
|
||||
</span>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={hoverStats()!.commits > 0}>
|
||||
<div class="am-hover-card-row">
|
||||
<span class="am-hover-card-row-label">
|
||||
{t("agentManager.hoverCard.commits")}
|
||||
</span>
|
||||
<span class="am-hover-card-row-value">{hoverStats()!.commits}</span>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
</HoverCard>
|
||||
</>
|
||||
@@ -1595,20 +1670,36 @@ const AgentManagerContent: Component = () => {
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
<div class="am-tab-actions">
|
||||
<TooltipKeybind
|
||||
title={t("agentManager.diff.toggle")}
|
||||
keybind={kb().toggleDiff ?? ""}
|
||||
placement="bottom"
|
||||
>
|
||||
<IconButton
|
||||
icon="layers"
|
||||
size="small"
|
||||
variant="ghost"
|
||||
label={t("agentManager.diff.toggle")}
|
||||
class={diffOpen() ? "am-tab-diff-btn-active" : ""}
|
||||
onClick={() => setDiffOpen((prev) => !prev)}
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
{(() => {
|
||||
const sel = () => selection()
|
||||
const stats = () =>
|
||||
typeof sel() === "string" && sel() !== LOCAL ? worktreeStats()[sel() as string] : undefined
|
||||
const hasChanges = () => {
|
||||
const s = stats()
|
||||
return s && (s.additions > 0 || s.deletions > 0)
|
||||
}
|
||||
return (
|
||||
<TooltipKeybind
|
||||
title={t("agentManager.diff.toggle")}
|
||||
keybind={kb().toggleDiff ?? ""}
|
||||
placement="bottom"
|
||||
>
|
||||
<button
|
||||
class={`am-diff-toggle-btn ${diffOpen() ? "am-tab-diff-btn-active" : ""} ${hasChanges() ? "am-diff-toggle-has-changes" : ""}`}
|
||||
onClick={() => setDiffOpen((prev) => !prev)}
|
||||
title={t("agentManager.diff.toggle")}
|
||||
>
|
||||
<Icon name="layers" size="small" />
|
||||
<Show when={hasChanges()}>
|
||||
<span class="am-diff-toggle-stats">
|
||||
<span class="am-stat-additions">+{stats()!.additions}</span>
|
||||
<span class="am-stat-deletions">−{stats()!.deletions}</span>
|
||||
</span>
|
||||
</Show>
|
||||
</button>
|
||||
</TooltipKeybind>
|
||||
)
|
||||
})()}
|
||||
<TooltipKeybind
|
||||
title={t("agentManager.tab.terminal")}
|
||||
keybind={kb().showTerminal ?? ""}
|
||||
|
||||
@@ -296,6 +296,60 @@ button.am-section-toggle:hover .am-section-label {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Per-worktree git stats (diff lines + commits missing from origin) */
|
||||
|
||||
.am-worktree-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.am-worktree-diff-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-inset-base);
|
||||
}
|
||||
|
||||
.am-worktree-item-active .am-worktree-diff-stats {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.am-stat-additions {
|
||||
color: #34d399;
|
||||
}
|
||||
|
||||
.am-stat-deletions {
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.am-worktree-item-active .am-stat-additions {
|
||||
color: #6ee7b7;
|
||||
}
|
||||
|
||||
.am-worktree-item-active .am-stat-deletions {
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.am-worktree-commits {
|
||||
color: #34d399;
|
||||
}
|
||||
|
||||
.am-worktree-item-active .am-worktree-commits {
|
||||
color: #6ee7b7;
|
||||
}
|
||||
|
||||
.am-worktree-item:hover .am-worktree-stats {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Grouped worktrees — visual grouping with header and left accent */
|
||||
|
||||
.am-wt-group-header {
|
||||
@@ -665,6 +719,51 @@ button.am-section-toggle:hover .am-section-label {
|
||||
|
||||
/* Diff Panel */
|
||||
|
||||
.am-diff-toggle-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-weak);
|
||||
cursor: pointer;
|
||||
padding: 4px 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 10px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.am-diff-toggle-btn:hover {
|
||||
background: var(--surface-inset-base-hover);
|
||||
color: var(--text-base);
|
||||
}
|
||||
|
||||
.am-diff-toggle-btn.am-tab-diff-btn-active {
|
||||
background: var(--surface-interactive-base) !important;
|
||||
color: var(--text-base) !important;
|
||||
}
|
||||
|
||||
.am-diff-toggle-btn.am-diff-toggle-has-changes {
|
||||
color: var(--text-base);
|
||||
}
|
||||
|
||||
.am-diff-toggle-stats {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.am-diff-toggle-btn.am-tab-diff-btn-active .am-stat-additions {
|
||||
color: #6ee7b7;
|
||||
}
|
||||
|
||||
.am-diff-toggle-btn.am-tab-diff-btn-active .am-stat-deletions {
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.am-tab-diff-btn-active {
|
||||
background: var(--surface-interactive-base) !important;
|
||||
color: var(--text-base) !important;
|
||||
@@ -1720,6 +1819,14 @@ button.am-section-toggle:hover .am-section-label {
|
||||
color: var(--text-base);
|
||||
}
|
||||
|
||||
.am-hover-card-diff-stats {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Keyboard shortcuts dialog */
|
||||
|
||||
.am-shortcuts {
|
||||
|
||||
@@ -953,6 +953,8 @@ export const dict = {
|
||||
"agentManager.hoverCard.branch": "الفرع",
|
||||
"agentManager.hoverCard.base": "الأساس",
|
||||
"agentManager.hoverCard.sessions": "الجلسات",
|
||||
"agentManager.hoverCard.changes": "التغييرات",
|
||||
"agentManager.hoverCard.commits": "عمليات الالتزام",
|
||||
"agentManager.session.new": "جلسة جديدة",
|
||||
"agentManager.session.untitled": "بدون عنوان",
|
||||
"agentManager.session.newSession": "جلسة جديدة",
|
||||
|
||||
@@ -967,6 +967,8 @@ export const dict = {
|
||||
"agentManager.hoverCard.branch": "BRANCH",
|
||||
"agentManager.hoverCard.base": "Base",
|
||||
"agentManager.hoverCard.sessions": "Sessões",
|
||||
"agentManager.hoverCard.changes": "Alterações",
|
||||
"agentManager.hoverCard.commits": "Commits",
|
||||
"agentManager.session.new": "Nova sessão",
|
||||
"agentManager.session.untitled": "Sem título",
|
||||
"agentManager.session.newSession": "Nova Sessão",
|
||||
|
||||
@@ -989,6 +989,8 @@ export const dict = {
|
||||
"agentManager.hoverCard.branch": "BRANCH",
|
||||
"agentManager.hoverCard.base": "Baza",
|
||||
"agentManager.hoverCard.sessions": "Sesije",
|
||||
"agentManager.hoverCard.changes": "Promjene",
|
||||
"agentManager.hoverCard.commits": "Commiti",
|
||||
"agentManager.session.new": "Nova sesija",
|
||||
"agentManager.session.untitled": "Bez naslova",
|
||||
"agentManager.session.newSession": "Nova sesija",
|
||||
|
||||
@@ -962,6 +962,8 @@ export const dict = {
|
||||
"agentManager.hoverCard.branch": "BRANCH",
|
||||
"agentManager.hoverCard.base": "Base",
|
||||
"agentManager.hoverCard.sessions": "Sessioner",
|
||||
"agentManager.hoverCard.changes": "Ændringer",
|
||||
"agentManager.hoverCard.commits": "Commits",
|
||||
"agentManager.session.new": "Ny session",
|
||||
"agentManager.session.untitled": "Uden titel",
|
||||
"agentManager.session.newSession": "Ny session",
|
||||
|
||||
@@ -975,6 +975,8 @@ export const dict = {
|
||||
"agentManager.hoverCard.branch": "BRANCH",
|
||||
"agentManager.hoverCard.base": "Basis",
|
||||
"agentManager.hoverCard.sessions": "Sitzungen",
|
||||
"agentManager.hoverCard.changes": "Änderungen",
|
||||
"agentManager.hoverCard.commits": "Commits",
|
||||
"agentManager.session.new": "Neue Sitzung",
|
||||
"agentManager.session.untitled": "Unbenannt",
|
||||
"agentManager.session.newSession": "Neue Sitzung",
|
||||
|
||||
@@ -1011,6 +1011,8 @@ export const dict = {
|
||||
"agentManager.hoverCard.branch": "BRANCH",
|
||||
"agentManager.hoverCard.base": "Base",
|
||||
"agentManager.hoverCard.sessions": "Sessions",
|
||||
"agentManager.hoverCard.changes": "Changes",
|
||||
"agentManager.hoverCard.commits": "Commits",
|
||||
|
||||
"agentManager.session.new": "New session",
|
||||
"agentManager.session.untitled": "Untitled",
|
||||
|
||||
@@ -969,6 +969,8 @@ export const dict = {
|
||||
"agentManager.hoverCard.branch": "BRANCH",
|
||||
"agentManager.hoverCard.base": "Base",
|
||||
"agentManager.hoverCard.sessions": "Sesiones",
|
||||
"agentManager.hoverCard.changes": "Cambios",
|
||||
"agentManager.hoverCard.commits": "Commits",
|
||||
"agentManager.session.new": "Nueva sesión",
|
||||
"agentManager.session.untitled": "Sin título",
|
||||
"agentManager.session.newSession": "Nueva Sesión",
|
||||
|
||||
@@ -977,6 +977,8 @@ export const dict = {
|
||||
"agentManager.hoverCard.branch": "BRANCHE",
|
||||
"agentManager.hoverCard.base": "Base",
|
||||
"agentManager.hoverCard.sessions": "Sessions",
|
||||
"agentManager.hoverCard.changes": "Modifications",
|
||||
"agentManager.hoverCard.commits": "Commits",
|
||||
"agentManager.session.new": "Nouvelle session",
|
||||
"agentManager.session.untitled": "Sans titre",
|
||||
"agentManager.session.newSession": "Nouvelle session",
|
||||
|
||||
@@ -957,6 +957,8 @@ export const dict = {
|
||||
"agentManager.hoverCard.branch": "ブランチ",
|
||||
"agentManager.hoverCard.base": "ベース",
|
||||
"agentManager.hoverCard.sessions": "セッション",
|
||||
"agentManager.hoverCard.changes": "変更",
|
||||
"agentManager.hoverCard.commits": "コミット",
|
||||
"agentManager.session.new": "新しいセッション",
|
||||
"agentManager.session.untitled": "無題",
|
||||
"agentManager.session.newSession": "新しいセッション",
|
||||
|
||||
@@ -958,6 +958,8 @@ export const dict = {
|
||||
"agentManager.hoverCard.branch": "브랜치",
|
||||
"agentManager.hoverCard.base": "베이스",
|
||||
"agentManager.hoverCard.sessions": "세션",
|
||||
"agentManager.hoverCard.changes": "변경 사항",
|
||||
"agentManager.hoverCard.commits": "커밋",
|
||||
"agentManager.session.new": "새 세션",
|
||||
"agentManager.session.untitled": "제목 없음",
|
||||
"agentManager.session.newSession": "새 세션",
|
||||
|
||||
@@ -963,6 +963,8 @@ export const dict = {
|
||||
"agentManager.hoverCard.branch": "BRANCH",
|
||||
"agentManager.hoverCard.base": "Base",
|
||||
"agentManager.hoverCard.sessions": "Økter",
|
||||
"agentManager.hoverCard.changes": "Endringer",
|
||||
"agentManager.hoverCard.commits": "Commits",
|
||||
"agentManager.session.new": "Ny økt",
|
||||
"agentManager.session.untitled": "Uten tittel",
|
||||
"agentManager.session.newSession": "Ny økt",
|
||||
|
||||
@@ -964,6 +964,8 @@ export const dict = {
|
||||
"agentManager.hoverCard.branch": "BRANCH",
|
||||
"agentManager.hoverCard.base": "Baza",
|
||||
"agentManager.hoverCard.sessions": "Sesje",
|
||||
"agentManager.hoverCard.changes": "Zmiany",
|
||||
"agentManager.hoverCard.commits": "Commity",
|
||||
"agentManager.session.new": "Nowa sesja",
|
||||
"agentManager.session.untitled": "Bez tytułu",
|
||||
"agentManager.session.newSession": "Nowa sesja",
|
||||
|
||||
@@ -966,6 +966,8 @@ export const dict = {
|
||||
"agentManager.hoverCard.branch": "ВЕТКА",
|
||||
"agentManager.hoverCard.base": "Основа",
|
||||
"agentManager.hoverCard.sessions": "Сессии",
|
||||
"agentManager.hoverCard.changes": "Изменения",
|
||||
"agentManager.hoverCard.commits": "Коммиты",
|
||||
"agentManager.session.new": "Новая сессия",
|
||||
"agentManager.session.untitled": "Без названия",
|
||||
"agentManager.session.newSession": "Новая сессия",
|
||||
|
||||
@@ -950,6 +950,8 @@ export const dict = {
|
||||
"agentManager.hoverCard.branch": "BRANCH",
|
||||
"agentManager.hoverCard.base": "ฐาน",
|
||||
"agentManager.hoverCard.sessions": "เซสชัน",
|
||||
"agentManager.hoverCard.changes": "การเปลี่ยนแปลง",
|
||||
"agentManager.hoverCard.commits": "คอมมิต",
|
||||
"agentManager.session.new": "เซสชันใหม่",
|
||||
"agentManager.session.untitled": "ไม่มีชื่อ",
|
||||
"agentManager.session.newSession": "เซสชันใหม่",
|
||||
|
||||
@@ -953,6 +953,8 @@ export const dict = {
|
||||
"agentManager.hoverCard.branch": "分支",
|
||||
"agentManager.hoverCard.base": "基础",
|
||||
"agentManager.hoverCard.sessions": "会话",
|
||||
"agentManager.hoverCard.changes": "更改",
|
||||
"agentManager.hoverCard.commits": "提交",
|
||||
"agentManager.session.new": "新建会话",
|
||||
"agentManager.session.untitled": "无标题",
|
||||
"agentManager.session.newSession": "新建会话",
|
||||
|
||||
@@ -949,6 +949,8 @@ export const dict = {
|
||||
"agentManager.hoverCard.branch": "分支",
|
||||
"agentManager.hoverCard.base": "基底",
|
||||
"agentManager.hoverCard.sessions": "工作階段",
|
||||
"agentManager.hoverCard.changes": "變更",
|
||||
"agentManager.hoverCard.commits": "提交",
|
||||
"agentManager.session.new": "新建工作階段",
|
||||
"agentManager.session.untitled": "未命名",
|
||||
"agentManager.session.newSession": "新建工作階段",
|
||||
|
||||
@@ -732,6 +732,20 @@ export interface AgentManagerWorktreeDiffLoadingMessage {
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
// Per-worktree git stats: diff additions/deletions and commits missing from origin
|
||||
export interface WorktreeGitStats {
|
||||
worktreeId: string
|
||||
additions: number
|
||||
deletions: number
|
||||
commits: number
|
||||
}
|
||||
|
||||
// Agent Manager: Worktree git stats push (extension → webview)
|
||||
export interface AgentManagerWorktreeStatsMessage {
|
||||
type: "agentManager.worktreeStats"
|
||||
stats: WorktreeGitStats[]
|
||||
}
|
||||
|
||||
// Request webview to send initial prompt to a newly created session (extension → webview)
|
||||
export interface AgentManagerSendInitialMessage {
|
||||
type: "agentManager.sendInitialMessage"
|
||||
@@ -801,6 +815,7 @@ export type ExtensionMessage =
|
||||
| WorkspaceDirectoryChangedMessage
|
||||
| AgentManagerWorktreeDiffMessage
|
||||
| AgentManagerWorktreeDiffLoadingMessage
|
||||
| AgentManagerWorktreeStatsMessage
|
||||
|
||||
// ============================================
|
||||
// Messages FROM webview TO extension
|
||||
|
||||
Reference in New Issue
Block a user