Merge branch 'main' into fix/memory-leak

This commit is contained in:
Marian Alexandru Alecu
2026-02-26 13:54:10 +02:00
committed by GitHub
7 changed files with 141 additions and 45 deletions
@@ -1,10 +1,12 @@
import * as vscode from "vscode"
import * as cp from "child_process"
import type { KiloConnectionService, SessionInfo, HttpClient } from "../services/cli-backend"
import { KiloProvider } from "../KiloProvider"
import { buildWebviewHtml } from "../utils"
import { WorktreeManager, type CreateWorktreeResult } from "./WorktreeManager"
import { WorktreeStateManager } from "./WorktreeStateManager"
import { versionedName } from "./branch-name"
import { normalizePath } from "./git-import"
import { SetupScriptService } from "./SetupScriptService"
import { SetupScriptRunner } from "./SetupScriptRunner"
import { SessionTerminalManager } from "./SessionTerminalManager"
@@ -21,6 +23,7 @@ import { MAX_MULTI_VERSIONS } from "./constants"
* SESSIONS (bottom) with unassociated workspace sessions.
*/
const PLATFORM = "agent-manager" as const
const LOCAL_DIFF_ID = "local" as const
export class AgentManagerProvider implements vscode.Disposable {
public static readonly viewType = "kilo-code.new.AgentManagerPanel"
@@ -37,6 +40,7 @@ export class AgentManagerProvider implements vscode.Disposable {
private diffInterval: ReturnType<typeof setInterval> | undefined
private diffSessionId: string | undefined
private lastDiffHash: string | undefined
private cachedDiffTarget: { directory: string; baseBranch: string } | undefined
constructor(
private readonly extensionUri: vscode.Uri,
@@ -975,7 +979,7 @@ export class AgentManagerProvider implements vscode.Disposable {
let worktree: ReturnType<typeof state.addWorktree> | undefined
try {
const externals = await manager.listExternalWorktrees(new Set(state.getWorktrees().map((wt) => wt.path)))
if (!externals.some((e) => e.path === wtPath)) {
if (!externals.some((e) => normalizePath(e.path) === normalizePath(wtPath))) {
this.postToWebview({
type: "agentManager.importResult",
success: false,
@@ -1272,7 +1276,8 @@ export class AgentManagerProvider implements vscode.Disposable {
// ---------------------------------------------------------------------------
/** Resolve worktree path + parentBranch for a session, or undefined if not applicable. */
private resolveDiffTarget(sessionId: string): { directory: string; baseBranch: string } | undefined {
private async resolveDiffTarget(sessionId: string): Promise<{ directory: string; baseBranch: string } | undefined> {
if (sessionId === LOCAL_DIFF_ID) return await this.resolveLocalDiffTarget()
const state = this.getStateManager()
if (!state) {
this.log(`resolveDiffTarget: no state manager for session ${sessionId}`)
@@ -1297,7 +1302,45 @@ export class AgentManagerProvider implements vscode.Disposable {
return { directory: worktree.path, baseBranch: worktree.parentBranch }
}
/** One-shot diff fetch with loading indicators. Used by requestWorktreeDiff. */
/** Resolve diff target for the local workspace — diffs against the remote tracking branch. */
private async resolveLocalDiffTarget(): Promise<{ directory: string; baseBranch: string } | undefined> {
const root = this.getWorkspaceRoot()
if (!root) return undefined
const tracking = await this.getRemoteTrackingBranch(root)
if (!tracking) {
this.log("Local diff: no remote tracking branch found")
return undefined
}
return { directory: root, baseBranch: tracking }
}
/** Detect the remote tracking branch for the current branch in the given directory. */
private async getRemoteTrackingBranch(cwd: string): Promise<string | undefined> {
// Try configured upstream tracking branch first (e.g. origin/feature-x)
const upstream = await this.git(cwd, ["rev-parse", "--abbrev-ref", "@{upstream}"])
if (upstream) return upstream
// No upstream configured — construct origin/<current-branch>
const branch = await this.git(cwd, ["rev-parse", "--abbrev-ref", "HEAD"])
if (!branch || branch === "HEAD") return undefined
const ref = `origin/${branch}`
const resolved = await this.git(cwd, ["rev-parse", "--verify", ref])
if (resolved) return ref
return undefined
}
/** Run a git command asynchronously and return trimmed stdout, or undefined on failure. */
private git(cwd: string, args: string[]): Promise<string | undefined> {
return new Promise((resolve) => {
cp.execFile("git", args, { cwd, encoding: "utf-8", timeout: 5000 }, (err, stdout) => {
if (err) resolve(undefined)
else resolve(stdout.trim() || undefined)
})
})
}
/** One-shot diff fetch with loading indicators. Resolves target async, then fetches. */
private async onRequestWorktreeDiff(sessionId: string): Promise<void> {
// Ensure state is loaded before resolving diff target — avoids race where
// startDiffWatch arrives before initializeState() finishes loading state from disk.
@@ -1308,9 +1351,12 @@ export class AgentManagerProvider implements vscode.Disposable {
await this.stateReady.catch((err) => this.log("stateReady rejected, continuing diff resolve:", err))
}
const target = this.resolveDiffTarget(sessionId)
const target = await this.resolveDiffTarget(sessionId)
if (!target) return
// Cache the resolved target so subsequent polls skip resolution entirely
this.cachedDiffTarget = target
this.postToWebview({ type: "agentManager.worktreeDiffLoading", sessionId, loading: true })
try {
const client = this.connectionService.getHttpClient()
@@ -1330,9 +1376,9 @@ export class AgentManagerProvider implements vscode.Disposable {
}
}
/** Polling diff fetch — no loading state, only pushes when hash changes. */
/** Polling diff fetch — uses cached target, no loading state, only pushes when hash changes. */
private async pollDiff(sessionId: string): Promise<void> {
const target = this.resolveDiffTarget(sessionId)
const target = this.cachedDiffTarget
if (!target) return
try {
@@ -1356,13 +1402,14 @@ export class AgentManagerProvider implements vscode.Disposable {
this.lastDiffHash = undefined
this.log(`Starting diff polling for session ${sessionId}`)
// Initial fetch with loading state
void this.onRequestWorktreeDiff(sessionId)
// Subsequent polls without loading state
this.diffInterval = setInterval(() => {
void this.pollDiff(sessionId)
}, 2500)
// Initial fetch resolves + caches the diff target, then starts interval polling
void this.onRequestWorktreeDiff(sessionId).then(() => {
// Only start interval if still watching the same session (may have been stopped)
if (this.diffSessionId !== sessionId) return
this.diffInterval = setInterval(() => {
void this.pollDiff(sessionId)
}, 2500)
})
}
private stopDiffPolling(): void {
@@ -1372,6 +1419,7 @@ export class AgentManagerProvider implements vscode.Disposable {
}
this.diffSessionId = undefined
this.lastDiffHash = undefined
this.cachedDiffTarget = undefined
}
private postToWebview(message: Record<string, unknown>): void {
@@ -20,6 +20,7 @@ import {
checkedOutBranchesFromWorktreeList,
classifyPRError,
validateGitRef,
normalizePath,
type PRInfo,
type BranchListItem,
} from "./git-import"
@@ -170,7 +171,7 @@ export class WorktreeManager {
// Git doesn't know about this directory — remove it directly
if (fs.existsSync(worktreePath)) {
if (!worktreePath.startsWith(this.dir)) {
if (!this.isManagedPath(worktreePath)) {
this.log(`Refusing to remove path outside worktrees directory: ${worktreePath}`)
return
}
@@ -256,6 +257,20 @@ export class WorktreeManager {
}
}
/**
* Returns true when target is strictly inside the managed worktrees directory.
* Prevents sibling-prefix confusion such as "/worktrees-evil".
*/
private isManagedPath(target: string): boolean {
const root = path.resolve(this.dir)
const child = path.resolve(target)
const rel = normalizePath(path.relative(root, child))
if (!rel || rel === ".") return false
if (rel.startsWith("../")) return false
if (path.isAbsolute(rel)) return false
return true
}
private async addExcludeEntry(excludePath: string, entry: string, comment: string): Promise<void> {
const infoDir = path.dirname(excludePath)
if (!fs.existsSync(infoDir)) await fs.promises.mkdir(infoDir, { recursive: true })
@@ -390,8 +405,12 @@ export class WorktreeManager {
async listExternalWorktrees(managedPaths: Set<string>): Promise<ExternalWorktreeItem[]> {
try {
const raw = await this.git.raw(["worktree", "list", "--porcelain"])
const normalizedRoot = normalizePath(this.root)
const normalizedManaged = new Set([...managedPaths].map(normalizePath))
return parseWorktreeList(raw)
.filter((e) => !e.bare && e.path !== this.root && !managedPaths.has(e.path))
.filter(
(e) => !e.bare && normalizePath(e.path) !== normalizedRoot && !normalizedManaged.has(normalizePath(e.path)),
)
.map((e) => ({ path: e.path, branch: e.branch }))
} catch (error) {
this.log(`Failed to list external worktrees: ${error}`)
@@ -11,6 +11,7 @@
import * as path from "path"
import * as fs from "fs"
import { normalizePath } from "./git-import"
export interface Worktree {
id: string
@@ -75,8 +76,9 @@ export class WorktreeStateManager {
/** Find worktree by its filesystem path. */
findWorktreeByPath(wtPath: string): Worktree | undefined {
const target = normalizePath(wtPath)
for (const wt of this.worktrees.values()) {
if (wt.path === wtPath) return wt
if (normalizePath(wt.path) === target) return wt
}
return undefined
}
@@ -132,6 +132,17 @@ export function validateGitRef(value: string, label: string): void {
}
}
/**
* Normalize a filesystem path for cross-platform comparison.
* Converts backslashes to forward slashes, strips trailing slashes,
* and lowercases Windows drive-letter paths (case-insensitive filesystem).
*/
export function normalizePath(p: string): string {
const normalized = p.replace(/\\/g, "/").replace(/\/+$/, "")
if (/^[A-Za-z]:/.test(normalized)) return normalized.toLowerCase()
return normalized
}
export function classifyPRError(msg: string): PRErrorKind {
if (msg.includes("command not found") || msg.includes("ENOENT") || msg.includes("is not recognized"))
return "gh_missing"
@@ -826,11 +826,19 @@ const AgentManagerContent: Component = () => {
// Start/stop diff watch when panel opens/closes or session changes
createEffect(() => {
const open = diffOpen()
const sel = selection()
const id = session.currentSessionID()
if (open && id) {
const ms = managedSessions().find((s) => s.id === id)
if (ms?.worktreeId) {
vscode.postMessage({ type: "agentManager.startDiffWatch", sessionId: id })
if (open) {
if (sel === LOCAL) {
// For local tab, diff against unpushed changes using LOCAL sentinel
vscode.postMessage({ type: "agentManager.startDiffWatch", sessionId: LOCAL })
} else if (id) {
const ms = managedSessions().find((s) => s.id === id)
if (ms?.worktreeId) {
vscode.postMessage({ type: "agentManager.startDiffWatch", sessionId: id })
} else {
vscode.postMessage({ type: "agentManager.stopDiffWatch" })
}
} else {
vscode.postMessage({ type: "agentManager.stopDiffWatch" })
}
@@ -1532,22 +1540,20 @@ const AgentManagerContent: Component = () => {
/>
</TooltipKeybind>
<div class="am-tab-actions">
<Show when={selection() !== LOCAL}>
<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>
</Show>
<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>
<TooltipKeybind
title={t("agentManager.tab.terminal")}
keybind={kb().showTerminal ?? ""}
@@ -1662,17 +1668,17 @@ const AgentManagerContent: Component = () => {
</Show>
</div>
<Show when={diffOpen()}>
<ResizeHandle
direction="horizontal"
edge="end"
size={diffWidth()}
min={200}
max={Math.round(window.innerWidth * 0.8)}
onResize={(w) => setDiffWidth(Math.max(200, Math.min(w, window.innerWidth * 0.8)))}
/>
<div class="am-diff-panel-wrapper" style={{ width: `${diffWidth()}px`, "flex-shrink": "0" }}>
<ResizeHandle
direction="horizontal"
edge="start"
size={diffWidth()}
min={200}
max={Math.round(window.innerWidth * 0.8)}
onResize={(w) => setDiffWidth(Math.max(200, Math.min(w, window.innerWidth * 0.8)))}
/>
<DiffPanel
diffs={diffDatas()[session.currentSessionID() ?? ""] ?? []}
diffs={diffDatas()[selection() === LOCAL ? LOCAL : (session.currentSessionID() ?? "")] ?? []}
loading={diffLoading()}
onClose={() => setDiffOpen(false)}
/>
@@ -643,12 +643,17 @@ button.am-section-toggle:hover .am-section-label {
}
.am-diff-panel-wrapper {
position: relative;
min-width: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.am-diff-panel-wrapper > [data-component="resize-handle"]::after {
background: var(--surface-interactive-base);
}
.am-diff-panel {
display: flex;
flex-direction: column;
@@ -32,8 +32,12 @@ export function ResizeHandle(props: ResizeHandleProps) {
const startSize = local.size
let current = startSize
// kilocode_change start - set resize cursor on body during drag
const cursor = local.direction === "horizontal" ? "col-resize" : "row-resize"
// kilocode_change end
document.body.style.userSelect = "none"
document.body.style.overflow = "hidden"
document.body.style.cursor = cursor // kilocode_change
const onMouseMove = (moveEvent: MouseEvent) => {
const pos = local.direction === "horizontal" ? moveEvent.clientX : moveEvent.clientY
@@ -53,6 +57,7 @@ export function ResizeHandle(props: ResizeHandleProps) {
const onMouseUp = () => {
document.body.style.userSelect = ""
document.body.style.overflow = ""
document.body.style.cursor = "" // kilocode_change
document.removeEventListener("mousemove", onMouseMove)
document.removeEventListener("mouseup", onMouseUp)