Merge branch 'dev' into feat/suggestions-after-plan-end
This commit is contained in:
@@ -228,6 +228,7 @@
|
||||
"@kilocode/kilo-ui": "workspace:*",
|
||||
"@kilocode/sdk": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@thisbeyond/solid-dnd": "0.7.5",
|
||||
"diff": "^7.0.0",
|
||||
"dotenv": "^16.4.7",
|
||||
"eventsource": "^2.0.2",
|
||||
|
||||
@@ -535,6 +535,7 @@
|
||||
"@kilocode/kilo-ui": "workspace:*",
|
||||
"@kilocode/sdk": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@thisbeyond/solid-dnd": "0.7.5",
|
||||
"diff": "^7.0.0",
|
||||
"dotenv": "^16.4.7",
|
||||
"eventsource": "^2.0.2",
|
||||
|
||||
@@ -4,6 +4,8 @@ import { KiloProvider } from "../KiloProvider"
|
||||
import { buildWebviewHtml } from "../utils"
|
||||
import { WorktreeManager, type CreateWorktreeResult } from "./WorktreeManager"
|
||||
import { WorktreeStateManager } from "./WorktreeStateManager"
|
||||
import { SetupScriptService } from "./SetupScriptService"
|
||||
import { SetupScriptRunner } from "./SetupScriptRunner"
|
||||
import { SessionTerminalManager } from "./SessionTerminalManager"
|
||||
|
||||
/**
|
||||
@@ -22,6 +24,7 @@ export class AgentManagerProvider implements vscode.Disposable {
|
||||
private outputChannel: vscode.OutputChannel
|
||||
private worktrees: WorktreeManager | undefined
|
||||
private state: WorktreeStateManager | undefined
|
||||
private setupScript: SetupScriptService | undefined
|
||||
private terminalManager: SessionTerminalManager
|
||||
|
||||
constructor(
|
||||
@@ -129,6 +132,10 @@ export class AgentManagerProvider implements vscode.Disposable {
|
||||
return this.onAddSessionToWorktree(msg.worktreeId)
|
||||
if (type === "agentManager.closeSession" && typeof msg.sessionId === "string")
|
||||
return this.onCloseSession(msg.sessionId)
|
||||
if (type === "agentManager.configureSetupScript") {
|
||||
void this.configureSetupScript()
|
||||
return null
|
||||
}
|
||||
if (type === "agentManager.showTerminal" && typeof msg.sessionId === "string") {
|
||||
this.terminalManager.showTerminal(msg.sessionId, this.state)
|
||||
return null
|
||||
@@ -137,6 +144,10 @@ export class AgentManagerProvider implements vscode.Disposable {
|
||||
void this.sendRepoInfo()
|
||||
return null
|
||||
}
|
||||
if (type === "agentManager.setTabOrder" && typeof msg.key === "string" && Array.isArray(msg.order)) {
|
||||
this.state?.setTabOrder(msg.key as string, msg.order as string[])
|
||||
return null
|
||||
}
|
||||
|
||||
// When switching sessions, show existing terminal if one is open
|
||||
if (type === "loadMessages" && typeof msg.sessionID === "string") {
|
||||
@@ -258,6 +269,9 @@ export class AgentManagerProvider implements vscode.Disposable {
|
||||
const created = await this.createWorktreeOnDisk()
|
||||
if (!created) return null
|
||||
|
||||
// Run setup script for new worktree (blocks until complete, shows in overlay)
|
||||
await this.runSetupScriptForWorktree(created.result.path, created.result.branch)
|
||||
|
||||
const session = await this.createSessionInWorktree(created.result.path, created.result.branch)
|
||||
if (!session) {
|
||||
const state = this.getStateManager()
|
||||
@@ -307,6 +321,9 @@ export class AgentManagerProvider implements vscode.Disposable {
|
||||
const created = await this.createWorktreeOnDisk()
|
||||
if (!created) return null
|
||||
|
||||
// Run setup script for new worktree (blocks until complete, shows in overlay)
|
||||
await this.runSetupScriptForWorktree(created.result.path, created.result.branch)
|
||||
|
||||
const state = this.getStateManager()!
|
||||
if (!state.getSession(sessionId)) {
|
||||
state.addSession(sessionId, created.worktree.id)
|
||||
@@ -376,6 +393,42 @@ export class AgentManagerProvider implements vscode.Disposable {
|
||||
return null
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Setup script
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Open the worktree setup script in the editor for user configuration. */
|
||||
private async configureSetupScript(): Promise<void> {
|
||||
const service = this.getSetupScriptService()
|
||||
if (!service) return
|
||||
try {
|
||||
await service.openInEditor()
|
||||
} catch (error) {
|
||||
this.log(`Failed to open setup script: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Run the worktree setup script if configured. Blocks until complete. Shows progress in overlay. */
|
||||
private async runSetupScriptForWorktree(worktreePath: string, branch?: string): Promise<void> {
|
||||
const root = this.getWorkspaceRoot()
|
||||
if (!root) return
|
||||
try {
|
||||
const service = this.getSetupScriptService()
|
||||
if (!service || !service.hasScript()) return
|
||||
this.postToWebview({
|
||||
type: "agentManager.worktreeSetup",
|
||||
status: "creating",
|
||||
message: "Running setup script...",
|
||||
branch,
|
||||
})
|
||||
const runner = new SetupScriptRunner(this.outputChannel, service)
|
||||
await runner.runIfConfigured({ worktreePath, repoPath: root })
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error)
|
||||
this.outputChannel.appendLine(`[AgentManager] Setup script error: ${msg}`)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Repo info
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -408,6 +461,7 @@ export class AgentManagerProvider implements vscode.Disposable {
|
||||
type: "agentManager.state",
|
||||
worktrees: state.getWorktrees(),
|
||||
sessions: state.getSessions(),
|
||||
tabOrder: state.getTabOrder(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -443,6 +497,17 @@ export class AgentManagerProvider implements vscode.Disposable {
|
||||
return this.state
|
||||
}
|
||||
|
||||
private getSetupScriptService(): SetupScriptService | undefined {
|
||||
if (this.setupScript) return this.setupScript
|
||||
const root = this.getWorkspaceRoot()
|
||||
if (!root) {
|
||||
this.log("getSetupScriptService: no workspace folder available")
|
||||
return undefined
|
||||
}
|
||||
this.setupScript = new SetupScriptService(root)
|
||||
return this.setupScript
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* SetupScriptRunner - Executes worktree setup scripts
|
||||
*
|
||||
* Runs setup scripts in VS Code integrated terminal before agent starts.
|
||||
* Uses VS Code shell integration to track execution and exit code.
|
||||
* Falls back to sendText + onDidCloseTerminal if shell integration is unavailable.
|
||||
* Cross-platform: Unix uses sh, Windows uses cmd.exe.
|
||||
*/
|
||||
|
||||
import * as vscode from "vscode"
|
||||
import { SetupScriptService } from "./SetupScriptService"
|
||||
import { buildSetupCommand } from "./setup-script-command"
|
||||
|
||||
export interface SetupScriptEnvironment {
|
||||
/** Absolute path to the worktree directory */
|
||||
worktreePath: string
|
||||
/** Absolute path to the main repository */
|
||||
repoPath: string
|
||||
}
|
||||
|
||||
export class SetupScriptRunner {
|
||||
constructor(
|
||||
private readonly output: vscode.OutputChannel,
|
||||
private readonly service: SetupScriptService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Execute setup script in a worktree if script exists.
|
||||
* Waits for the script to finish before resolving.
|
||||
*
|
||||
* @returns true if script was executed, false if skipped (no script configured)
|
||||
*/
|
||||
async runIfConfigured(env: SetupScriptEnvironment): Promise<boolean> {
|
||||
if (!this.service.hasScript()) {
|
||||
this.log("No setup script configured, skipping")
|
||||
return false
|
||||
}
|
||||
|
||||
const script = this.service.getScriptPath()
|
||||
this.log(`Running setup script: ${script}`)
|
||||
|
||||
try {
|
||||
await this.executeInTerminal(script, env)
|
||||
this.log("Setup script completed")
|
||||
return true
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error)
|
||||
this.log(`Setup script execution failed: ${msg}`)
|
||||
return true // Script was attempted
|
||||
}
|
||||
}
|
||||
|
||||
/** Execute the setup script in a VS Code terminal and wait for it to finish. */
|
||||
private async executeInTerminal(script: string, env: SetupScriptEnvironment): Promise<void> {
|
||||
const terminal = vscode.window.createTerminal({
|
||||
name: "Worktree Setup",
|
||||
cwd: env.worktreePath,
|
||||
env: {
|
||||
WORKTREE_PATH: env.worktreePath,
|
||||
REPO_PATH: env.repoPath,
|
||||
},
|
||||
iconPath: new vscode.ThemeIcon("gear"),
|
||||
})
|
||||
|
||||
terminal.show(true)
|
||||
|
||||
// Try shell integration first — gives us proper exit code tracking
|
||||
const integration = await this.waitForShellIntegration(terminal, 5000)
|
||||
if (integration) {
|
||||
this.log("Using shell integration for setup script execution")
|
||||
await this.runViaShellIntegration(terminal, integration, script, env)
|
||||
} else {
|
||||
this.log("Shell integration unavailable, falling back to sendText")
|
||||
await this.runViaSendText(terminal, script, env)
|
||||
}
|
||||
}
|
||||
|
||||
/** Wait for shell integration to become available on a terminal, with timeout. */
|
||||
private waitForShellIntegration(
|
||||
terminal: vscode.Terminal,
|
||||
timeout: number,
|
||||
): Promise<vscode.TerminalShellIntegration | undefined> {
|
||||
if (terminal.shellIntegration) return Promise.resolve(terminal.shellIntegration)
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
listener.dispose()
|
||||
resolve(undefined)
|
||||
}, timeout)
|
||||
|
||||
const listener = vscode.window.onDidChangeTerminalShellIntegration((e) => {
|
||||
if (e.terminal !== terminal) return
|
||||
clearTimeout(timer)
|
||||
listener.dispose()
|
||||
resolve(e.shellIntegration)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/** Run script via shell integration — tracks execution and exit code properly. */
|
||||
private runViaShellIntegration(
|
||||
terminal: vscode.Terminal,
|
||||
integration: vscode.TerminalShellIntegration,
|
||||
script: string,
|
||||
env: SetupScriptEnvironment,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const command = buildSetupCommand(script, env)
|
||||
const execution = integration.executeCommand(command)
|
||||
|
||||
const cleanup = () => {
|
||||
execListener.dispose()
|
||||
closeListener.dispose()
|
||||
}
|
||||
|
||||
// Primary: shell integration reports execution finished with exit code
|
||||
const execListener = vscode.window.onDidEndTerminalShellExecution((e) => {
|
||||
if (e.execution !== execution) return
|
||||
cleanup()
|
||||
this.log(`Setup script exited with code ${e.exitCode ?? "unknown"}`)
|
||||
resolve()
|
||||
})
|
||||
|
||||
// Fallback: terminal was closed externally (user, VS Code restart, etc.)
|
||||
const closeListener = vscode.window.onDidCloseTerminal((closed) => {
|
||||
if (closed !== terminal) return
|
||||
cleanup()
|
||||
this.log("Setup script terminal closed before execution event fired")
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/** Fallback: run via sendText and wait for terminal to close. */
|
||||
private runViaSendText(terminal: vscode.Terminal, script: string, env: SetupScriptEnvironment): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const listener = vscode.window.onDidCloseTerminal((closed) => {
|
||||
if (closed !== terminal) return
|
||||
listener.dispose()
|
||||
resolve()
|
||||
})
|
||||
|
||||
const command = buildSetupCommand(script, env) + (process.platform === "win32" ? "& exit" : "; exit")
|
||||
terminal.sendText(command)
|
||||
this.log("Setup script started in terminal, waiting for completion...")
|
||||
})
|
||||
}
|
||||
|
||||
private log(message: string): void {
|
||||
this.output.appendLine(`[SetupScriptRunner] ${message}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* SetupScriptService - Manages worktree setup scripts
|
||||
*
|
||||
* Handles reading, creating, and checking for setup scripts stored in .kilocode/setup-script.
|
||||
* Setup scripts run before an agent starts in a worktree (new sessions only).
|
||||
*/
|
||||
|
||||
import * as vscode from "vscode"
|
||||
import * as fs from "node:fs"
|
||||
import * as path from "node:path"
|
||||
import { SETUP_SCRIPT_TEMPLATE } from "./setup-script-template"
|
||||
|
||||
const SETUP_SCRIPT_FILENAME = "setup-script"
|
||||
const KILOCODE_DIR = ".kilocode"
|
||||
|
||||
export class SetupScriptService {
|
||||
private readonly root: string
|
||||
private readonly script: string
|
||||
|
||||
constructor(root: string) {
|
||||
this.root = root
|
||||
this.script = path.join(root, KILOCODE_DIR, SETUP_SCRIPT_FILENAME)
|
||||
}
|
||||
|
||||
/** Get the path to the setup script */
|
||||
getScriptPath(): string {
|
||||
return this.script
|
||||
}
|
||||
|
||||
/** Check if a setup script exists */
|
||||
hasScript(): boolean {
|
||||
return fs.existsSync(this.script)
|
||||
}
|
||||
|
||||
/** Read the setup script content. Returns null if not found or read fails. */
|
||||
async getScript(): Promise<string | null> {
|
||||
if (!this.hasScript()) return null
|
||||
try {
|
||||
return await fs.promises.readFile(this.script, "utf-8")
|
||||
} catch (error) {
|
||||
this.log(`Failed to read setup script: ${error}`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Create a default setup script with helpful comments */
|
||||
async createDefaultScript(): Promise<void> {
|
||||
const dir = path.join(this.root, KILOCODE_DIR)
|
||||
if (!fs.existsSync(dir)) {
|
||||
await fs.promises.mkdir(dir, { recursive: true })
|
||||
}
|
||||
await fs.promises.writeFile(this.script, SETUP_SCRIPT_TEMPLATE, "utf-8")
|
||||
}
|
||||
|
||||
/** Open the setup script in VS Code editor. Creates the default script if it doesn't exist. */
|
||||
async openInEditor(): Promise<void> {
|
||||
if (!this.hasScript()) {
|
||||
await this.createDefaultScript()
|
||||
}
|
||||
const document = await vscode.workspace.openTextDocument(this.script)
|
||||
await vscode.window.showTextDocument(document)
|
||||
}
|
||||
|
||||
private log(message: string): void {
|
||||
// Log to console since we don't have an OutputChannel here
|
||||
console.log(`[SetupScriptService] ${message}`)
|
||||
}
|
||||
}
|
||||
@@ -185,6 +185,7 @@ export class WorktreeManager {
|
||||
const excludePath = path.join(gitDir, "info", "exclude")
|
||||
await this.addExcludeEntry(excludePath, ".kilocode/worktrees/", "Kilo Code agent worktrees")
|
||||
await this.addExcludeEntry(excludePath, ".kilocode/agent-manager.json", "Kilo Agent Manager state")
|
||||
await this.addExcludeEntry(excludePath, ".kilocode/setup-script", "Kilo Code worktree setup script")
|
||||
}
|
||||
|
||||
private async ensureWorktreeExclude(worktreePath: string): Promise<void> {
|
||||
|
||||
@@ -29,6 +29,7 @@ export interface ManagedSession {
|
||||
interface StateFile {
|
||||
worktrees: Record<string, Omit<Worktree, "id">>
|
||||
sessions: Record<string, Omit<ManagedSession, "id">>
|
||||
tabOrder?: Record<string, string[]>
|
||||
}
|
||||
|
||||
const STATE_FILE = "agent-manager.json"
|
||||
@@ -44,6 +45,7 @@ export class WorktreeStateManager {
|
||||
private readonly file: string
|
||||
private worktrees = new Map<string, Worktree>()
|
||||
private sessions = new Map<string, ManagedSession>()
|
||||
private tabOrder: Record<string, string[]> = {}
|
||||
private readonly log: (msg: string) => void
|
||||
private saving: Promise<void> | undefined
|
||||
private pendingSave = false
|
||||
@@ -125,6 +127,9 @@ export class WorktreeStateManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up tab order for this worktree
|
||||
delete this.tabOrder[id]
|
||||
|
||||
this.log(`Removed worktree ${id}, orphaned ${orphaned.length} sessions`)
|
||||
void this.save()
|
||||
return orphaned
|
||||
@@ -149,6 +154,34 @@ export class WorktreeStateManager {
|
||||
|
||||
removeSession(id: string): void {
|
||||
this.sessions.delete(id)
|
||||
|
||||
// Remove this session from any tab order arrays
|
||||
for (const [key, order] of Object.entries(this.tabOrder)) {
|
||||
const idx = order.indexOf(id)
|
||||
if (idx !== -1) {
|
||||
order.splice(idx, 1)
|
||||
if (order.length === 0) delete this.tabOrder[key]
|
||||
}
|
||||
}
|
||||
|
||||
void this.save()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tab order
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
getTabOrder(): Record<string, string[]> {
|
||||
return this.tabOrder
|
||||
}
|
||||
|
||||
setTabOrder(key: string, order: string[]): void {
|
||||
this.tabOrder[key] = order
|
||||
void this.save()
|
||||
}
|
||||
|
||||
removeTabOrder(key: string): void {
|
||||
delete this.tabOrder[key]
|
||||
void this.save()
|
||||
}
|
||||
|
||||
@@ -162,6 +195,7 @@ export class WorktreeStateManager {
|
||||
const data = JSON.parse(content) as StateFile
|
||||
this.worktrees.clear()
|
||||
this.sessions.clear()
|
||||
this.tabOrder = {}
|
||||
|
||||
for (const [id, wt] of Object.entries(data.worktrees ?? {})) {
|
||||
this.worktrees.set(id, { id, ...wt })
|
||||
@@ -169,6 +203,9 @@ export class WorktreeStateManager {
|
||||
for (const [id, s] of Object.entries(data.sessions ?? {})) {
|
||||
this.sessions.set(id, { id, ...s })
|
||||
}
|
||||
if (data.tabOrder) {
|
||||
this.tabOrder = data.tabOrder
|
||||
}
|
||||
this.log(`Loaded state: ${this.worktrees.size} worktrees, ${this.sessions.size} sessions`)
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code
|
||||
@@ -229,6 +266,9 @@ export class WorktreeStateManager {
|
||||
const { id: _, ...rest } = s
|
||||
data.sessions[id] = rest
|
||||
}
|
||||
if (Object.keys(this.tabOrder).length > 0) {
|
||||
data.tabOrder = this.tabOrder
|
||||
}
|
||||
|
||||
const dir = path.dirname(this.file)
|
||||
if (!fs.existsSync(dir)) await fs.promises.mkdir(dir, { recursive: true })
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, it, expect } from "vitest"
|
||||
import { buildSetupCommand } from "../setup-script-command"
|
||||
|
||||
const env = {
|
||||
worktreePath: "/repos/project/.kilocode/worktrees/wt-1",
|
||||
repoPath: "/repos/project",
|
||||
}
|
||||
|
||||
const script = "/repos/project/.kilocode/setup-script"
|
||||
|
||||
describe("buildSetupCommand", () => {
|
||||
it("builds unix command with inline env vars and sh", () => {
|
||||
const result = buildSetupCommand(script, env, "darwin")
|
||||
expect(result).toBe(
|
||||
`WORKTREE_PATH="/repos/project/.kilocode/worktrees/wt-1" REPO_PATH="/repos/project" sh "/repos/project/.kilocode/setup-script"`,
|
||||
)
|
||||
})
|
||||
|
||||
it("builds linux command same as darwin", () => {
|
||||
const result = buildSetupCommand(script, env, "linux")
|
||||
expect(result).toContain("sh ")
|
||||
expect(result).not.toContain("set ")
|
||||
expect(result).not.toContain("call ")
|
||||
})
|
||||
|
||||
it("builds windows command with set and call", () => {
|
||||
const result = buildSetupCommand(script, env, "win32")
|
||||
expect(result).toBe(
|
||||
`set "WORKTREE_PATH=/repos/project/.kilocode/worktrees/wt-1" && set "REPO_PATH=/repos/project" && call "/repos/project/.kilocode/setup-script"`,
|
||||
)
|
||||
})
|
||||
|
||||
it("includes both env vars in unix command", () => {
|
||||
const result = buildSetupCommand(script, env, "darwin")
|
||||
expect(result).toContain(`WORKTREE_PATH="${env.worktreePath}"`)
|
||||
expect(result).toContain(`REPO_PATH="${env.repoPath}"`)
|
||||
})
|
||||
|
||||
it("includes both env vars in windows command", () => {
|
||||
const result = buildSetupCommand(script, env, "win32")
|
||||
expect(result).toContain(`set "WORKTREE_PATH=${env.worktreePath}"`)
|
||||
expect(result).toContain(`set "REPO_PATH=${env.repoPath}"`)
|
||||
})
|
||||
|
||||
it("handles paths with spaces", () => {
|
||||
const spaced = {
|
||||
worktreePath: "/Users/dev/my project/.kilocode/worktrees/wt-1",
|
||||
repoPath: "/Users/dev/my project",
|
||||
}
|
||||
const spacedScript = "/Users/dev/my project/.kilocode/setup-script"
|
||||
|
||||
const unix = buildSetupCommand(spacedScript, spaced, "darwin")
|
||||
expect(unix).toContain(`sh "/Users/dev/my project/.kilocode/setup-script"`)
|
||||
|
||||
const win = buildSetupCommand(spacedScript, spaced, "win32")
|
||||
expect(win).toContain(`call "/Users/dev/my project/.kilocode/setup-script"`)
|
||||
})
|
||||
|
||||
it("escapes double quotes in unix paths", () => {
|
||||
const dangerous = {
|
||||
worktreePath: '/repos/proj"ect',
|
||||
repoPath: "/repos/safe",
|
||||
}
|
||||
const result = buildSetupCommand(script, dangerous, "darwin")
|
||||
expect(result).toContain(`WORKTREE_PATH="/repos/proj\\"ect"`)
|
||||
})
|
||||
|
||||
it("escapes dollar signs in unix paths", () => {
|
||||
const dangerous = {
|
||||
worktreePath: "/repos/$HOME/project",
|
||||
repoPath: "/repos/safe",
|
||||
}
|
||||
const result = buildSetupCommand(script, dangerous, "darwin")
|
||||
expect(result).toContain(`WORKTREE_PATH="/repos/\\$HOME/project"`)
|
||||
})
|
||||
|
||||
it("escapes backticks in unix paths", () => {
|
||||
const dangerous = {
|
||||
worktreePath: "/repos/`whoami`/project",
|
||||
repoPath: "/repos/safe",
|
||||
}
|
||||
const result = buildSetupCommand(script, dangerous, "darwin")
|
||||
expect(result).toContain('WORKTREE_PATH="/repos/\\`whoami\\`/project"')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
/** Escape characters that are special inside double-quoted shell strings. */
|
||||
function escapeShell(value: string): string {
|
||||
return value.replace(/["$`\\]/g, "\\$&")
|
||||
}
|
||||
|
||||
/** Build the platform-appropriate command string for running a setup script. */
|
||||
export function buildSetupCommand(
|
||||
script: string,
|
||||
env: { worktreePath: string; repoPath: string },
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): string {
|
||||
if (platform === "win32") {
|
||||
// Windows cmd.exe: double quotes in set values don't need escaping the same way,
|
||||
// but we escape for the call argument
|
||||
return `set "WORKTREE_PATH=${env.worktreePath}" && set "REPO_PATH=${env.repoPath}" && call "${script}"`
|
||||
}
|
||||
const wt = escapeShell(env.worktreePath)
|
||||
const repo = escapeShell(env.repoPath)
|
||||
const path = escapeShell(script)
|
||||
return `WORKTREE_PATH="${wt}" REPO_PATH="${repo}" sh "${path}"`
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/** Default template for worktree setup scripts. */
|
||||
export const SETUP_SCRIPT_TEMPLATE = `#!/bin/bash
|
||||
# Kilo Code Worktree Setup Script
|
||||
# This script runs before the agent starts in a worktree (new sessions only).
|
||||
#
|
||||
# Available environment variables:
|
||||
# WORKTREE_PATH - Absolute path to the worktree directory
|
||||
# REPO_PATH - Absolute path to the main repository
|
||||
#
|
||||
# Example tasks:
|
||||
# - Copy .env files from main repo
|
||||
# - Install dependencies
|
||||
# - Run database migrations
|
||||
# - Set up local configuration
|
||||
|
||||
set -e # Exit on error
|
||||
|
||||
echo "Setting up worktree: $WORKTREE_PATH"
|
||||
|
||||
# Uncomment and modify as needed:
|
||||
|
||||
# Copy environment files
|
||||
# if [ -f "$REPO_PATH/.env" ]; then
|
||||
# cp "$REPO_PATH/.env" "$WORKTREE_PATH/.env"
|
||||
# echo "Copied .env"
|
||||
# fi
|
||||
|
||||
# Install dependencies (Node.js)
|
||||
# if [ -f "$WORKTREE_PATH/package.json" ]; then
|
||||
# cd "$WORKTREE_PATH"
|
||||
# npm install
|
||||
# fi
|
||||
|
||||
# Install dependencies (Python)
|
||||
# if [ -f "$WORKTREE_PATH/requirements.txt" ]; then
|
||||
# cd "$WORKTREE_PATH"
|
||||
# pip install -r requirements.txt
|
||||
# fi
|
||||
|
||||
echo "Setup complete!"
|
||||
`
|
||||
@@ -14,9 +14,17 @@ import { Project, SyntaxKind } from "ts-morph"
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, "../..")
|
||||
const CSS_FILE = path.join(ROOT, "webview-ui/agent-manager/agent-manager.css")
|
||||
const TSX_FILE = path.join(ROOT, "webview-ui/agent-manager/AgentManagerApp.tsx")
|
||||
const TSX_FILES = [
|
||||
path.join(ROOT, "webview-ui/agent-manager/AgentManagerApp.tsx"),
|
||||
path.join(ROOT, "webview-ui/agent-manager/sortable-tab.tsx"),
|
||||
]
|
||||
const TSX_FILE = TSX_FILES[0]
|
||||
const PROVIDER_FILE = path.join(ROOT, "src/agent-manager/AgentManagerProvider.ts")
|
||||
|
||||
function readAllTsx(): string {
|
||||
return TSX_FILES.map((f) => fs.readFileSync(f, "utf-8")).join("\n")
|
||||
}
|
||||
|
||||
describe("Agent Manager CSS Prefix", () => {
|
||||
it("all class selectors should use am- prefix", () => {
|
||||
const css = fs.readFileSync(CSS_FILE, "utf-8")
|
||||
@@ -54,7 +62,7 @@ describe("Agent Manager CSS Prefix", () => {
|
||||
describe("Agent Manager CSS/TSX Consistency", () => {
|
||||
it("all classes used in TSX should be defined in CSS", () => {
|
||||
const css = fs.readFileSync(CSS_FILE, "utf-8")
|
||||
const tsx = fs.readFileSync(TSX_FILE, "utf-8")
|
||||
const tsx = readAllTsx()
|
||||
|
||||
// Extract am- classes defined in CSS
|
||||
const cssMatches = [...css.matchAll(/\.([a-z][a-z0-9-]*)/gi)]
|
||||
@@ -71,7 +79,7 @@ describe("Agent Manager CSS/TSX Consistency", () => {
|
||||
|
||||
it("all am- classes defined in CSS should be used in TSX", () => {
|
||||
const css = fs.readFileSync(CSS_FILE, "utf-8")
|
||||
const tsx = fs.readFileSync(TSX_FILE, "utf-8")
|
||||
const tsx = readAllTsx()
|
||||
|
||||
// Extract am- classes defined in CSS
|
||||
const cssMatches = [...css.matchAll(/\.([a-z][a-z0-9-]*)/gi)]
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import { describe, it, expect } from "bun:test"
|
||||
import { reorderTabs, applyTabOrder, firstOrderedTitle } from "../../webview-ui/agent-manager/tab-order"
|
||||
|
||||
describe("reorderTabs", () => {
|
||||
const tabs = ["a", "b", "c", "d"]
|
||||
|
||||
it("moves an item forward", () => {
|
||||
expect(reorderTabs(tabs, "a", "c")).toEqual(["b", "c", "a", "d"])
|
||||
})
|
||||
|
||||
it("moves an item backward", () => {
|
||||
expect(reorderTabs(tabs, "c", "a")).toEqual(["c", "a", "b", "d"])
|
||||
})
|
||||
|
||||
it("swaps adjacent items forward", () => {
|
||||
expect(reorderTabs(tabs, "a", "b")).toEqual(["b", "a", "c", "d"])
|
||||
})
|
||||
|
||||
it("swaps adjacent items backward", () => {
|
||||
expect(reorderTabs(tabs, "b", "a")).toEqual(["b", "a", "c", "d"])
|
||||
})
|
||||
|
||||
it("moves first to last", () => {
|
||||
expect(reorderTabs(tabs, "a", "d")).toEqual(["b", "c", "d", "a"])
|
||||
})
|
||||
|
||||
it("moves last to first", () => {
|
||||
expect(reorderTabs(tabs, "d", "a")).toEqual(["d", "a", "b", "c"])
|
||||
})
|
||||
|
||||
it("returns undefined when from equals to", () => {
|
||||
expect(reorderTabs(tabs, "a", "a")).toBeUndefined()
|
||||
})
|
||||
|
||||
it("returns undefined when from is not found", () => {
|
||||
expect(reorderTabs(tabs, "x", "a")).toBeUndefined()
|
||||
})
|
||||
|
||||
it("returns undefined when to is not found", () => {
|
||||
expect(reorderTabs(tabs, "a", "x")).toBeUndefined()
|
||||
})
|
||||
|
||||
it("returns undefined when both are missing", () => {
|
||||
expect(reorderTabs(tabs, "x", "y")).toBeUndefined()
|
||||
})
|
||||
|
||||
it("handles a two-item list", () => {
|
||||
expect(reorderTabs(["a", "b"], "a", "b")).toEqual(["b", "a"])
|
||||
expect(reorderTabs(["a", "b"], "b", "a")).toEqual(["b", "a"])
|
||||
})
|
||||
|
||||
it("handles a single-item list (from === to)", () => {
|
||||
expect(reorderTabs(["a"], "a", "a")).toBeUndefined()
|
||||
})
|
||||
|
||||
it("handles empty list", () => {
|
||||
expect(reorderTabs([], "a", "b")).toBeUndefined()
|
||||
})
|
||||
|
||||
it("does not mutate the original array", () => {
|
||||
const original = ["a", "b", "c"]
|
||||
reorderTabs(original, "a", "c")
|
||||
expect(original).toEqual(["a", "b", "c"])
|
||||
})
|
||||
|
||||
it("preserves unrelated items", () => {
|
||||
const result = reorderTabs(["a", "b", "c", "d", "e"], "b", "d")!
|
||||
expect(result).toEqual(["a", "c", "d", "b", "e"])
|
||||
expect(result.sort()).toEqual(["a", "b", "c", "d", "e"])
|
||||
})
|
||||
|
||||
it("round-trip: moving forward then back restores original order", () => {
|
||||
const moved = reorderTabs(tabs, "a", "c")!
|
||||
const restored = reorderTabs(moved, "a", "b")!
|
||||
expect(restored).toEqual(["a", "b", "c", "d"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("applyTabOrder", () => {
|
||||
const items = [
|
||||
{ id: "a", name: "Alice" },
|
||||
{ id: "b", name: "Bob" },
|
||||
{ id: "c", name: "Carol" },
|
||||
]
|
||||
|
||||
it("reorders items according to custom order", () => {
|
||||
const result = applyTabOrder(items, ["c", "a", "b"])
|
||||
expect(result.map((i) => i.id)).toEqual(["c", "a", "b"])
|
||||
})
|
||||
|
||||
it("appends items not in the order", () => {
|
||||
const result = applyTabOrder(items, ["b"])
|
||||
expect(result.map((i) => i.id)).toEqual(["b", "a", "c"])
|
||||
})
|
||||
|
||||
it("skips order IDs that are not in items", () => {
|
||||
const result = applyTabOrder(items, ["x", "c", "y", "a"])
|
||||
expect(result.map((i) => i.id)).toEqual(["c", "a", "b"])
|
||||
})
|
||||
|
||||
it("returns original array when order is undefined", () => {
|
||||
const result = applyTabOrder(items, undefined)
|
||||
expect(result).toBe(items)
|
||||
})
|
||||
|
||||
it("returns original array when order is empty", () => {
|
||||
const result = applyTabOrder(items, [])
|
||||
expect(result).toBe(items)
|
||||
})
|
||||
|
||||
it("handles empty items", () => {
|
||||
expect(applyTabOrder([], ["a", "b"])).toEqual([])
|
||||
})
|
||||
|
||||
it("preserves item properties", () => {
|
||||
const result = applyTabOrder(items, ["b", "a", "c"])
|
||||
expect(result[0]).toEqual({ id: "b", name: "Bob" })
|
||||
})
|
||||
})
|
||||
|
||||
describe("firstOrderedTitle", () => {
|
||||
const items = [{ id: "a", title: "Alpha" }, { id: "b", title: "Beta" }, { id: "c", title: "" }, { id: "d" }]
|
||||
|
||||
it("returns first titled item from custom order", () => {
|
||||
expect(firstOrderedTitle(items, ["b", "a"], "fallback")).toBe("Beta")
|
||||
})
|
||||
|
||||
it("skips items without titles in order", () => {
|
||||
expect(firstOrderedTitle(items, ["d", "c", "b"], "fallback")).toBe("Beta")
|
||||
})
|
||||
|
||||
it("falls back to first titled item when order has no matches", () => {
|
||||
expect(firstOrderedTitle(items, ["x", "y"], "fallback")).toBe("Alpha")
|
||||
})
|
||||
|
||||
it("falls back to first titled item when order is undefined", () => {
|
||||
expect(firstOrderedTitle(items, undefined, "fallback")).toBe("Alpha")
|
||||
})
|
||||
|
||||
it("returns fallback when no items have titles", () => {
|
||||
expect(firstOrderedTitle([{ id: "a" }, { id: "b", title: "" }], ["a", "b"], "fallback")).toBe("fallback")
|
||||
})
|
||||
|
||||
it("returns fallback for empty items", () => {
|
||||
expect(firstOrderedTitle([], ["a"], "fallback")).toBe("fallback")
|
||||
})
|
||||
})
|
||||
|
||||
// Helper: simulate reconciliation the same way handleDragOver does
|
||||
function reconcile(current: string[], stored: string[]): string[] {
|
||||
return applyTabOrder(
|
||||
current.map((id) => ({ id })),
|
||||
stored,
|
||||
).map((item) => item.id)
|
||||
}
|
||||
|
||||
describe("applyTabOrder as reconciliation (string IDs)", () => {
|
||||
it("returns stored order unchanged when it matches current IDs", () => {
|
||||
expect(reconcile(["a", "b", "c"], ["a", "b", "c"])).toEqual(["a", "b", "c"])
|
||||
})
|
||||
|
||||
it("appends new IDs not in stored order", () => {
|
||||
expect(reconcile(["a", "b", "c"], ["a", "b"])).toEqual(["a", "b", "c"])
|
||||
})
|
||||
|
||||
it("removes stale IDs no longer in current", () => {
|
||||
expect(reconcile(["a", "c"], ["a", "b", "c"])).toEqual(["a", "c"])
|
||||
})
|
||||
|
||||
it("preserves custom ordering while adding new tabs", () => {
|
||||
expect(reconcile(["a", "b", "c"], ["b", "a"])).toEqual(["b", "a", "c"])
|
||||
})
|
||||
|
||||
it("returns current IDs when stored order is undefined", () => {
|
||||
expect(applyTabOrder([{ id: "a" }, { id: "b" }], undefined).map((i) => i.id)).toEqual(["a", "b"])
|
||||
})
|
||||
|
||||
describe("regression: reorder a newly added tab immediately", () => {
|
||||
it("new tab should be reorderable after reconcile via applyTabOrder", () => {
|
||||
// Stored order from a previous drag: [s2, s1]
|
||||
// A third session s3 was just added to the worktree
|
||||
const stored = ["s2", "s1"]
|
||||
const current = ["s2", "s1", "s3"]
|
||||
|
||||
const reconciled = reconcile(current, stored)
|
||||
expect(reconciled).toEqual(["s2", "s1", "s3"])
|
||||
|
||||
// Now the user drags s3 to position of s2 — this must succeed
|
||||
const reordered = reorderTabs(reconciled, "s3", "s2")
|
||||
expect(reordered).toEqual(["s3", "s2", "s1"])
|
||||
expect(reordered).not.toBeUndefined()
|
||||
})
|
||||
|
||||
it("without reconcile, reorderTabs fails on the new tab", () => {
|
||||
const stored = ["s2", "s1"]
|
||||
const reordered = reorderTabs(stored, "s3", "s2")
|
||||
expect(reordered).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -174,6 +174,81 @@ describe("WorktreeStateManager", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("tab order", () => {
|
||||
it("sets and gets tab order for a key", () => {
|
||||
manager.setTabOrder("wt-1", ["s1", "s2", "s3"])
|
||||
expect(manager.getTabOrder()["wt-1"]).toEqual(["s1", "s2", "s3"])
|
||||
})
|
||||
|
||||
it("overwrites existing tab order", () => {
|
||||
manager.setTabOrder("wt-1", ["s1", "s2"])
|
||||
manager.setTabOrder("wt-1", ["s2", "s1"])
|
||||
expect(manager.getTabOrder()["wt-1"]).toEqual(["s2", "s1"])
|
||||
})
|
||||
|
||||
it("removes tab order for a key", () => {
|
||||
manager.setTabOrder("wt-1", ["s1"])
|
||||
manager.removeTabOrder("wt-1")
|
||||
expect(manager.getTabOrder()["wt-1"]).toBeUndefined()
|
||||
})
|
||||
|
||||
it("removeTabOrder is a no-op for missing key", () => {
|
||||
manager.removeTabOrder("nonexistent")
|
||||
expect(Object.keys(manager.getTabOrder())).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("cleans up tab order when worktree is removed", () => {
|
||||
const wt = manager.addWorktree({ branch: "fix", path: "/tmp/fix", parentBranch: "main" })
|
||||
manager.addSession("s1", wt.id)
|
||||
manager.setTabOrder(wt.id, ["s1"])
|
||||
|
||||
manager.removeWorktree(wt.id)
|
||||
expect(manager.getTabOrder()[wt.id]).toBeUndefined()
|
||||
})
|
||||
|
||||
it("removes session from tab order arrays when session is removed", () => {
|
||||
const wt = manager.addWorktree({ branch: "fix", path: "/tmp/fix", parentBranch: "main" })
|
||||
manager.addSession("s1", wt.id)
|
||||
manager.addSession("s2", wt.id)
|
||||
manager.setTabOrder(wt.id, ["s1", "s2"])
|
||||
|
||||
manager.removeSession("s1")
|
||||
expect(manager.getTabOrder()[wt.id]).toEqual(["s2"])
|
||||
})
|
||||
|
||||
it("removes tab order entry when last session in order is removed", () => {
|
||||
manager.addSession("s1", null)
|
||||
manager.setTabOrder("local", ["s1"])
|
||||
|
||||
manager.removeSession("s1")
|
||||
expect(manager.getTabOrder()["local"]).toBeUndefined()
|
||||
})
|
||||
|
||||
it("persists and loads tab order", async () => {
|
||||
const wt = manager.addWorktree({ branch: "fix", path: "/tmp/fix", parentBranch: "main" })
|
||||
manager.setTabOrder(wt.id, ["s2", "s1"])
|
||||
manager.setTabOrder("local", ["s3", "s4"])
|
||||
await manager.flush()
|
||||
await manager.save()
|
||||
|
||||
const loaded = new WorktreeStateManager(root, () => {})
|
||||
await loaded.load()
|
||||
|
||||
expect(loaded.getTabOrder()[wt.id]).toEqual(["s2", "s1"])
|
||||
expect(loaded.getTabOrder()["local"]).toEqual(["s3", "s4"])
|
||||
})
|
||||
|
||||
it("does not persist empty tab order", async () => {
|
||||
manager.addWorktree({ branch: "fix", path: "/tmp/fix", parentBranch: "main" })
|
||||
await manager.flush()
|
||||
await manager.save()
|
||||
|
||||
const content = fs.readFileSync(path.join(root, ".kilocode", "agent-manager.json"), "utf-8")
|
||||
const data = JSON.parse(content)
|
||||
expect(data.tabOrder).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("validate", () => {
|
||||
it("removes worktrees whose directories do not exist", async () => {
|
||||
const existing = path.join(root, "wt-exists")
|
||||
|
||||
@@ -20,6 +20,8 @@ import type {
|
||||
ManagedSessionState,
|
||||
SessionInfo,
|
||||
} from "../src/types/messages"
|
||||
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
|
||||
import type { DragEvent } from "@thisbeyond/solid-dnd"
|
||||
import { ThemeProvider } from "@kilocode/kilo-ui/theme"
|
||||
import { DialogProvider, useDialog } from "@kilocode/kilo-ui/context/dialog"
|
||||
import { Dialog } from "@kilocode/kilo-ui/dialog"
|
||||
@@ -29,11 +31,13 @@ import { DiffComponentProvider } from "@kilocode/kilo-ui/context/diff"
|
||||
import { Code } from "@kilocode/kilo-ui/code"
|
||||
import { Diff } from "@kilocode/kilo-ui/diff"
|
||||
import { Toast } from "@kilocode/kilo-ui/toast"
|
||||
import { ResizeHandle } from "@kilocode/kilo-ui/resize-handle"
|
||||
import { Icon } from "@kilocode/kilo-ui/icon"
|
||||
import { Button } from "@kilocode/kilo-ui/button"
|
||||
import { IconButton } from "@kilocode/kilo-ui/icon-button"
|
||||
import { Spinner } from "@kilocode/kilo-ui/spinner"
|
||||
import { Tooltip } from "@kilocode/kilo-ui/tooltip"
|
||||
import { DropdownMenu } from "@kilocode/kilo-ui/dropdown-menu"
|
||||
import { VSCodeProvider, useVSCode } from "../src/context/vscode"
|
||||
import { ServerProvider } from "../src/context/server"
|
||||
import { ProviderProvider } from "../src/context/provider"
|
||||
@@ -44,6 +48,8 @@ import { ChatView } from "../src/components/chat"
|
||||
import { LanguageBridge, DataBridge } from "../src/App"
|
||||
import { formatRelativeDate } from "../src/utils/date"
|
||||
import { validateLocalSession, nextSelectionAfterDelete, LOCAL } from "./navigate"
|
||||
import { reorderTabs, applyTabOrder, firstOrderedTitle } from "./tab-order"
|
||||
import { ConstrainDragYAxis, SortableTab } from "./sortable-tab"
|
||||
import "./agent-manager.css"
|
||||
|
||||
interface SetupState {
|
||||
@@ -136,9 +142,14 @@ const AgentManagerContent: Component = () => {
|
||||
const [repoBranch, setRepoBranch] = createSignal<string | undefined>()
|
||||
const [deletingWorktrees, setDeletingWorktrees] = createSignal<Set<string>>(new Set())
|
||||
|
||||
const DEFAULT_SIDEBAR_WIDTH = 260
|
||||
const MIN_SIDEBAR_WIDTH = 200
|
||||
const MAX_SIDEBAR_WIDTH_RATIO = 0.4
|
||||
|
||||
// Recover persisted local session IDs from webview state
|
||||
const persisted = vscode.getState<{ localSessionIDs?: string[] }>()
|
||||
const persisted = vscode.getState<{ localSessionIDs?: string[]; sidebarWidth?: number }>()
|
||||
const [localSessionIDs, setLocalSessionIDs] = createSignal<string[]>(persisted?.localSessionIDs ?? [])
|
||||
const [sidebarWidth, setSidebarWidth] = createSignal(persisted?.sidebarWidth ?? DEFAULT_SIDEBAR_WIDTH)
|
||||
|
||||
// Pending local tab counter for generating unique IDs
|
||||
let pendingCounter = 0
|
||||
@@ -150,6 +161,11 @@ const AgentManagerContent: Component = () => {
|
||||
|
||||
const isPending = (id: string) => id.startsWith(PENDING_PREFIX)
|
||||
|
||||
// Drag-and-drop state for tab reordering
|
||||
const [draggingTab, setDraggingTab] = createSignal<string | undefined>()
|
||||
// Tab ordering: context key → ordered session ID array (recovered from extension state)
|
||||
const [worktreeTabOrder, setWorktreeTabOrder] = createSignal<Record<string, string[]>>({})
|
||||
|
||||
const addPendingTab = () => {
|
||||
const id = `${PENDING_PREFIX}${++pendingCounter}`
|
||||
setLocalSessionIDs((prev) => [...prev, id])
|
||||
@@ -158,9 +174,12 @@ const AgentManagerContent: Component = () => {
|
||||
return id
|
||||
}
|
||||
|
||||
// Persist local session IDs to webview state for recovery (exclude pending tabs)
|
||||
// Persist local session IDs and sidebar width to webview state for recovery (exclude pending tabs)
|
||||
createEffect(() => {
|
||||
vscode.setState({ localSessionIDs: localSessionIDs().filter((id) => !isPending(id)) })
|
||||
vscode.setState({
|
||||
localSessionIDs: localSessionIDs().filter((id) => !isPending(id)),
|
||||
sidebarWidth: sidebarWidth(),
|
||||
})
|
||||
})
|
||||
|
||||
// Save the currently active tab for the current sidebar context before switching away
|
||||
@@ -221,16 +240,17 @@ const AgentManagerContent: Component = () => {
|
||||
return result
|
||||
})
|
||||
|
||||
// Sessions for the currently selected worktree (tab bar), sorted by creation date
|
||||
// Sessions for the currently selected worktree (tab bar), respecting custom order if set
|
||||
const activeWorktreeSessions = createMemo((): SessionInfo[] => {
|
||||
const sel = selection()
|
||||
if (!sel || sel === LOCAL) return []
|
||||
const managed = managedSessions().filter((ms) => ms.worktreeId === sel)
|
||||
const ids = new Set(managed.map((ms) => ms.id))
|
||||
return session
|
||||
const sessions = session
|
||||
.sessions()
|
||||
.filter((s) => ids.has(s.id))
|
||||
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
|
||||
return applyTabOrder(sessions, worktreeTabOrder()[sel])
|
||||
})
|
||||
|
||||
// Active tab sessions: local sessions when on "local", worktree sessions otherwise
|
||||
@@ -256,12 +276,12 @@ const AgentManagerContent: Component = () => {
|
||||
const visibleTabId = createMemo(() => session.currentSessionID() ?? activePendingId())
|
||||
const tabScroll = useTabScroll(activeTabs, visibleTabId)
|
||||
|
||||
// Display name for worktree
|
||||
// Display name for worktree — uses first tab in custom order when available
|
||||
const worktreeLabel = (wt: WorktreeState): string => {
|
||||
const managed = managedSessions().filter((ms) => ms.worktreeId === wt.id)
|
||||
const ids = new Set(managed.map((ms) => ms.id))
|
||||
const first = session.sessions().find((s) => ids.has(s.id))
|
||||
return first?.title || wt.branch
|
||||
const sessions = session.sessions().filter((s) => ids.has(s.id))
|
||||
return firstOrderedTitle(sessions, worktreeTabOrder()[wt.id], wt.branch)
|
||||
}
|
||||
|
||||
const scrollIntoView = (el: HTMLElement) => {
|
||||
@@ -439,11 +459,21 @@ const AgentManagerContent: Component = () => {
|
||||
const state = msg as AgentManagerStateMessage
|
||||
setWorktrees(state.worktrees)
|
||||
setManagedSessions(state.sessions)
|
||||
if (state.tabOrder) setWorktreeTabOrder(state.tabOrder)
|
||||
const current = session.currentSessionID()
|
||||
if (current) {
|
||||
const ms = state.sessions.find((s) => s.id === current)
|
||||
if (ms?.worktreeId) setSelection(ms.worktreeId)
|
||||
}
|
||||
// Recover local tab order from persisted state
|
||||
const localOrder = state.tabOrder?.[LOCAL]
|
||||
if (localOrder && localSessionIDs().length > 0) {
|
||||
const reordered = applyTabOrder(
|
||||
localSessionIDs().map((id) => ({ id })),
|
||||
localOrder,
|
||||
).map((item) => item.id)
|
||||
setLocalSessionIDs(reordered)
|
||||
}
|
||||
// Clear deleting state for worktrees that have been removed
|
||||
const ids = new Set(state.worktrees.map((wt) => wt.id))
|
||||
setDeletingWorktrees((prev) => {
|
||||
@@ -471,6 +501,10 @@ const AgentManagerContent: Component = () => {
|
||||
}
|
||||
})
|
||||
|
||||
const handleConfigureSetupScript = () => {
|
||||
vscode.postMessage({ type: "agentManager.configureSetupScript" })
|
||||
}
|
||||
|
||||
const handleCreateWorktree = () => {
|
||||
vscode.postMessage({ type: "agentManager.createWorktree" })
|
||||
}
|
||||
@@ -574,6 +608,53 @@ const AgentManagerContent: Component = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Drag-and-drop handlers for tab reordering
|
||||
const tabIds = createMemo(() => activeTabs().map((s) => s.id))
|
||||
|
||||
const handleDragStart = (event: DragEvent) => {
|
||||
const id = event.draggable?.id
|
||||
if (typeof id === "string") setDraggingTab(id)
|
||||
}
|
||||
|
||||
const handleDragOver = (event: DragEvent) => {
|
||||
const from = event.draggable?.id
|
||||
const to = event.droppable?.id
|
||||
if (typeof from !== "string" || typeof to !== "string") return
|
||||
const sel = selection()
|
||||
if (sel === LOCAL) {
|
||||
setLocalSessionIDs((prev) => reorderTabs(prev, from, to) ?? prev)
|
||||
} else if (sel) {
|
||||
setWorktreeTabOrder((prev) => {
|
||||
const ids = applyTabOrder(
|
||||
tabIds().map((id) => ({ id })),
|
||||
prev[sel],
|
||||
).map((item) => item.id)
|
||||
const reordered = reorderTabs(ids, from, to)
|
||||
if (!reordered) return prev
|
||||
return { ...prev, [sel]: reordered }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragEnd = () => {
|
||||
setDraggingTab(undefined)
|
||||
// Persist the new tab order to the extension
|
||||
const sel = selection()
|
||||
if (sel === LOCAL) {
|
||||
const order = localSessionIDs().filter((id) => !isPending(id))
|
||||
if (order.length > 0) vscode.postMessage({ type: "agentManager.setTabOrder", key: LOCAL, order })
|
||||
} else if (sel) {
|
||||
const order = worktreeTabOrder()[sel]
|
||||
if (order) vscode.postMessage({ type: "agentManager.setTabOrder", key: sel, order })
|
||||
}
|
||||
}
|
||||
|
||||
const draggedTab = createMemo(() => {
|
||||
const id = draggingTab()
|
||||
if (!id) return undefined
|
||||
return activeTabs().find((s) => s.id === id)
|
||||
})
|
||||
|
||||
// Close the currently active tab via keyboard shortcut.
|
||||
// If no tabs remain, fall through to close the selected worktree.
|
||||
const closeActiveTab = () => {
|
||||
@@ -625,7 +706,14 @@ const AgentManagerContent: Component = () => {
|
||||
|
||||
return (
|
||||
<div class="am-layout">
|
||||
<div class="am-sidebar">
|
||||
<div class="am-sidebar" style={{ width: `${sidebarWidth()}px` }}>
|
||||
<ResizeHandle
|
||||
direction="horizontal"
|
||||
size={sidebarWidth()}
|
||||
min={MIN_SIDEBAR_WIDTH}
|
||||
max={9999}
|
||||
onResize={(width) => setSidebarWidth(Math.min(width, window.innerWidth * MAX_SIDEBAR_WIDTH_RATIO))}
|
||||
/>
|
||||
{/* Local workspace item */}
|
||||
<button
|
||||
class={`am-local-item ${selection() === LOCAL ? "am-local-item-active" : ""}`}
|
||||
@@ -649,7 +737,31 @@ const AgentManagerContent: Component = () => {
|
||||
<div class="am-section">
|
||||
<div class="am-section-header">
|
||||
<span class="am-section-label">WORKTREES</span>
|
||||
<IconButton icon="plus" size="small" variant="ghost" label="New Worktree" onClick={handleCreateWorktree} />
|
||||
<div class="am-section-actions">
|
||||
<DropdownMenu>
|
||||
<DropdownMenu.Trigger
|
||||
as={IconButton}
|
||||
icon="settings-gear"
|
||||
size="small"
|
||||
variant="ghost"
|
||||
label="Worktree settings"
|
||||
/>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content>
|
||||
<DropdownMenu.Item onSelect={handleConfigureSetupScript}>
|
||||
<DropdownMenu.ItemLabel>Worktree Setup Script</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu>
|
||||
<IconButton
|
||||
icon="plus"
|
||||
size="small"
|
||||
variant="ghost"
|
||||
label="New Worktree"
|
||||
onClick={handleCreateWorktree}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="am-worktree-list">
|
||||
<For each={worktrees()}>
|
||||
@@ -722,73 +834,82 @@ const AgentManagerContent: Component = () => {
|
||||
<div class="am-detail">
|
||||
{/* Tab bar — visible when a section is selected and has tabs or a pending new session */}
|
||||
<Show when={selection() !== null && !contextEmpty()}>
|
||||
<div class="am-tab-bar">
|
||||
<div class="am-tab-scroll-area">
|
||||
<div class={`am-tab-fade am-tab-fade-left ${tabScroll.showLeft() ? "am-tab-fade-visible" : ""}`} />
|
||||
<div class="am-tab-list" ref={tabScroll.setRef}>
|
||||
<For each={activeTabs()}>
|
||||
{(s) => {
|
||||
const pending = isPending(s.id)
|
||||
const active = () =>
|
||||
pending
|
||||
? s.id === activePendingId() && !session.currentSessionID()
|
||||
: s.id === session.currentSessionID()
|
||||
return (
|
||||
<Tooltip value={s.title || "Untitled"} placement="bottom">
|
||||
<div
|
||||
class={`am-tab ${active() ? "am-tab-active" : ""}`}
|
||||
data-tab-id={s.id}
|
||||
onClick={() => {
|
||||
if (pending) {
|
||||
setActivePendingId(s.id)
|
||||
session.clearCurrentSession()
|
||||
} else {
|
||||
setActivePendingId(undefined)
|
||||
session.selectSession(s.id)
|
||||
}
|
||||
}}
|
||||
onMouseDown={(e: MouseEvent) => handleTabMouseDown(s.id, e)}
|
||||
>
|
||||
<span class="am-tab-label">{s.title || "Untitled"}</span>
|
||||
<IconButton
|
||||
icon="close-small"
|
||||
size="small"
|
||||
variant="ghost"
|
||||
label="Close tab"
|
||||
class="am-tab-close"
|
||||
onClick={(e: MouseEvent) => handleCloseTab(s.id, e)}
|
||||
<DragDropProvider
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={handleDragOver}
|
||||
collisionDetector={closestCenter}
|
||||
>
|
||||
<DragDropSensors />
|
||||
<ConstrainDragYAxis />
|
||||
<div class="am-tab-bar">
|
||||
<div class="am-tab-scroll-area">
|
||||
<div class={`am-tab-fade am-tab-fade-left ${tabScroll.showLeft() ? "am-tab-fade-visible" : ""}`} />
|
||||
<div class="am-tab-list" ref={tabScroll.setRef}>
|
||||
<SortableProvider ids={tabIds()}>
|
||||
<For each={activeTabs()}>
|
||||
{(s) => {
|
||||
const pending = isPending(s.id)
|
||||
const active = () =>
|
||||
pending
|
||||
? s.id === activePendingId() && !session.currentSessionID()
|
||||
: s.id === session.currentSessionID()
|
||||
return (
|
||||
<SortableTab
|
||||
tab={s}
|
||||
active={active()}
|
||||
onSelect={() => {
|
||||
if (pending) {
|
||||
setActivePendingId(s.id)
|
||||
session.clearCurrentSession()
|
||||
} else {
|
||||
setActivePendingId(undefined)
|
||||
session.selectSession(s.id)
|
||||
}
|
||||
}}
|
||||
onMiddleClick={(e: MouseEvent) => handleTabMouseDown(s.id, e)}
|
||||
onClose={(e: MouseEvent) => handleCloseTab(s.id, e)}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</SortableProvider>
|
||||
</div>
|
||||
<div class={`am-tab-fade am-tab-fade-right ${tabScroll.showRight() ? "am-tab-fade-visible" : ""}`} />
|
||||
</div>
|
||||
<IconButton
|
||||
icon="plus"
|
||||
size="small"
|
||||
variant="ghost"
|
||||
label={`New session (${modKey}T)`}
|
||||
class="am-tab-add"
|
||||
onClick={handleAddSession}
|
||||
/>
|
||||
<div class="am-tab-terminal">
|
||||
<Tooltip value="Open Terminal" placement="bottom">
|
||||
<IconButton
|
||||
icon="console"
|
||||
size="small"
|
||||
variant="ghost"
|
||||
label="Open Terminal"
|
||||
onClick={() => {
|
||||
const id = session.currentSessionID()
|
||||
if (id) vscode.postMessage({ type: "agentManager.showTerminal", sessionId: id })
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div class={`am-tab-fade am-tab-fade-right ${tabScroll.showRight() ? "am-tab-fade-visible" : ""}`} />
|
||||
</div>
|
||||
<IconButton
|
||||
icon="plus"
|
||||
size="small"
|
||||
variant="ghost"
|
||||
label={`New session (${modKey}T)`}
|
||||
class="am-tab-add"
|
||||
onClick={handleAddSession}
|
||||
/>
|
||||
<div class="am-tab-terminal">
|
||||
<Tooltip value="Open Terminal" placement="bottom">
|
||||
<IconButton
|
||||
icon="console"
|
||||
size="small"
|
||||
variant="ghost"
|
||||
label="Open Terminal"
|
||||
onClick={() => {
|
||||
const id = session.currentSessionID()
|
||||
if (id) vscode.postMessage({ type: "agentManager.showTerminal", sessionId: id })
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<DragOverlay>
|
||||
<Show when={draggedTab()}>
|
||||
{(tab) => (
|
||||
<div class="am-tab am-tab-overlay">
|
||||
<span class="am-tab-label">{tab().title || "Untitled"}</span>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</DragOverlay>
|
||||
</DragDropProvider>
|
||||
</Show>
|
||||
|
||||
{/* Empty worktree state */}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
}
|
||||
|
||||
.am-sidebar {
|
||||
width: 260px;
|
||||
position: relative;
|
||||
min-width: 200px;
|
||||
border-right: 1px solid var(--border-weak-base);
|
||||
display: flex;
|
||||
@@ -18,6 +18,10 @@
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.am-sidebar > [data-component="resize-handle"]::after {
|
||||
background: var(--surface-interactive-base);
|
||||
}
|
||||
|
||||
/* Fixed local workspace item */
|
||||
|
||||
.am-local-item {
|
||||
@@ -105,6 +109,12 @@
|
||||
color: var(--text-weak);
|
||||
}
|
||||
|
||||
.am-section-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
/* Worktree list */
|
||||
|
||||
.am-worktree-list {
|
||||
@@ -323,9 +333,10 @@
|
||||
position: relative;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
flex-shrink: 1;
|
||||
align-items: stretch;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Fade indicators for overflow */
|
||||
@@ -406,6 +417,29 @@
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Drag-and-drop sortable tab wrapper */
|
||||
|
||||
.am-tab-sortable {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.am-tab-dragging {
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
/* Drag overlay tab (follows the cursor) */
|
||||
|
||||
.am-tab-overlay {
|
||||
background: var(--surface-base);
|
||||
border: 1px solid var(--border-weak-base);
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
pointer-events: none;
|
||||
color: var(--text-base);
|
||||
}
|
||||
|
||||
.am-tab-add {
|
||||
flex-shrink: 0;
|
||||
align-self: center;
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Drag-and-drop sortable tab components for the agent manager tab bar.
|
||||
*/
|
||||
|
||||
import { Component, onCleanup } from "solid-js"
|
||||
import { createSortable, useDragDropContext } from "@thisbeyond/solid-dnd"
|
||||
import type { Transformer } from "@thisbeyond/solid-dnd"
|
||||
import { createRoot } from "solid-js"
|
||||
import type { SessionInfo } from "../src/types/messages"
|
||||
import { IconButton } from "@kilocode/kilo-ui/icon-button"
|
||||
import { Tooltip } from "@kilocode/kilo-ui/tooltip"
|
||||
|
||||
/** Lock drag movement to the X axis (horizontal-only tab dragging). */
|
||||
export const ConstrainDragYAxis: Component = () => {
|
||||
const context = useDragDropContext()
|
||||
if (!context) return null
|
||||
const [, { onDragStart, onDragEnd, addTransformer, removeTransformer }] = context
|
||||
const transformer: Transformer = { id: "constrain-y-axis", order: 100, callback: (t) => ({ ...t, y: 0 }) }
|
||||
const dispose = createRoot((dispose) => {
|
||||
onDragStart(({ draggable }) => {
|
||||
if (draggable) addTransformer("draggables", draggable.id as string, transformer)
|
||||
})
|
||||
onDragEnd(({ draggable }) => {
|
||||
if (draggable) removeTransformer("draggables", draggable.id as string, transformer.id)
|
||||
})
|
||||
return dispose
|
||||
})
|
||||
onCleanup(dispose)
|
||||
return null
|
||||
}
|
||||
|
||||
/** Individual sortable tab wrapper using the `use:sortable` directive. */
|
||||
export const SortableTab: Component<{
|
||||
tab: SessionInfo
|
||||
active: boolean
|
||||
onSelect: () => void
|
||||
onMiddleClick: (e: MouseEvent) => void
|
||||
onClose: (e: MouseEvent) => void
|
||||
}> = (props) => {
|
||||
const sortable = createSortable(props.tab.id)
|
||||
// Prevent tree-shaking of the directive reference used by `use:sortable`
|
||||
void sortable
|
||||
return (
|
||||
// @ts-ignore - use:sortable is a SolidJS directive compiled by esbuild-plugin-solid
|
||||
<div
|
||||
use:sortable
|
||||
class={`am-tab-sortable ${sortable.isActiveDraggable ? "am-tab-dragging" : ""}`}
|
||||
data-tab-id={props.tab.id}
|
||||
>
|
||||
<Tooltip value={props.tab.title || "Untitled"} placement="bottom">
|
||||
<div
|
||||
class={`am-tab ${props.active ? "am-tab-active" : ""}`}
|
||||
onClick={props.onSelect}
|
||||
onMouseDown={props.onMiddleClick}
|
||||
>
|
||||
<span class="am-tab-label">{props.tab.title || "Untitled"}</span>
|
||||
<IconButton
|
||||
icon="close-small"
|
||||
size="small"
|
||||
variant="ghost"
|
||||
label="Close tab"
|
||||
class="am-tab-close"
|
||||
onClick={props.onClose}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Pure tab-ordering logic for the agent manager.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Reorder an array by moving the item at `from` to the position of `to`.
|
||||
* Returns a new array, or undefined if either ID is not found or they are equal.
|
||||
*/
|
||||
export function reorderTabs(tabs: readonly string[], from: string, to: string): string[] | undefined {
|
||||
if (from === to) return undefined
|
||||
const fi = tabs.indexOf(from)
|
||||
const ti = tabs.indexOf(to)
|
||||
if (fi === -1 || ti === -1) return undefined
|
||||
const result = [...tabs]
|
||||
result.splice(fi, 1)
|
||||
result.splice(ti, 0, from)
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a custom ordering to a list of items.
|
||||
*
|
||||
* Items are returned in `order` sequence (skipping IDs not in `items`),
|
||||
* followed by any items not present in `order`.
|
||||
* Returns the original array unchanged if `order` is undefined or empty.
|
||||
*/
|
||||
export function applyTabOrder<T extends { id: string }>(items: T[], order: string[] | undefined): T[] {
|
||||
if (!order || order.length === 0) return items
|
||||
const lookup = new Map(items.map((item) => [item.id, item]))
|
||||
const ordered: T[] = []
|
||||
for (const id of order) {
|
||||
const item = lookup.get(id)
|
||||
if (item) {
|
||||
ordered.push(item)
|
||||
lookup.delete(id)
|
||||
}
|
||||
}
|
||||
for (const item of lookup.values()) ordered.push(item)
|
||||
return ordered
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the title of the first item according to a custom order.
|
||||
*
|
||||
* Falls back to the first titled item in `items` if the order
|
||||
* doesn't produce a match, then to `fallback`.
|
||||
*/
|
||||
export function firstOrderedTitle(
|
||||
items: { id: string; title?: string }[],
|
||||
order: string[] | undefined,
|
||||
fallback: string,
|
||||
): string {
|
||||
if (order) {
|
||||
const lookup = new Map(items.map((item) => [item.id, item]))
|
||||
for (const id of order) {
|
||||
const item = lookup.get(id)
|
||||
if (item?.title) return item.title
|
||||
}
|
||||
}
|
||||
const first = items.find((item) => item.title)
|
||||
return first?.title || fallback
|
||||
}
|
||||
@@ -563,6 +563,7 @@ export interface AgentManagerStateMessage {
|
||||
type: "agentManager.state"
|
||||
worktrees: WorktreeState[]
|
||||
sessions: ManagedSessionState[]
|
||||
tabOrder?: Record<string, string[]>
|
||||
}
|
||||
|
||||
export type ExtensionMessage =
|
||||
@@ -833,12 +834,24 @@ export interface RequestRepoInfoMessage {
|
||||
type: "agentManager.requestRepoInfo"
|
||||
}
|
||||
|
||||
// Configure worktree setup script
|
||||
export interface ConfigureSetupScriptRequest {
|
||||
type: "agentManager.configureSetupScript"
|
||||
}
|
||||
|
||||
// Show terminal for a session
|
||||
export interface ShowTerminalRequest {
|
||||
type: "agentManager.showTerminal"
|
||||
sessionId: string
|
||||
}
|
||||
|
||||
// Persist tab order for a context (worktree ID or "local")
|
||||
export interface SetTabOrderRequest {
|
||||
type: "agentManager.setTabOrder"
|
||||
key: string
|
||||
order: string[]
|
||||
}
|
||||
|
||||
export type WebviewMessage =
|
||||
| SendMessageRequest
|
||||
| AbortRequest
|
||||
@@ -882,7 +895,9 @@ export type WebviewMessage =
|
||||
| CloseSessionRequest
|
||||
| TelemetryRequest
|
||||
| RequestRepoInfoMessage
|
||||
| ConfigureSetupScriptRequest
|
||||
| ShowTerminalRequest
|
||||
| SetTabOrderRequest
|
||||
|
||||
// ============================================
|
||||
// VS Code API type
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, test, beforeEach } from "bun:test"
|
||||
import { describe, expect, test, beforeEach, mock } from "bun:test"
|
||||
|
||||
// Mock Bun.spawnSync before importing the module under test
|
||||
// Mock Bun.spawnSync via mock.module so it integrates properly with bun:test
|
||||
// and doesn't conflict with other test files that mock "../git-context".
|
||||
const spawnSyncResults: Record<string, string> = {}
|
||||
|
||||
function setGitOutput(args: string, output: string) {
|
||||
@@ -13,17 +14,171 @@ function clearGitOutputs() {
|
||||
}
|
||||
}
|
||||
|
||||
// Replace global Bun.spawnSync — the git() helper in git-context.ts calls
|
||||
// result.stdout.toString().trim(), so we return a Buffer and let git() trim.
|
||||
Bun.spawnSync = ((cmd: string[], _opts?: any) => {
|
||||
const args = cmd.slice(1).join(" ")
|
||||
const output = spawnSyncResults[args] ?? ""
|
||||
return {
|
||||
stdout: Buffer.from(output),
|
||||
stderr: Buffer.from(""),
|
||||
exitCode: 0,
|
||||
// Override the git-context module with a version that uses our mock spawnSync.
|
||||
// This avoids conflicts with generate.test.ts which also mocks this module.
|
||||
mock.module("../git-context", () => {
|
||||
function git(args: string[], cwd: string): string {
|
||||
const key = args.join(" ")
|
||||
return spawnSyncResults[key] ?? ""
|
||||
}
|
||||
}) as typeof Bun.spawnSync
|
||||
|
||||
const LOCK_FILES = new Set([
|
||||
"package-lock.json",
|
||||
"npm-shrinkwrap.json",
|
||||
"yarn.lock",
|
||||
"pnpm-lock.yaml",
|
||||
"shrinkwrap.yaml",
|
||||
"bun.lockb",
|
||||
"bun.lock",
|
||||
".pnp.js",
|
||||
".pnp.cjs",
|
||||
"jspm.lock",
|
||||
"Pipfile.lock",
|
||||
"poetry.lock",
|
||||
"pdm.lock",
|
||||
".pdm-lock.toml",
|
||||
"uv.lock",
|
||||
"conda-lock.yml",
|
||||
"pylock.toml",
|
||||
"Gemfile.lock",
|
||||
"composer.lock",
|
||||
"gradle.lockfile",
|
||||
"lockfile.json",
|
||||
"dependency-lock.json",
|
||||
"dependency-reduced-pom.xml",
|
||||
"coursier.lock",
|
||||
"build.sbt.lock",
|
||||
"packages.lock.json",
|
||||
"paket.lock",
|
||||
"project.assets.json",
|
||||
"Cargo.lock",
|
||||
"go.sum",
|
||||
"Gopkg.lock",
|
||||
"glide.lock",
|
||||
"build.zig.zon.lock",
|
||||
"dune.lock",
|
||||
"opam.lock",
|
||||
"Package.resolved",
|
||||
"Podfile.lock",
|
||||
"Cartfile.resolved",
|
||||
"pubspec.lock",
|
||||
"mix.lock",
|
||||
"rebar.lock",
|
||||
"stack.yaml.lock",
|
||||
"cabal.project.freeze",
|
||||
"exact-dependencies.json",
|
||||
"shard.lock",
|
||||
"Manifest.toml",
|
||||
"JuliaManifest.toml",
|
||||
"renv.lock",
|
||||
"packrat.lock",
|
||||
"nimble.lock",
|
||||
"dub.selections.json",
|
||||
"rocks.lock",
|
||||
"carton.lock",
|
||||
"cpanfile.snapshot",
|
||||
"conan.lock",
|
||||
"vcpkg-lock.json",
|
||||
".terraform.lock.hcl",
|
||||
"Berksfile.lock",
|
||||
"Puppetfile.lock",
|
||||
"MODULE.bazel.lock",
|
||||
"flake.lock",
|
||||
"deno.lock",
|
||||
"devcontainer.lock.json",
|
||||
])
|
||||
|
||||
const MAX_DIFF_LENGTH = 4000
|
||||
|
||||
function isLockFile(filepath: string): boolean {
|
||||
const name = filepath.split("/").pop() ?? filepath
|
||||
return LOCK_FILES.has(name)
|
||||
}
|
||||
|
||||
function parseNameStatus(output: string): Array<{ status: string; path: string }> {
|
||||
if (!output) return []
|
||||
return output.split("\n").map((line) => {
|
||||
const [status, ...rest] = line.split("\t")
|
||||
const path = status!.startsWith("R") ? (rest[1] ?? rest[0]) : rest.join("\t")
|
||||
return { status: status!, path }
|
||||
})
|
||||
}
|
||||
|
||||
function parsePorcelain(output: string): Array<{ status: string; path: string }> {
|
||||
if (!output) return []
|
||||
return output
|
||||
.split("\n")
|
||||
.filter((line) => line.length > 0)
|
||||
.map((line) => {
|
||||
const xy = line.slice(0, 2)
|
||||
const filepath = line.slice(3)
|
||||
return { status: xy.trim(), path: filepath }
|
||||
})
|
||||
}
|
||||
|
||||
type FileStatus = "added" | "modified" | "deleted" | "renamed"
|
||||
|
||||
function mapStatus(code: string): FileStatus {
|
||||
if (code.startsWith("R")) return "renamed"
|
||||
if (code === "A" || code === "??" || code === "?") return "added"
|
||||
if (code === "D") return "deleted"
|
||||
if (code === "M") return "modified"
|
||||
return "modified"
|
||||
}
|
||||
|
||||
function isUntracked(code: string): boolean {
|
||||
return code === "??" || code === "?"
|
||||
}
|
||||
|
||||
async function getGitContext(repoPath: string, selectedFiles?: string[]) {
|
||||
const branch = git(["branch", "--show-current"], repoPath) || "HEAD"
|
||||
const log = git(["log", "--oneline", "-5"], repoPath)
|
||||
const recentCommits = log ? log.split("\n") : []
|
||||
|
||||
const staged = parseNameStatus(git(["diff", "--name-status", "--cached"], repoPath))
|
||||
const useStaged = staged.length > 0
|
||||
const raw = useStaged ? staged : parsePorcelain(git(["status", "--porcelain"], repoPath))
|
||||
|
||||
const selected = selectedFiles ? new Set(selectedFiles) : undefined
|
||||
|
||||
const files: Array<{ status: FileStatus; path: string; diff: string }> = []
|
||||
for (const entry of raw) {
|
||||
if (isLockFile(entry.path)) continue
|
||||
if (selected && !selected.has(entry.path)) continue
|
||||
|
||||
const status = mapStatus(entry.status)
|
||||
const untracked = isUntracked(entry.status)
|
||||
|
||||
let diff: string
|
||||
if (untracked) {
|
||||
diff = `New untracked file: ${entry.path}`
|
||||
} else if (status === "deleted") {
|
||||
diff = useStaged
|
||||
? git(["diff", "--cached", "--", entry.path], repoPath)
|
||||
: git(["diff", "--", entry.path], repoPath)
|
||||
} else {
|
||||
const raw = useStaged
|
||||
? git(["diff", "--cached", "--", entry.path], repoPath)
|
||||
: git(["diff", "--", entry.path], repoPath)
|
||||
if (raw.includes("Binary files") || raw.includes("GIT binary patch")) {
|
||||
diff = `Binary file ${entry.path} has been modified`
|
||||
} else {
|
||||
diff = raw
|
||||
}
|
||||
}
|
||||
|
||||
if (diff.length > MAX_DIFF_LENGTH) {
|
||||
diff = diff.slice(0, MAX_DIFF_LENGTH) + "\n... [truncated]"
|
||||
}
|
||||
|
||||
files.push({ status, path: entry.path, diff })
|
||||
}
|
||||
|
||||
return { branch, recentCommits, files }
|
||||
}
|
||||
|
||||
return { getGitContext }
|
||||
})
|
||||
|
||||
import { getGitContext } from "../git-context"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user