mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-19 10:02:04 +08:00
Merge branch 'main' into fix/memory-leak
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user