mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-10-24 23:13:35 +08:00
Merge branch 'main' into feat/cloud-sessions-list
This commit is contained in:
@@ -32,6 +32,7 @@ export class AgentManagerProvider implements vscode.Disposable {
|
||||
private setupScript: SetupScriptService | undefined
|
||||
private terminalManager: SessionTerminalManager
|
||||
private stateReady: Promise<void> | undefined
|
||||
private importing = false
|
||||
|
||||
constructor(
|
||||
private readonly extensionUri: vscode.Uri,
|
||||
@@ -172,19 +173,30 @@ export class AgentManagerProvider implements vscode.Disposable {
|
||||
if (type === "agentManager.requestState") {
|
||||
void this.stateReady
|
||||
?.then(() => {
|
||||
// When the workspace is not a git repo (or has no folder open),
|
||||
// this.state is never created. pushState() silently returns in that
|
||||
// case, so re-send the empty/non-git state explicitly.
|
||||
if (!this.state) {
|
||||
this.pushEmptyState()
|
||||
return
|
||||
}
|
||||
this.pushState()
|
||||
// Refresh sessions after pushState so the webview's sessionsLoaded
|
||||
// handler is guaranteed to be registered (requestState fires from
|
||||
// onMount). Without this, the initial refreshSessions() in
|
||||
// initializeState() can race ahead of webview mount, causing
|
||||
// sessionsLoaded to never flip to true.
|
||||
if (this.state && this.state.getSessions().length > 0) {
|
||||
if (this.state.getSessions().length > 0) {
|
||||
this.provider?.refreshSessions()
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
this.log("initializeState failed, pushing partial state:", err)
|
||||
this.pushState()
|
||||
if (!this.state) {
|
||||
this.pushEmptyState()
|
||||
} else {
|
||||
this.pushState()
|
||||
}
|
||||
})
|
||||
return null
|
||||
}
|
||||
@@ -201,6 +213,35 @@ export class AgentManagerProvider implements vscode.Disposable {
|
||||
return null
|
||||
}
|
||||
|
||||
if (type === "agentManager.requestBranches") {
|
||||
void this.onRequestBranches()
|
||||
return null
|
||||
}
|
||||
if (type === "agentManager.requestExternalWorktrees") {
|
||||
void this.onRequestExternalWorktrees()
|
||||
return null
|
||||
}
|
||||
if (type === "agentManager.importFromBranch" && typeof msg.branch === "string") {
|
||||
void this.onImportFromBranch(msg.branch as string)
|
||||
return null
|
||||
}
|
||||
if (type === "agentManager.importFromPR" && typeof msg.url === "string") {
|
||||
void this.onImportFromPR(msg.url as string)
|
||||
return null
|
||||
}
|
||||
if (
|
||||
type === "agentManager.importExternalWorktree" &&
|
||||
typeof msg.path === "string" &&
|
||||
typeof msg.branch === "string"
|
||||
) {
|
||||
void this.onImportExternalWorktree(msg.path as string, msg.branch as string)
|
||||
return null
|
||||
}
|
||||
if (type === "agentManager.importAllExternalWorktrees") {
|
||||
void this.onImportAllExternalWorktrees()
|
||||
return null
|
||||
}
|
||||
|
||||
// When switching sessions, show existing terminal if one is open
|
||||
if (type === "loadMessages" && typeof msg.sessionID === "string") {
|
||||
this.terminalManager.showExisting(msg.sessionID)
|
||||
@@ -523,21 +564,6 @@ export class AgentManagerProvider implements vscode.Disposable {
|
||||
return null
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Branch discovery
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private async onRequestBranches(): Promise<void> {
|
||||
const manager = this.getWorktreeManager()
|
||||
if (!manager) return
|
||||
try {
|
||||
const data = await manager.listBranches()
|
||||
this.postToWebview({ type: "agentManager.branches", branches: data.branches, defaultBranch: data.defaultBranch })
|
||||
} catch (error) {
|
||||
this.log(`Failed to list branches: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Multi-version worktree creation
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -688,6 +714,322 @@ export class AgentManagerProvider implements vscode.Disposable {
|
||||
return null
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Import
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private async onRequestBranches(): Promise<void> {
|
||||
const manager = this.getWorktreeManager()
|
||||
if (!manager) {
|
||||
this.postToWebview({ type: "agentManager.branches", branches: [], defaultBranch: "main" })
|
||||
return
|
||||
}
|
||||
try {
|
||||
const result = await manager.listBranches()
|
||||
const checkedOut = await manager.checkedOutBranches()
|
||||
const filtered = result.branches.filter((b) => !checkedOut.has(b.name))
|
||||
this.postToWebview({
|
||||
type: "agentManager.branches",
|
||||
branches: filtered,
|
||||
defaultBranch: result.defaultBranch,
|
||||
})
|
||||
} catch (error) {
|
||||
this.log(`Failed to list branches: ${error}`)
|
||||
this.postToWebview({ type: "agentManager.branches", branches: [], defaultBranch: "main" })
|
||||
}
|
||||
}
|
||||
|
||||
private async onRequestExternalWorktrees(): Promise<void> {
|
||||
const manager = this.getWorktreeManager()
|
||||
const state = this.getStateManager()
|
||||
if (!manager || !state) {
|
||||
this.postToWebview({ type: "agentManager.externalWorktrees", worktrees: [] })
|
||||
return
|
||||
}
|
||||
try {
|
||||
const managedPaths = new Set(state.getWorktrees().map((wt) => wt.path))
|
||||
const worktrees = await manager.listExternalWorktrees(managedPaths)
|
||||
this.postToWebview({ type: "agentManager.externalWorktrees", worktrees })
|
||||
} catch (error) {
|
||||
this.log(`Failed to list external worktrees: ${error}`)
|
||||
this.postToWebview({ type: "agentManager.externalWorktrees", worktrees: [] })
|
||||
}
|
||||
}
|
||||
|
||||
private async onImportFromBranch(branch: string): Promise<void> {
|
||||
const manager = this.getWorktreeManager()
|
||||
const state = this.getStateManager()
|
||||
if (!manager || !state) {
|
||||
this.postToWebview({ type: "agentManager.importResult", success: false, message: "Not a git repository" })
|
||||
return
|
||||
}
|
||||
if (this.importing) {
|
||||
this.postToWebview({
|
||||
type: "agentManager.importResult",
|
||||
success: false,
|
||||
message: "Another import is already in progress",
|
||||
})
|
||||
return
|
||||
}
|
||||
this.importing = true
|
||||
|
||||
try {
|
||||
this.postToWebview({
|
||||
type: "agentManager.worktreeSetup",
|
||||
status: "creating",
|
||||
message: "Creating worktree from branch...",
|
||||
})
|
||||
const result = await manager.createWorktree({ existingBranch: branch })
|
||||
const worktree = state.addWorktree({
|
||||
branch: result.branch,
|
||||
path: result.path,
|
||||
parentBranch: result.parentBranch,
|
||||
})
|
||||
this.pushState()
|
||||
|
||||
try {
|
||||
this.postToWebview({
|
||||
type: "agentManager.worktreeSetup",
|
||||
status: "creating",
|
||||
message: "Running setup script...",
|
||||
branch: result.branch,
|
||||
worktreeId: worktree.id,
|
||||
})
|
||||
await this.runSetupScriptForWorktree(result.path, result.branch, worktree.id)
|
||||
|
||||
const session = await this.createSessionInWorktree(result.path, result.branch, worktree.id)
|
||||
if (!session) throw new Error("Failed to create session")
|
||||
|
||||
state.addSession(session.id, worktree.id)
|
||||
this.registerWorktreeSession(session.id, result.path)
|
||||
this.notifyWorktreeReady(session.id, result, worktree.id)
|
||||
this.postToWebview({ type: "agentManager.importResult", success: true, message: `Opened branch ${branch}` })
|
||||
this.log(`Imported branch ${branch} as worktree ${worktree.id}`)
|
||||
} catch (inner) {
|
||||
state.removeWorktree(worktree.id)
|
||||
await manager.removeWorktree(result.path)
|
||||
this.pushState()
|
||||
throw inner
|
||||
}
|
||||
} catch (error) {
|
||||
const raw = error instanceof Error ? error.message : String(error)
|
||||
const msg =
|
||||
raw.includes("already used by worktree") || raw.includes("already checked out")
|
||||
? `Branch "${branch}" is already checked out in another worktree`
|
||||
: raw
|
||||
this.postToWebview({ type: "agentManager.worktreeSetup", status: "error", message: msg })
|
||||
this.postToWebview({ type: "agentManager.importResult", success: false, message: msg })
|
||||
} finally {
|
||||
this.importing = false
|
||||
}
|
||||
}
|
||||
|
||||
private async onImportFromPR(url: string): Promise<void> {
|
||||
const manager = this.getWorktreeManager()
|
||||
const state = this.getStateManager()
|
||||
if (!manager || !state) {
|
||||
this.postToWebview({ type: "agentManager.importResult", success: false, message: "Not a git repository" })
|
||||
return
|
||||
}
|
||||
|
||||
if (this.importing) {
|
||||
this.postToWebview({
|
||||
type: "agentManager.importResult",
|
||||
success: false,
|
||||
message: "Another import is already in progress",
|
||||
})
|
||||
return
|
||||
}
|
||||
this.importing = true
|
||||
|
||||
try {
|
||||
this.postToWebview({ type: "agentManager.worktreeSetup", status: "creating", message: "Resolving PR..." })
|
||||
const result = await manager.createFromPR(url)
|
||||
const worktree = state.addWorktree({
|
||||
branch: result.branch,
|
||||
path: result.path,
|
||||
parentBranch: result.parentBranch,
|
||||
})
|
||||
this.pushState()
|
||||
|
||||
try {
|
||||
this.postToWebview({
|
||||
type: "agentManager.worktreeSetup",
|
||||
status: "creating",
|
||||
message: "Setting up workspace...",
|
||||
branch: result.branch,
|
||||
worktreeId: worktree.id,
|
||||
})
|
||||
await this.runSetupScriptForWorktree(result.path, result.branch, worktree.id)
|
||||
|
||||
const session = await this.createSessionInWorktree(result.path, result.branch, worktree.id)
|
||||
if (!session) throw new Error("Failed to create session")
|
||||
|
||||
state.addSession(session.id, worktree.id)
|
||||
this.registerWorktreeSession(session.id, result.path)
|
||||
this.notifyWorktreeReady(session.id, result, worktree.id)
|
||||
this.postToWebview({
|
||||
type: "agentManager.importResult",
|
||||
success: true,
|
||||
message: `Opened PR branch ${result.branch}`,
|
||||
})
|
||||
this.log(`Imported PR ${url} as worktree ${worktree.id}`)
|
||||
} catch (inner) {
|
||||
state.removeWorktree(worktree.id)
|
||||
await manager.removeWorktree(result.path)
|
||||
this.pushState()
|
||||
throw inner
|
||||
}
|
||||
} catch (error) {
|
||||
const raw = error instanceof Error ? error.message : String(error)
|
||||
const msg =
|
||||
raw.includes("already used by worktree") || raw.includes("already checked out")
|
||||
? "This PR's branch is already checked out in another worktree"
|
||||
: raw
|
||||
this.postToWebview({ type: "agentManager.worktreeSetup", status: "error", message: msg })
|
||||
this.postToWebview({ type: "agentManager.importResult", success: false, message: msg })
|
||||
} finally {
|
||||
this.importing = false
|
||||
}
|
||||
}
|
||||
|
||||
private async onImportExternalWorktree(wtPath: string, branch: string): Promise<void> {
|
||||
const state = this.getStateManager()
|
||||
const manager = this.getWorktreeManager()
|
||||
if (!state || !manager) {
|
||||
this.postToWebview({ type: "agentManager.importResult", success: false, message: "State not initialized" })
|
||||
return
|
||||
}
|
||||
|
||||
if (this.importing) {
|
||||
this.postToWebview({
|
||||
type: "agentManager.importResult",
|
||||
success: false,
|
||||
message: "Another import is already in progress",
|
||||
})
|
||||
return
|
||||
}
|
||||
this.importing = true
|
||||
|
||||
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)) {
|
||||
this.postToWebview({
|
||||
type: "agentManager.importResult",
|
||||
success: false,
|
||||
message: "Path is not a valid worktree for this repository",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const parent = await manager.defaultBranch()
|
||||
worktree = state.addWorktree({ branch, path: wtPath, parentBranch: parent })
|
||||
this.pushState()
|
||||
|
||||
const session = await this.createSessionInWorktree(wtPath, branch, worktree.id)
|
||||
if (!session) {
|
||||
state.removeWorktree(worktree.id)
|
||||
this.pushState()
|
||||
this.postToWebview({ type: "agentManager.importResult", success: false, message: "Failed to create session" })
|
||||
return
|
||||
}
|
||||
|
||||
state.addSession(session.id, worktree.id)
|
||||
this.registerWorktreeSession(session.id, wtPath)
|
||||
this.pushState()
|
||||
this.postToWebview({
|
||||
type: "agentManager.worktreeSetup",
|
||||
status: "ready",
|
||||
message: "Worktree imported",
|
||||
sessionId: session.id,
|
||||
branch,
|
||||
worktreeId: worktree.id,
|
||||
})
|
||||
this.postToWebview({
|
||||
type: "agentManager.sessionMeta",
|
||||
sessionId: session.id,
|
||||
mode: "worktree",
|
||||
branch,
|
||||
path: wtPath,
|
||||
parentBranch: parent,
|
||||
})
|
||||
this.postToWebview({ type: "agentManager.importResult", success: true, message: `Imported ${branch}` })
|
||||
this.log(`Imported external worktree ${wtPath} (${branch})`)
|
||||
} catch (error) {
|
||||
if (worktree) {
|
||||
state.removeWorktree(worktree.id)
|
||||
this.pushState()
|
||||
}
|
||||
const msg = error instanceof Error ? error.message : String(error)
|
||||
this.postToWebview({ type: "agentManager.importResult", success: false, message: msg })
|
||||
} finally {
|
||||
this.importing = false
|
||||
}
|
||||
}
|
||||
|
||||
private async onImportAllExternalWorktrees(): Promise<void> {
|
||||
if (this.importing) {
|
||||
this.postToWebview({
|
||||
type: "agentManager.importResult",
|
||||
success: false,
|
||||
message: "Another import is already in progress",
|
||||
})
|
||||
return
|
||||
}
|
||||
const manager = this.getWorktreeManager()
|
||||
const state = this.getStateManager()
|
||||
if (!manager || !state) {
|
||||
this.postToWebview({ type: "agentManager.importResult", success: false, message: "Not a git repository" })
|
||||
return
|
||||
}
|
||||
this.importing = true
|
||||
|
||||
try {
|
||||
const managedPaths = new Set(state.getWorktrees().map((wt) => wt.path))
|
||||
const externals = await manager.listExternalWorktrees(managedPaths)
|
||||
if (externals.length === 0) {
|
||||
this.postToWebview({
|
||||
type: "agentManager.importResult",
|
||||
success: true,
|
||||
message: "No external worktrees to import",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
let imported = 0
|
||||
const parent = await manager.defaultBranch()
|
||||
for (const ext of externals) {
|
||||
try {
|
||||
const worktree = state.addWorktree({ branch: ext.branch, path: ext.path, parentBranch: parent })
|
||||
const session = await this.createSessionInWorktree(ext.path, ext.branch, worktree.id)
|
||||
if (session) {
|
||||
state.addSession(session.id, worktree.id)
|
||||
this.registerWorktreeSession(session.id, ext.path)
|
||||
imported++
|
||||
} else {
|
||||
state.removeWorktree(worktree.id)
|
||||
}
|
||||
} catch (error) {
|
||||
this.log(`Failed to import external worktree ${ext.path}: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
this.pushState()
|
||||
this.postToWebview({
|
||||
type: "agentManager.importResult",
|
||||
success: true,
|
||||
message: `Imported ${imported} workspace${imported !== 1 ? "s" : ""}`,
|
||||
})
|
||||
this.log(`Imported ${imported}/${externals.length} external worktrees`)
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error)
|
||||
this.postToWebview({ type: "agentManager.importResult", success: false, message: msg })
|
||||
} finally {
|
||||
this.importing = false
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Keybindings
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -8,9 +8,23 @@
|
||||
|
||||
import * as path from "path"
|
||||
import * as fs from "fs"
|
||||
import * as cp from "child_process"
|
||||
import simpleGit, { type SimpleGit } from "simple-git"
|
||||
import { generateBranchName } from "./branch-name"
|
||||
import {
|
||||
parsePRUrl,
|
||||
localBranchName,
|
||||
parseForEachRefOutput,
|
||||
buildBranchList,
|
||||
parseWorktreeList,
|
||||
checkedOutBranchesFromWorktreeList,
|
||||
classifyPRError,
|
||||
validateGitRef,
|
||||
type PRInfo,
|
||||
type BranchListItem,
|
||||
} from "./git-import"
|
||||
|
||||
export type { BranchListItem }
|
||||
export { generateBranchName }
|
||||
|
||||
export interface WorktreeInfo {
|
||||
@@ -27,12 +41,9 @@ export interface CreateWorktreeResult {
|
||||
parentBranch: string
|
||||
}
|
||||
|
||||
export interface BranchInfo {
|
||||
name: string
|
||||
isLocal: boolean
|
||||
isRemote: boolean
|
||||
lastCommitDate: number
|
||||
isDefault: boolean
|
||||
export interface ExternalWorktreeItem {
|
||||
path: string
|
||||
branch: string
|
||||
}
|
||||
|
||||
const KILOCODE_DIR = ".kilocode"
|
||||
@@ -87,7 +98,6 @@ export class WorktreeManager {
|
||||
if (!exists) throw new Error(`Branch "${branch}" does not exist`)
|
||||
}
|
||||
|
||||
// Sanitize directory name — replace slashes with dashes for filesystem safety
|
||||
const dirName = branch.replace(/\//g, "-")
|
||||
let worktreePath = path.join(this.dir, dirName)
|
||||
|
||||
@@ -215,73 +225,6 @@ export class WorktreeManager {
|
||||
return undefined
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Branch & worktree discovery
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async listBranches(): Promise<{ branches: BranchInfo[]; defaultBranch: string }> {
|
||||
const defBranch = await this.defaultBranch()
|
||||
|
||||
// Get local branches with commit dates
|
||||
const localRaw = await this.git
|
||||
.raw(["for-each-ref", "--sort=-committerdate", "--format=%(refname:short)\t%(committerdate:unix)", "refs/heads/"])
|
||||
.then((out) => out.trim())
|
||||
.catch(() => "")
|
||||
|
||||
// Get remote branches with commit dates
|
||||
const remoteRaw = await this.git
|
||||
.raw([
|
||||
"for-each-ref",
|
||||
"--sort=-committerdate",
|
||||
"--format=%(refname:short)\t%(committerdate:unix)",
|
||||
"refs/remotes/origin/",
|
||||
])
|
||||
.then((out) => out.trim())
|
||||
.catch(() => "")
|
||||
|
||||
const map = new Map<string, BranchInfo>()
|
||||
|
||||
for (const line of localRaw.split("\n").filter(Boolean)) {
|
||||
const [name, dateStr] = line.split("\t")
|
||||
if (!name) continue
|
||||
map.set(name, {
|
||||
name,
|
||||
isLocal: true,
|
||||
isRemote: false,
|
||||
lastCommitDate: parseInt(dateStr || "0", 10),
|
||||
isDefault: name === defBranch,
|
||||
})
|
||||
}
|
||||
|
||||
for (const line of remoteRaw.split("\n").filter(Boolean)) {
|
||||
const [ref, dateStr] = line.split("\t")
|
||||
if (!ref) continue
|
||||
const name = ref.replace(/^origin\//, "")
|
||||
if (name === "HEAD") continue
|
||||
const existing = map.get(name)
|
||||
if (existing) {
|
||||
existing.isRemote = true
|
||||
} else {
|
||||
map.set(name, {
|
||||
name,
|
||||
isLocal: false,
|
||||
isRemote: true,
|
||||
lastCommitDate: parseInt(dateStr || "0", 10),
|
||||
isDefault: name === defBranch,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Sort: default first, then by lastCommitDate descending
|
||||
const branches = [...map.values()].sort((a, b) => {
|
||||
if (a.isDefault && !b.isDefault) return -1
|
||||
if (!a.isDefault && b.isDefault) return 1
|
||||
return b.lastCommitDate - a.lastCommitDate
|
||||
})
|
||||
|
||||
return { branches, defaultBranch: defBranch }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Git exclude management
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -409,4 +352,151 @@ export class WorktreeManager {
|
||||
|
||||
return "main"
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Import
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async listBranches(): Promise<{ branches: BranchListItem[]; defaultBranch: string }> {
|
||||
const defBranch = await this.defaultBranch()
|
||||
const raw = await this.git.raw([
|
||||
"for-each-ref",
|
||||
"--sort=-committerdate",
|
||||
"--format=%(refname)\t%(committerdate:iso-strict)",
|
||||
"refs/heads/",
|
||||
"refs/remotes/origin/",
|
||||
])
|
||||
const { locals, remotes, dates } = parseForEachRefOutput(raw)
|
||||
return { branches: buildBranchList(locals, remotes, dates, defBranch), defaultBranch: defBranch }
|
||||
}
|
||||
|
||||
async checkedOutBranches(): Promise<Set<string>> {
|
||||
try {
|
||||
const raw = await this.git.raw(["worktree", "list", "--porcelain"])
|
||||
return checkedOutBranchesFromWorktreeList(raw)
|
||||
} catch (error) {
|
||||
this.log(`Failed to list worktree branches: ${error}`)
|
||||
const result = new Set<string>()
|
||||
try {
|
||||
result.add(await this.currentBranch())
|
||||
} catch (inner) {
|
||||
this.log(`Failed to get current branch: ${inner}`)
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
async listExternalWorktrees(managedPaths: Set<string>): Promise<ExternalWorktreeItem[]> {
|
||||
try {
|
||||
const raw = await this.git.raw(["worktree", "list", "--porcelain"])
|
||||
return parseWorktreeList(raw)
|
||||
.filter((e) => !e.bare && e.path !== this.root && !managedPaths.has(e.path))
|
||||
.map((e) => ({ path: e.path, branch: e.branch }))
|
||||
} catch (error) {
|
||||
this.log(`Failed to list external worktrees: ${error}`)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async createFromPR(url: string): Promise<CreateWorktreeResult> {
|
||||
const parsed = parsePRUrl(url)
|
||||
if (!parsed) throw new Error("Invalid PR URL. Expected: https://github.com/owner/repo/pull/123")
|
||||
|
||||
const info = await this.fetchPRInfo(parsed)
|
||||
const branch = localBranchName(info)
|
||||
const isFork = info.isCrossRepository
|
||||
const forkOwner = info.headRepositoryOwner?.login?.toLowerCase()
|
||||
|
||||
const checkedOut = await this.checkedOutBranches()
|
||||
if (checkedOut.has(branch) || checkedOut.has(info.headRefName)) {
|
||||
throw new Error("This PR's branch is already checked out in another worktree")
|
||||
}
|
||||
|
||||
await this.fetchPRBranch(info, parsed, isFork, forkOwner)
|
||||
|
||||
if (isFork && forkOwner) {
|
||||
if (await this.branchExists(branch)) {
|
||||
await this.git.raw(["branch", "-D", branch])
|
||||
}
|
||||
await this.git.raw(["branch", branch, `${forkOwner}/${info.headRefName}`])
|
||||
}
|
||||
|
||||
return this.createWorktree({ existingBranch: branch })
|
||||
}
|
||||
|
||||
private async fetchPRInfo(parsed: { owner: string; repo: string; number: number }): Promise<PRInfo> {
|
||||
try {
|
||||
const json = await this.exec(
|
||||
"gh",
|
||||
[
|
||||
"pr",
|
||||
"view",
|
||||
String(parsed.number),
|
||||
"--repo",
|
||||
`${parsed.owner}/${parsed.repo}`,
|
||||
"--json",
|
||||
"headRefName,headRepositoryOwner,isCrossRepository,title",
|
||||
],
|
||||
30000,
|
||||
)
|
||||
return JSON.parse(json) as PRInfo
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : String(error)
|
||||
const kind = classifyPRError(msg)
|
||||
if (kind === "not_found") throw new Error(`PR #${parsed.number} not found in ${parsed.owner}/${parsed.repo}`)
|
||||
if (kind === "gh_missing")
|
||||
throw new Error("GitHub CLI (gh) is not installed. Install it from https://cli.github.com/")
|
||||
if (kind === "gh_auth") throw new Error("Not authenticated with GitHub CLI. Run 'gh auth login' first.")
|
||||
throw new Error(`Failed to fetch PR info: ${msg}`)
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchPRBranch(
|
||||
info: PRInfo,
|
||||
parsed: { owner: string; repo: string; number: number },
|
||||
isFork: boolean,
|
||||
forkOwner: string | undefined,
|
||||
): Promise<void> {
|
||||
if (isFork && forkOwner) {
|
||||
validateGitRef(forkOwner, "fork owner")
|
||||
validateGitRef(info.headRefName, "branch name")
|
||||
const remotes = await this.git.getRemotes()
|
||||
if (!remotes.some((r) => r.name === forkOwner)) {
|
||||
await this.git.addRemote(forkOwner, `https://github.com/${forkOwner}/${parsed.repo}.git`)
|
||||
}
|
||||
await this.gitExec(["fetch", forkOwner, info.headRefName])
|
||||
} else {
|
||||
validateGitRef(info.headRefName, "branch name")
|
||||
const ok = await this.gitTry(["fetch", "origin", info.headRefName])
|
||||
if (!ok) {
|
||||
await this.gitExec([
|
||||
"fetch",
|
||||
"origin",
|
||||
`+refs/pull/${parsed.number}/head:refs/remotes/origin/${info.headRefName}`,
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private exec(cmd: string, args: string[], timeout = 120000): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
cp.execFile(cmd, args, { cwd: this.root, timeout, encoding: "utf-8" }, (error, stdout) => {
|
||||
if (error) reject(error)
|
||||
else resolve(stdout)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private async gitExec(args: string[]): Promise<void> {
|
||||
await this.exec("git", args)
|
||||
}
|
||||
|
||||
private async gitTry(args: string[]): Promise<boolean> {
|
||||
try {
|
||||
await this.gitExec(args)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -320,8 +320,17 @@ export class WorktreeStateManager {
|
||||
data.sessionsCollapsed = true
|
||||
}
|
||||
|
||||
const dir = path.dirname(this.file)
|
||||
if (!fs.existsSync(dir)) await fs.promises.mkdir(dir, { recursive: true })
|
||||
await fs.promises.writeFile(this.file, JSON.stringify(data, null, 2), "utf-8")
|
||||
try {
|
||||
const dir = path.dirname(this.file)
|
||||
if (!fs.existsSync(dir)) await fs.promises.mkdir(dir, { recursive: true })
|
||||
await fs.promises.writeFile(this.file, JSON.stringify(data, null, 2), "utf-8")
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code
|
||||
if (code === "ENOENT") {
|
||||
this.log("State directory was removed, skipping save")
|
||||
return
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
export interface BranchListItem {
|
||||
name: string
|
||||
isLocal: boolean
|
||||
isRemote: boolean
|
||||
isDefault: boolean
|
||||
lastCommitDate?: string
|
||||
}
|
||||
|
||||
export interface PRUrlParts {
|
||||
owner: string
|
||||
repo: string
|
||||
number: number
|
||||
}
|
||||
|
||||
export interface PRInfo {
|
||||
headRefName: string
|
||||
headRepositoryOwner?: { login: string }
|
||||
isCrossRepository: boolean
|
||||
title: string
|
||||
}
|
||||
|
||||
export interface WorktreeEntry {
|
||||
path: string
|
||||
branch: string
|
||||
bare: boolean
|
||||
detached: boolean
|
||||
}
|
||||
|
||||
export type PRErrorKind = "not_found" | "gh_missing" | "gh_auth" | "unknown"
|
||||
|
||||
export function parsePRUrl(url: string): PRUrlParts | null {
|
||||
let normalized = url.trim()
|
||||
if (!normalized.startsWith("http")) normalized = `https://${normalized}`
|
||||
normalized = normalized.replace(/\/+$/, "")
|
||||
const match = normalized.match(/\/\/(?:www\.)?github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/)
|
||||
if (!match) return null
|
||||
return { owner: match[1], repo: match[2], number: parseInt(match[3], 10) }
|
||||
}
|
||||
|
||||
export function localBranchName(info: PRInfo): string {
|
||||
if (info.isCrossRepository) {
|
||||
const owner = info.headRepositoryOwner?.login?.toLowerCase()
|
||||
if (owner) return `${owner}/${info.headRefName}`
|
||||
}
|
||||
return info.headRefName
|
||||
}
|
||||
|
||||
export function parseForEachRefOutput(raw: string): {
|
||||
locals: Set<string>
|
||||
remotes: Set<string>
|
||||
dates: Map<string, string>
|
||||
} {
|
||||
const locals = new Set<string>()
|
||||
const remotes = new Set<string>()
|
||||
const dates = new Map<string, string>()
|
||||
|
||||
for (const line of raw.split("\n")) {
|
||||
if (!line) continue
|
||||
const [ref, date] = line.split("\t")
|
||||
if (ref.includes("HEAD")) continue
|
||||
|
||||
if (ref.startsWith("refs/heads/")) {
|
||||
const name = ref.slice(11)
|
||||
locals.add(name)
|
||||
if (date && !dates.has(name)) dates.set(name, date)
|
||||
} else if (ref.startsWith("refs/remotes/origin/")) {
|
||||
const name = ref.slice(20)
|
||||
remotes.add(name)
|
||||
if (date && !dates.has(name)) dates.set(name, date)
|
||||
}
|
||||
}
|
||||
|
||||
return { locals, remotes, dates }
|
||||
}
|
||||
|
||||
export function buildBranchList(
|
||||
locals: Set<string>,
|
||||
remotes: Set<string>,
|
||||
dates: Map<string, string>,
|
||||
defaultBranch: string,
|
||||
): BranchListItem[] {
|
||||
const all = new Set([...locals, ...remotes])
|
||||
const branches: BranchListItem[] = [...all].map((name) => ({
|
||||
name,
|
||||
isLocal: locals.has(name),
|
||||
isRemote: remotes.has(name),
|
||||
isDefault: name === defaultBranch,
|
||||
lastCommitDate: dates.get(name),
|
||||
}))
|
||||
|
||||
branches.sort((a, b) => {
|
||||
if (a.isDefault && !b.isDefault) return -1
|
||||
if (!a.isDefault && b.isDefault) return 1
|
||||
if (a.lastCommitDate && b.lastCommitDate) return b.lastCommitDate.localeCompare(a.lastCommitDate)
|
||||
return 0
|
||||
})
|
||||
|
||||
return branches
|
||||
}
|
||||
|
||||
export function parseWorktreeList(raw: string): WorktreeEntry[] {
|
||||
const entries: WorktreeEntry[] = []
|
||||
for (const block of raw.split("\n\n")) {
|
||||
if (!block.trim()) continue
|
||||
const lines = block.split("\n")
|
||||
const wtPath = lines.find((l) => l.startsWith("worktree "))?.slice(9)
|
||||
if (!wtPath) continue
|
||||
|
||||
const branchLine = lines.find((l) => l.startsWith("branch "))
|
||||
const bare = lines.some((l) => l === "bare")
|
||||
const detached = lines.some((l) => l === "detached")
|
||||
const branch = branchLine ? branchLine.slice(7).replace("refs/heads/", "") : detached ? "(detached)" : "unknown"
|
||||
|
||||
entries.push({ path: wtPath, branch, bare, detached })
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
export function checkedOutBranchesFromWorktreeList(raw: string): Set<string> {
|
||||
const result = new Set<string>()
|
||||
for (const entry of parseWorktreeList(raw)) {
|
||||
if (!entry.bare && !entry.detached) result.add(entry.branch)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const SAFE_GIT_REF = /^[a-zA-Z0-9._\-/]+$/
|
||||
|
||||
export function validateGitRef(value: string, label: string): void {
|
||||
if (!value || !SAFE_GIT_REF.test(value) || value.startsWith("-") || value.includes("..")) {
|
||||
throw new Error(`Unsafe ${label}: "${value}"`)
|
||||
}
|
||||
}
|
||||
|
||||
export function classifyPRError(msg: string): PRErrorKind {
|
||||
if (msg.includes("command not found") || msg.includes("ENOENT") || msg.includes("is not recognized"))
|
||||
return "gh_missing"
|
||||
if (msg.includes("not logged") || msg.includes("auth login")) return "gh_auth"
|
||||
if (msg.includes("not found") || msg.includes("Could not resolve")) return "not_found"
|
||||
return "unknown"
|
||||
}
|
||||
@@ -256,4 +256,55 @@ describe("Agent Manager Provider — onMessage routing", () => {
|
||||
// Must also send sessionMeta so the webview knows the branch/path
|
||||
expect(text).toContain("agentManager.sessionMeta")
|
||||
})
|
||||
|
||||
// -- agentManager.requestState in non-git workspace -------------------------
|
||||
|
||||
/**
|
||||
* Regression: when the workspace is not a git repo, this.state is undefined.
|
||||
* pushState() silently returns in that case, so requestState must explicitly
|
||||
* call pushEmptyState() instead — otherwise the webview stays stuck on
|
||||
* loading skeletons forever.
|
||||
*/
|
||||
it("requestState handler calls pushEmptyState when this.state is falsy", () => {
|
||||
const text = body("onMessage")
|
||||
// Extract the requestState branch
|
||||
const start = text.indexOf('"agentManager.requestState"')
|
||||
expect(start, "requestState branch must exist").toBeGreaterThan(-1)
|
||||
// Grab a reasonable window after the match
|
||||
const snippet = text.slice(start, start + 600)
|
||||
expect(snippet, "must call pushEmptyState when state is absent").toContain("pushEmptyState")
|
||||
expect(snippet, "must guard on this.state being falsy").toMatch(/!this\.state/)
|
||||
})
|
||||
|
||||
it("requestState handler calls pushState when this.state is truthy", () => {
|
||||
const text = body("onMessage")
|
||||
const start = text.indexOf('"agentManager.requestState"')
|
||||
const snippet = text.slice(start, start + 600)
|
||||
expect(snippet, "must call pushState for the normal path").toContain("this.pushState()")
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Webview — non-git skeleton fix
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("Agent Manager Webview — non-git sessionsLoaded fix", () => {
|
||||
const tsx = readAllTsx()
|
||||
|
||||
/**
|
||||
* Regression: when isGitRepo is false, the Kilo server never sends a
|
||||
* "sessionsLoaded" message, so the skeleton was stuck forever.
|
||||
* The fix must set sessionsLoaded(true) when receiving a state message
|
||||
* with isGitRepo === false.
|
||||
*/
|
||||
it("sets sessionsLoaded when agentManager.state arrives with isGitRepo false", () => {
|
||||
// Find the agentManager.state handler block
|
||||
const start = tsx.indexOf('"agentManager.state"')
|
||||
expect(start, "agentManager.state handler must exist").toBeGreaterThan(-1)
|
||||
const snippet = tsx.slice(start, start + 800)
|
||||
expect(snippet, "must call setSessionsLoaded in the non-git branch").toContain("setSessionsLoaded")
|
||||
expect(snippet, "must check isGitRepo === false before setting sessionsLoaded").toMatch(
|
||||
/isGitRepo.*false|false.*isGitRepo/,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import {
|
||||
parsePRUrl,
|
||||
localBranchName,
|
||||
parseForEachRefOutput,
|
||||
buildBranchList,
|
||||
parseWorktreeList,
|
||||
checkedOutBranchesFromWorktreeList,
|
||||
classifyPRError,
|
||||
validateGitRef,
|
||||
} from "../../src/agent-manager/git-import"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parsePRUrl
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("parsePRUrl", () => {
|
||||
it("parses a standard GitHub PR URL", () => {
|
||||
expect(parsePRUrl("https://github.com/Kilo-Org/kilocode/pull/6164")).toEqual({
|
||||
owner: "Kilo-Org",
|
||||
repo: "kilocode",
|
||||
number: 6164,
|
||||
})
|
||||
})
|
||||
|
||||
it("handles URL without protocol", () => {
|
||||
expect(parsePRUrl("github.com/owner/repo/pull/42")).toEqual({
|
||||
owner: "owner",
|
||||
repo: "repo",
|
||||
number: 42,
|
||||
})
|
||||
})
|
||||
|
||||
it("handles trailing slashes", () => {
|
||||
expect(parsePRUrl("https://github.com/o/r/pull/1///")).toEqual({
|
||||
owner: "o",
|
||||
repo: "r",
|
||||
number: 1,
|
||||
})
|
||||
})
|
||||
|
||||
it("handles www.github.com", () => {
|
||||
expect(parsePRUrl("https://www.github.com/o/r/pull/99")).toEqual({
|
||||
owner: "o",
|
||||
repo: "r",
|
||||
number: 99,
|
||||
})
|
||||
})
|
||||
|
||||
it("returns null for non-GitHub URLs", () => {
|
||||
expect(parsePRUrl("https://gitlab.com/owner/repo/pull/1")).toBeNull()
|
||||
})
|
||||
|
||||
it("returns null for GitHub URLs without /pull/", () => {
|
||||
expect(parsePRUrl("https://github.com/owner/repo/issues/1")).toBeNull()
|
||||
})
|
||||
|
||||
it("returns null for empty string", () => {
|
||||
expect(parsePRUrl("")).toBeNull()
|
||||
})
|
||||
|
||||
it("returns null for garbage input", () => {
|
||||
expect(parsePRUrl("not a url at all")).toBeNull()
|
||||
})
|
||||
|
||||
it("returns null when PR number is missing", () => {
|
||||
expect(parsePRUrl("https://github.com/owner/repo/pull/")).toBeNull()
|
||||
})
|
||||
|
||||
it("strips whitespace", () => {
|
||||
expect(parsePRUrl(" https://github.com/o/r/pull/5 ")).toEqual({
|
||||
owner: "o",
|
||||
repo: "r",
|
||||
number: 5,
|
||||
})
|
||||
})
|
||||
|
||||
it("handles URL with extra path segments after PR number", () => {
|
||||
const result = parsePRUrl("https://github.com/o/r/pull/123/files")
|
||||
expect(result).toEqual({ owner: "o", repo: "r", number: 123 })
|
||||
})
|
||||
|
||||
it("rejects lookalike domains", () => {
|
||||
expect(parsePRUrl("https://evilgithub.com/owner/repo/pull/1")).toBeNull()
|
||||
expect(parsePRUrl("https://notgithub.com/owner/repo/pull/1")).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// localBranchName
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("localBranchName", () => {
|
||||
it("returns headRefName for same-repo PR", () => {
|
||||
expect(
|
||||
localBranchName({
|
||||
headRefName: "feat/cool",
|
||||
isCrossRepository: false,
|
||||
title: "test",
|
||||
}),
|
||||
).toBe("feat/cool")
|
||||
})
|
||||
|
||||
it("prefixes with fork owner for cross-repo PR", () => {
|
||||
expect(
|
||||
localBranchName({
|
||||
headRefName: "fix/bug",
|
||||
headRepositoryOwner: { login: "Contributor" },
|
||||
isCrossRepository: true,
|
||||
title: "test",
|
||||
}),
|
||||
).toBe("contributor/fix/bug")
|
||||
})
|
||||
|
||||
it("returns headRefName if cross-repo but no owner", () => {
|
||||
expect(
|
||||
localBranchName({
|
||||
headRefName: "fix/bug",
|
||||
isCrossRepository: true,
|
||||
title: "test",
|
||||
}),
|
||||
).toBe("fix/bug")
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseForEachRefOutput
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("parseForEachRefOutput", () => {
|
||||
it("parses local and remote branches with dates", () => {
|
||||
const raw = [
|
||||
"refs/heads/main\t2025-01-15T10:00:00+00:00",
|
||||
"refs/heads/feat/a\t2025-01-14T09:00:00+00:00",
|
||||
"refs/remotes/origin/main\t2025-01-15T10:00:00+00:00",
|
||||
"refs/remotes/origin/feat/b\t2025-01-13T08:00:00+00:00",
|
||||
].join("\n")
|
||||
|
||||
const { locals, remotes, dates } = parseForEachRefOutput(raw)
|
||||
|
||||
expect([...locals]).toEqual(["main", "feat/a"])
|
||||
expect([...remotes]).toEqual(["main", "feat/b"])
|
||||
expect(dates.get("main")).toBe("2025-01-15T10:00:00+00:00")
|
||||
expect(dates.get("feat/a")).toBe("2025-01-14T09:00:00+00:00")
|
||||
expect(dates.get("feat/b")).toBe("2025-01-13T08:00:00+00:00")
|
||||
})
|
||||
|
||||
it("skips HEAD entries", () => {
|
||||
const raw = "refs/remotes/origin/HEAD\t2025-01-01T00:00:00+00:00\nrefs/heads/main\t2025-01-01T00:00:00+00:00"
|
||||
const { locals, remotes } = parseForEachRefOutput(raw)
|
||||
expect(remotes.has("HEAD")).toBe(false)
|
||||
expect(locals.has("main")).toBe(true)
|
||||
})
|
||||
|
||||
it("skips empty lines", () => {
|
||||
const raw = "\n\nrefs/heads/main\t2025-01-01T00:00:00+00:00\n\n"
|
||||
const { locals } = parseForEachRefOutput(raw)
|
||||
expect([...locals]).toEqual(["main"])
|
||||
})
|
||||
|
||||
it("handles empty output", () => {
|
||||
const { locals, remotes, dates } = parseForEachRefOutput("")
|
||||
expect(locals.size).toBe(0)
|
||||
expect(remotes.size).toBe(0)
|
||||
expect(dates.size).toBe(0)
|
||||
})
|
||||
|
||||
it("local branch takes priority for date when both exist", () => {
|
||||
const raw = [
|
||||
"refs/heads/main\t2025-02-01T00:00:00+00:00",
|
||||
"refs/remotes/origin/main\t2025-01-01T00:00:00+00:00",
|
||||
].join("\n")
|
||||
const { dates } = parseForEachRefOutput(raw)
|
||||
expect(dates.get("main")).toBe("2025-02-01T00:00:00+00:00")
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// buildBranchList
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("buildBranchList", () => {
|
||||
it("merges local and remote into deduplicated sorted list", () => {
|
||||
const locals = new Set(["main", "feat/a"])
|
||||
const remotes = new Set(["main", "feat/b"])
|
||||
const dates = new Map([
|
||||
["main", "2025-01-15T00:00:00+00:00"],
|
||||
["feat/a", "2025-01-14T00:00:00+00:00"],
|
||||
["feat/b", "2025-01-13T00:00:00+00:00"],
|
||||
])
|
||||
|
||||
const result = buildBranchList(locals, remotes, dates, "main")
|
||||
|
||||
expect(result[0].name).toBe("main")
|
||||
expect(result[0].isDefault).toBe(true)
|
||||
expect(result[0].isLocal).toBe(true)
|
||||
expect(result[0].isRemote).toBe(true)
|
||||
|
||||
expect(result[1].name).toBe("feat/a")
|
||||
expect(result[1].isLocal).toBe(true)
|
||||
expect(result[1].isRemote).toBe(false)
|
||||
|
||||
expect(result[2].name).toBe("feat/b")
|
||||
expect(result[2].isLocal).toBe(false)
|
||||
expect(result[2].isRemote).toBe(true)
|
||||
})
|
||||
|
||||
it("sorts default branch first", () => {
|
||||
const locals = new Set(["z-branch", "main"])
|
||||
const dates = new Map([
|
||||
["z-branch", "2099-01-01T00:00:00+00:00"],
|
||||
["main", "2020-01-01T00:00:00+00:00"],
|
||||
])
|
||||
const result = buildBranchList(locals, new Set(), dates, "main")
|
||||
expect(result[0].name).toBe("main")
|
||||
})
|
||||
|
||||
it("sorts by date descending after default", () => {
|
||||
const locals = new Set(["old", "new", "mid"])
|
||||
const dates = new Map([
|
||||
["old", "2020-01-01T00:00:00+00:00"],
|
||||
["new", "2025-01-01T00:00:00+00:00"],
|
||||
["mid", "2023-01-01T00:00:00+00:00"],
|
||||
])
|
||||
const result = buildBranchList(locals, new Set(), dates, "none")
|
||||
expect(result.map((b) => b.name)).toEqual(["new", "mid", "old"])
|
||||
})
|
||||
|
||||
it("handles empty inputs", () => {
|
||||
expect(buildBranchList(new Set(), new Set(), new Map(), "main")).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseWorktreeList
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("parseWorktreeList", () => {
|
||||
it("parses standard worktree entries", () => {
|
||||
const raw = [
|
||||
"worktree /home/user/repo",
|
||||
"HEAD abc123",
|
||||
"branch refs/heads/main",
|
||||
"",
|
||||
"worktree /home/user/repo/.worktrees/feat",
|
||||
"HEAD def456",
|
||||
"branch refs/heads/feat/cool",
|
||||
"",
|
||||
].join("\n")
|
||||
|
||||
const entries = parseWorktreeList(raw)
|
||||
expect(entries).toHaveLength(2)
|
||||
expect(entries[0]).toEqual({ path: "/home/user/repo", branch: "main", bare: false, detached: false })
|
||||
expect(entries[1]).toEqual({
|
||||
path: "/home/user/repo/.worktrees/feat",
|
||||
branch: "feat/cool",
|
||||
bare: false,
|
||||
detached: false,
|
||||
})
|
||||
})
|
||||
|
||||
it("detects bare worktree", () => {
|
||||
const raw = "worktree /home/user/repo\nHEAD abc\nbare\n\n"
|
||||
const entries = parseWorktreeList(raw)
|
||||
expect(entries[0].bare).toBe(true)
|
||||
})
|
||||
|
||||
it("detects detached HEAD", () => {
|
||||
const raw = "worktree /home/user/repo/.wt/fix\nHEAD abc123\ndetached\n\n"
|
||||
const entries = parseWorktreeList(raw)
|
||||
expect(entries[0].detached).toBe(true)
|
||||
expect(entries[0].branch).toBe("(detached)")
|
||||
})
|
||||
|
||||
it("handles empty output", () => {
|
||||
expect(parseWorktreeList("")).toEqual([])
|
||||
expect(parseWorktreeList("\n\n")).toEqual([])
|
||||
})
|
||||
|
||||
it("handles missing branch line with unknown", () => {
|
||||
const raw = "worktree /some/path\nHEAD abc\n\n"
|
||||
const entries = parseWorktreeList(raw)
|
||||
expect(entries[0].branch).toBe("unknown")
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// checkedOutBranchesFromWorktreeList
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("checkedOutBranchesFromWorktreeList", () => {
|
||||
it("returns branches from non-bare non-detached entries", () => {
|
||||
const raw = [
|
||||
"worktree /repo",
|
||||
"HEAD abc",
|
||||
"branch refs/heads/main",
|
||||
"",
|
||||
"worktree /repo/.wt/feat",
|
||||
"HEAD def",
|
||||
"branch refs/heads/feat/x",
|
||||
"",
|
||||
"worktree /repo/.wt/detached",
|
||||
"HEAD ghi",
|
||||
"detached",
|
||||
"",
|
||||
].join("\n")
|
||||
|
||||
const branches = checkedOutBranchesFromWorktreeList(raw)
|
||||
expect(branches.has("main")).toBe(true)
|
||||
expect(branches.has("feat/x")).toBe(true)
|
||||
expect(branches.has("(detached)")).toBe(false)
|
||||
expect(branches.size).toBe(2)
|
||||
})
|
||||
|
||||
it("excludes bare entries", () => {
|
||||
const raw = "worktree /repo\nHEAD abc\nbare\n\n"
|
||||
expect(checkedOutBranchesFromWorktreeList(raw).size).toBe(0)
|
||||
})
|
||||
|
||||
it("handles empty input", () => {
|
||||
expect(checkedOutBranchesFromWorktreeList("").size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// classifyPRError
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("classifyPRError", () => {
|
||||
it("detects PR not found", () => {
|
||||
expect(classifyPRError("GraphQL: Could not resolve to a PullRequest")).toBe("not_found")
|
||||
expect(classifyPRError("not found")).toBe("not_found")
|
||||
})
|
||||
|
||||
it("detects gh CLI missing", () => {
|
||||
expect(classifyPRError("gh: command not found")).toBe("gh_missing")
|
||||
expect(classifyPRError("spawn gh ENOENT")).toBe("gh_missing")
|
||||
expect(classifyPRError("'gh' is not recognized as an internal command")).toBe("gh_missing")
|
||||
})
|
||||
|
||||
it("detects gh auth issue", () => {
|
||||
expect(classifyPRError("not logged into any github hosts")).toBe("gh_auth")
|
||||
expect(classifyPRError("please run `gh auth login`")).toBe("gh_auth")
|
||||
})
|
||||
|
||||
it("returns unknown for unrecognized errors", () => {
|
||||
expect(classifyPRError("something went wrong")).toBe("unknown")
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// validateGitRef
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("validateGitRef", () => {
|
||||
it("accepts simple branch names", () => {
|
||||
expect(() => validateGitRef("main", "branch")).not.toThrow()
|
||||
expect(() => validateGitRef("feat/cool", "branch")).not.toThrow()
|
||||
expect(() => validateGitRef("fix-123", "branch")).not.toThrow()
|
||||
expect(() => validateGitRef("v1.2.3", "branch")).not.toThrow()
|
||||
})
|
||||
|
||||
it("accepts usernames with dots, hyphens, underscores", () => {
|
||||
expect(() => validateGitRef("some-user", "owner")).not.toThrow()
|
||||
expect(() => validateGitRef("user.name", "owner")).not.toThrow()
|
||||
expect(() => validateGitRef("user_name", "owner")).not.toThrow()
|
||||
})
|
||||
|
||||
it("rejects values starting with a dash (git flag injection)", () => {
|
||||
expect(() => validateGitRef("--upload-pack=evil", "ref")).toThrow('Unsafe ref: "--upload-pack=evil"')
|
||||
expect(() => validateGitRef("-b", "ref")).toThrow('Unsafe ref: "-b"')
|
||||
})
|
||||
|
||||
it("rejects empty strings", () => {
|
||||
expect(() => validateGitRef("", "ref")).toThrow('Unsafe ref: ""')
|
||||
})
|
||||
|
||||
it("rejects values with spaces", () => {
|
||||
expect(() => validateGitRef("bad name", "ref")).toThrow()
|
||||
})
|
||||
|
||||
it("rejects values with shell metacharacters", () => {
|
||||
expect(() => validateGitRef("$(whoami)", "ref")).toThrow()
|
||||
expect(() => validateGitRef("foo;rm -rf /", "ref")).toThrow()
|
||||
expect(() => validateGitRef("foo`id`", "ref")).toThrow()
|
||||
expect(() => validateGitRef("foo|bar", "ref")).toThrow()
|
||||
})
|
||||
|
||||
it("rejects values with newlines", () => {
|
||||
expect(() => validateGitRef("foo\nbar", "ref")).toThrow()
|
||||
})
|
||||
|
||||
it("rejects values containing .. (git ref traversal)", () => {
|
||||
expect(() => validateGitRef("foo/../bar", "ref")).toThrow()
|
||||
expect(() => validateGitRef("..hidden", "ref")).toThrow()
|
||||
})
|
||||
})
|
||||
@@ -545,3 +545,80 @@ describe("WorktreeManager.removeWorktree safety", () => {
|
||||
expect(exists).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WorktreeManager -- listBranches
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("WorktreeManager.listBranches", () => {
|
||||
it("returns the current branch", async () => {
|
||||
const root = await createTempRepo()
|
||||
const mgr = createManager(root)
|
||||
|
||||
const { branches, defaultBranch } = await mgr.listBranches()
|
||||
|
||||
const names = branches.map((b) => b.name)
|
||||
const git = simpleGit(root)
|
||||
const current = (await git.revparse(["--abbrev-ref", "HEAD"])).trim()
|
||||
expect(names).toContain(current)
|
||||
expect(defaultBranch).toBeTruthy()
|
||||
})
|
||||
|
||||
it("includes branches created after init", async () => {
|
||||
const root = await createTempRepo()
|
||||
const git = simpleGit(root)
|
||||
await git.branch(["feature-test"])
|
||||
|
||||
const mgr = createManager(root)
|
||||
const { branches } = await mgr.listBranches()
|
||||
|
||||
expect(branches.map((b) => b.name)).toContain("feature-test")
|
||||
})
|
||||
|
||||
it("marks local branches as isLocal", async () => {
|
||||
const root = await createTempRepo()
|
||||
const mgr = createManager(root)
|
||||
|
||||
const { branches } = await mgr.listBranches()
|
||||
for (const b of branches) {
|
||||
expect(b.isLocal).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WorktreeManager -- checkedOutBranches
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("WorktreeManager.checkedOutBranches", () => {
|
||||
it("includes the main branch", async () => {
|
||||
const root = await createTempRepo()
|
||||
const mgr = createManager(root)
|
||||
|
||||
const checked = await mgr.checkedOutBranches()
|
||||
const git = simpleGit(root)
|
||||
const current = (await git.revparse(["--abbrev-ref", "HEAD"])).trim()
|
||||
expect(checked.has(current)).toBe(true)
|
||||
})
|
||||
|
||||
it("includes worktree branches", async () => {
|
||||
const root = await createTempRepo()
|
||||
const mgr = createManager(root)
|
||||
|
||||
const wt = await mgr.createWorktree({ prompt: "checked-out-test" })
|
||||
const checked = await mgr.checkedOutBranches()
|
||||
|
||||
expect(checked.has(wt.branch)).toBe(true)
|
||||
})
|
||||
|
||||
it("excludes branches after worktree removal", async () => {
|
||||
const root = await createTempRepo()
|
||||
const mgr = createManager(root)
|
||||
|
||||
const wt = await mgr.createWorktree({ prompt: "removal-test" })
|
||||
await mgr.removeWorktree(wt.path)
|
||||
|
||||
const checked = await mgr.checkedOutBranches()
|
||||
expect(checked.has(wt.branch)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -19,10 +19,12 @@ import type {
|
||||
AgentManagerKeybindingsMessage,
|
||||
AgentManagerMultiVersionProgressMessage,
|
||||
AgentManagerSendInitialMessage,
|
||||
AgentManagerBranchInfo,
|
||||
AgentManagerBranchesMessage,
|
||||
AgentManagerImportResultMessage,
|
||||
WorktreeState,
|
||||
ManagedSessionState,
|
||||
SessionInfo,
|
||||
BranchInfo,
|
||||
} from "../src/types/messages"
|
||||
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
|
||||
import type { DragEvent } from "@thisbeyond/solid-dnd"
|
||||
@@ -35,13 +37,14 @@ import { CodeComponentProvider } from "@kilocode/kilo-ui/context/code"
|
||||
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 { Toast, showToast } 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, TooltipKeybind } from "@kilocode/kilo-ui/tooltip"
|
||||
import { Popover } from "@kilocode/kilo-ui/popover"
|
||||
import { HoverCard } from "@kilocode/kilo-ui/hover-card"
|
||||
import { VSCodeProvider, useVSCode } from "../src/context/vscode"
|
||||
import { ServerProvider } from "../src/context/server"
|
||||
@@ -657,6 +660,9 @@ const AgentManagerContent: Component = () => {
|
||||
setManagedSessions(state.sessions)
|
||||
if (state.isGitRepo !== undefined) setIsGitRepo(state.isGitRepo)
|
||||
if (!worktreesLoaded()) setWorktreesLoaded(true)
|
||||
// When not a git repo, also mark sessions as loaded since the Kilo
|
||||
// server won't connect to send the sessionsLoaded message.
|
||||
if (state.isGitRepo === false && !sessionsLoaded()) setSessionsLoaded(true)
|
||||
if (state.tabOrder) setWorktreeTabOrder(state.tabOrder)
|
||||
const current = session.currentSessionID()
|
||||
if (current) {
|
||||
@@ -1563,12 +1569,14 @@ const AgentManagerContent: Component = () => {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Advanced "New Worktree" dialog — prompt, versions, model, mode, advanced options
|
||||
// Advanced "New Worktree" dialog — prompt, versions, model, mode, import tab
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type VersionCount = 1 | 2 | 3 | 4
|
||||
const VERSION_OPTIONS: VersionCount[] = [1, 2, 3, 4]
|
||||
|
||||
type DialogTab = "new" | "import"
|
||||
|
||||
function sanitizeSegment(text: string, maxLength = 50): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
@@ -1591,20 +1599,19 @@ function sanitizeBranchName(name: string): string {
|
||||
.join("/")
|
||||
}
|
||||
|
||||
function formatRelativeTime(epoch: number): string {
|
||||
const diff = Math.floor(Date.now() / 1000) - epoch
|
||||
if (diff < 60) return "now"
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m`
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h`
|
||||
if (diff < 2592000) return `${Math.floor(diff / 86400)}d`
|
||||
if (diff < 31536000) return `${Math.floor(diff / 2592000)}mo`
|
||||
return `${Math.floor(diff / 31536000)}y`
|
||||
}
|
||||
|
||||
const NewWorktreeDialog: Component<{ onClose: () => void }> = (props) => {
|
||||
const vscode = useVSCode()
|
||||
const session = useSession()
|
||||
|
||||
const [tab, setTab] = createSignal<DialogTab>("new")
|
||||
|
||||
// --- Shared branch data (used by both New tab's base branch selector and Import tab) ---
|
||||
const [branches, setBranches] = createSignal<BranchInfo[]>([])
|
||||
const [branchesLoading, setBranchesLoading] = createSignal(false)
|
||||
const [defaultBranch, setDefaultBranch] = createSignal("main")
|
||||
const [branchSearch, setBranchSearch] = createSignal("")
|
||||
|
||||
// --- New tab state ---
|
||||
const [name, setName] = createSignal("")
|
||||
const [prompt, setPrompt] = createSignal("")
|
||||
const [versions, setVersions] = createSignal<VersionCount>(1)
|
||||
@@ -1614,9 +1621,6 @@ const NewWorktreeDialog: Component<{ onClose: () => void }> = (props) => {
|
||||
const [showAdvanced, setShowAdvanced] = createSignal(false)
|
||||
const [branchName, setBranchName] = createSignal("")
|
||||
const [baseBranch, setBaseBranch] = createSignal<string | null>(null)
|
||||
const [branches, setBranches] = createSignal<AgentManagerBranchInfo[]>([])
|
||||
const [defaultBranch, setDefaultBranch] = createSignal("main")
|
||||
const [branchSearch, setBranchSearch] = createSignal("")
|
||||
const [baseBranchOpen, setBaseBranchOpen] = createSignal(false)
|
||||
const [highlightedIndex, setHighlightedIndex] = createSignal(0)
|
||||
|
||||
@@ -1628,20 +1632,10 @@ const NewWorktreeDialog: Component<{ onClose: () => void }> = (props) => {
|
||||
textareaRef.focus()
|
||||
textareaRef.select()
|
||||
})
|
||||
setBranchesLoading(true)
|
||||
vscode.postMessage({ type: "agentManager.requestBranches" })
|
||||
})
|
||||
|
||||
// Listen for branch data
|
||||
const handler = (e: MessageEvent) => {
|
||||
const msg = e.data as ExtensionMessage
|
||||
if (msg.type === "agentManager.branches") {
|
||||
setBranches(msg.branches)
|
||||
setDefaultBranch(msg.defaultBranch)
|
||||
}
|
||||
}
|
||||
window.addEventListener("message", handler)
|
||||
onCleanup(() => window.removeEventListener("message", handler))
|
||||
|
||||
const effectiveBaseBranch = () => baseBranch() ?? defaultBranch()
|
||||
|
||||
const filteredBranches = createMemo(() => {
|
||||
@@ -1692,214 +1686,399 @@ const NewWorktreeDialog: Component<{ onClose: () => void }> = (props) => {
|
||||
textareaRef.style.height = `${Math.min(textareaRef.scrollHeight, 200)}px`
|
||||
}
|
||||
|
||||
// --- Import tab state ---
|
||||
const [prUrl, setPrUrl] = createSignal("")
|
||||
const [prPending, setPrPending] = createSignal(false)
|
||||
const [branchOpen, setBranchOpen] = createSignal(false)
|
||||
const [importPending, setImportPending] = createSignal(false)
|
||||
|
||||
const isPending = () => prPending() || importPending()
|
||||
|
||||
// Listen for branch data + import results
|
||||
const importUnsub = vscode.onMessage((msg) => {
|
||||
if (msg.type === "agentManager.branches") {
|
||||
const ev = msg as AgentManagerBranchesMessage
|
||||
setBranches(ev.branches)
|
||||
setDefaultBranch(ev.defaultBranch)
|
||||
setBranchesLoading(false)
|
||||
}
|
||||
if (msg.type === "agentManager.importResult") {
|
||||
const ev = msg as AgentManagerImportResultMessage
|
||||
setPrPending(false)
|
||||
setImportPending(false)
|
||||
if (ev.success) {
|
||||
props.onClose()
|
||||
} else {
|
||||
showToast({ variant: "error", title: "Import failed", description: ev.message })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
onCleanup(() => importUnsub())
|
||||
|
||||
const handlePRSubmit = () => {
|
||||
const url = prUrl().trim()
|
||||
if (!url || isPending()) return
|
||||
setPrPending(true)
|
||||
vscode.postMessage({ type: "agentManager.importFromPR", url })
|
||||
}
|
||||
|
||||
const handleBranchSelect = (name: string) => {
|
||||
if (isPending()) return
|
||||
setImportPending(true)
|
||||
setBranchOpen(false)
|
||||
setBranchSearch("")
|
||||
vscode.postMessage({ type: "agentManager.importFromBranch", branch: name })
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog title="New Worktree" fit>
|
||||
<div class="am-nv-dialog" onKeyDown={handleKeyDown}>
|
||||
{/* Optional worktree name */}
|
||||
<input
|
||||
class="am-nv-name-input"
|
||||
placeholder="Worktree name (optional)"
|
||||
value={name()}
|
||||
onInput={(e) => setName(e.currentTarget.value)}
|
||||
/>
|
||||
|
||||
{/* Prompt input — reuses the sidebar chat-input base classes for consistent styling */}
|
||||
<div class="prompt-input-container am-prompt-input-container">
|
||||
<div class="prompt-input-wrapper am-prompt-input-wrapper">
|
||||
<div class="prompt-input-ghost-wrapper am-prompt-input-ghost-wrapper">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
class="prompt-input am-prompt-input"
|
||||
placeholder={`Type a message (${isMac ? "\u2318" : "Ctrl+"}Enter to send)`}
|
||||
value={prompt()}
|
||||
onInput={(e) => {
|
||||
setPrompt(e.currentTarget.value)
|
||||
adjustHeight()
|
||||
}}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="prompt-input-hint">
|
||||
<div class="prompt-input-hint-selectors">
|
||||
<ModelSelectorBase
|
||||
value={model()}
|
||||
onSelect={(pid, mid) => setModel(pid && mid ? { providerID: pid, modelID: mid } : null)}
|
||||
placement="top-start"
|
||||
allowClear
|
||||
clearLabel="Default"
|
||||
/>
|
||||
<Show when={session.agents().length > 1}>
|
||||
<ModeSwitcherBase agents={session.agents()} value={agent()} onSelect={setAgent} />
|
||||
</Show>
|
||||
</div>
|
||||
<div class="prompt-input-hint-actions" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Advanced options toggle */}
|
||||
<button class="am-advanced-toggle" onClick={() => setShowAdvanced(!showAdvanced())} type="button">
|
||||
<Icon name={showAdvanced() ? "chevron-down" : "chevron-right"} size="small" />
|
||||
<span>Advanced options</span>
|
||||
<Dialog title="Open Workspace" fit>
|
||||
{/* Tab switcher */}
|
||||
<div class="am-tab-switcher">
|
||||
<button
|
||||
class="am-tab-switcher-pill"
|
||||
classList={{ "am-tab-switcher-pill-active": tab() === "new" }}
|
||||
onClick={() => setTab("new")}
|
||||
type="button"
|
||||
>
|
||||
New
|
||||
</button>
|
||||
<button
|
||||
class="am-tab-switcher-pill"
|
||||
classList={{ "am-tab-switcher-pill-active": tab() === "import" }}
|
||||
onClick={() => setTab("import")}
|
||||
type="button"
|
||||
>
|
||||
Import
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Show when={showAdvanced()}>
|
||||
<div class="am-advanced-section">
|
||||
<div class="am-advanced-field">
|
||||
<span class="am-nv-config-label">Branch name</span>
|
||||
<input
|
||||
class="am-advanced-input"
|
||||
type="text"
|
||||
placeholder="auto-generated"
|
||||
value={branchName()}
|
||||
onInput={(e) => setBranchName(sanitizeBranchName(e.currentTarget.value))}
|
||||
/>
|
||||
{/* New tab */}
|
||||
<Show when={tab() === "new"}>
|
||||
<div class="am-nv-dialog" onKeyDown={handleKeyDown}>
|
||||
<input
|
||||
class="am-nv-name-input"
|
||||
placeholder="Worktree name (optional)"
|
||||
value={name()}
|
||||
onInput={(e) => setName(e.currentTarget.value)}
|
||||
/>
|
||||
{/* Prompt input — reuses the sidebar chat-input base classes for consistent styling */}
|
||||
<div class="prompt-input-container am-prompt-input-container">
|
||||
<div class="prompt-input-wrapper am-prompt-input-wrapper">
|
||||
<div class="prompt-input-ghost-wrapper am-prompt-input-ghost-wrapper">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
class="prompt-input am-prompt-input"
|
||||
placeholder={`Type a message (${isMac ? "\u2318" : "Ctrl+"}Enter to send)`}
|
||||
value={prompt()}
|
||||
onInput={(e) => {
|
||||
setPrompt(e.currentTarget.value)
|
||||
adjustHeight()
|
||||
}}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="am-advanced-field">
|
||||
<span class="am-nv-config-label">Base branch</span>
|
||||
<div class="am-branch-selector-wrapper">
|
||||
<button
|
||||
class="am-branch-selector-trigger"
|
||||
onClick={() => setBaseBranchOpen(!baseBranchOpen())}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="branch" size="small" />
|
||||
<span class="am-branch-selector-value">{effectiveBaseBranch()}</span>
|
||||
<Show when={!baseBranch()}>
|
||||
<span class="am-branch-badge">default</span>
|
||||
</Show>
|
||||
<Icon name="selector" size="small" />
|
||||
</button>
|
||||
<Show when={baseBranchOpen()}>
|
||||
<div class="am-branch-dropdown" onWheel={(e) => e.stopPropagation()}>
|
||||
<div class="am-branch-search">
|
||||
<Icon name="magnifying-glass" size="small" />
|
||||
<input
|
||||
class="am-branch-search-input"
|
||||
type="text"
|
||||
placeholder="Search branches..."
|
||||
value={branchSearch()}
|
||||
ref={(el) => requestAnimationFrame(() => el.focus())}
|
||||
onInput={(e) => {
|
||||
setBranchSearch(e.currentTarget.value)
|
||||
setHighlightedIndex(0)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
const items = filteredBranches()
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const next = Math.min(highlightedIndex() + 1, items.length - 1)
|
||||
setHighlightedIndex(next)
|
||||
requestAnimationFrame(() => {
|
||||
document
|
||||
.querySelector(`.am-branch-item[data-index="${next}"]`)
|
||||
?.scrollIntoView({ block: "nearest" })
|
||||
})
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const prev = Math.max(highlightedIndex() - 1, 0)
|
||||
setHighlightedIndex(prev)
|
||||
requestAnimationFrame(() => {
|
||||
document
|
||||
.querySelector(`.am-branch-item[data-index="${prev}"]`)
|
||||
?.scrollIntoView({ block: "nearest" })
|
||||
})
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const selected = items[highlightedIndex()]
|
||||
if (selected) {
|
||||
setBaseBranch(selected.name)
|
||||
<div class="prompt-input-hint">
|
||||
<div class="prompt-input-hint-selectors">
|
||||
<ModelSelectorBase
|
||||
value={model()}
|
||||
onSelect={(pid, mid) => setModel(pid && mid ? { providerID: pid, modelID: mid } : null)}
|
||||
placement="top-start"
|
||||
allowClear
|
||||
clearLabel="Default"
|
||||
/>
|
||||
<Show when={session.agents().length > 1}>
|
||||
<ModeSwitcherBase agents={session.agents()} value={agent()} onSelect={setAgent} />
|
||||
</Show>
|
||||
</div>
|
||||
<div class="prompt-input-hint-actions" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Advanced options toggle */}
|
||||
<button class="am-advanced-toggle" onClick={() => setShowAdvanced(!showAdvanced())} type="button">
|
||||
<Icon name={showAdvanced() ? "chevron-down" : "chevron-right"} size="small" />
|
||||
<span>Advanced options</span>
|
||||
</button>
|
||||
|
||||
<Show when={showAdvanced()}>
|
||||
<div class="am-advanced-section">
|
||||
<div class="am-advanced-field">
|
||||
<span class="am-nv-config-label">Branch name</span>
|
||||
<input
|
||||
class="am-advanced-input"
|
||||
type="text"
|
||||
placeholder="auto-generated"
|
||||
value={branchName()}
|
||||
onInput={(e) => setBranchName(sanitizeBranchName(e.currentTarget.value))}
|
||||
/>
|
||||
</div>
|
||||
<div class="am-advanced-field">
|
||||
<span class="am-nv-config-label">Base branch</span>
|
||||
<div class="am-branch-selector-wrapper">
|
||||
<button
|
||||
class="am-branch-selector-trigger"
|
||||
onClick={() => setBaseBranchOpen(!baseBranchOpen())}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="branch" size="small" />
|
||||
<span class="am-branch-selector-value">{effectiveBaseBranch()}</span>
|
||||
<Show when={!baseBranch()}>
|
||||
<span class="am-branch-badge">default</span>
|
||||
</Show>
|
||||
<Icon name="selector" size="small" />
|
||||
</button>
|
||||
<Show when={baseBranchOpen()}>
|
||||
<div class="am-branch-dropdown" onWheel={(e) => e.stopPropagation()}>
|
||||
<div class="am-branch-search">
|
||||
<Icon name="magnifying-glass" size="small" />
|
||||
<input
|
||||
class="am-branch-search-input"
|
||||
type="text"
|
||||
placeholder="Search branches..."
|
||||
value={branchSearch()}
|
||||
ref={(el) => requestAnimationFrame(() => el.focus())}
|
||||
onInput={(e) => {
|
||||
setBranchSearch(e.currentTarget.value)
|
||||
setHighlightedIndex(0)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
const items = filteredBranches()
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const next = Math.min(highlightedIndex() + 1, items.length - 1)
|
||||
setHighlightedIndex(next)
|
||||
requestAnimationFrame(() => {
|
||||
document
|
||||
.querySelector(`.am-branch-item[data-index="${next}"]`)
|
||||
?.scrollIntoView({ block: "nearest" })
|
||||
})
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const prev = Math.max(highlightedIndex() - 1, 0)
|
||||
setHighlightedIndex(prev)
|
||||
requestAnimationFrame(() => {
|
||||
document
|
||||
.querySelector(`.am-branch-item[data-index="${prev}"]`)
|
||||
?.scrollIntoView({ block: "nearest" })
|
||||
})
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const selected = items[highlightedIndex()]
|
||||
if (selected) {
|
||||
setBaseBranch(selected.name)
|
||||
setBaseBranchOpen(false)
|
||||
setBranchSearch("")
|
||||
setHighlightedIndex(0)
|
||||
}
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setBaseBranchOpen(false)
|
||||
setBranchSearch("")
|
||||
setHighlightedIndex(0)
|
||||
}
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setBaseBranchOpen(false)
|
||||
setBranchSearch("")
|
||||
setHighlightedIndex(0)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div class="am-branch-list">
|
||||
<For each={filteredBranches()}>
|
||||
{(branch, index) => (
|
||||
<button
|
||||
class="am-branch-item"
|
||||
classList={{
|
||||
"am-branch-item-active": effectiveBaseBranch() === branch.name,
|
||||
"am-branch-item-highlighted": highlightedIndex() === index(),
|
||||
}}
|
||||
data-index={index()}
|
||||
onClick={() => {
|
||||
setBaseBranch(branch.name)
|
||||
setBaseBranchOpen(false)
|
||||
setBranchSearch("")
|
||||
setHighlightedIndex(0)
|
||||
}}
|
||||
onMouseEnter={() => setHighlightedIndex(index())}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="branch" size="small" />
|
||||
<span class="am-branch-item-name">{branch.name}</span>
|
||||
<Show when={branch.isDefault}>
|
||||
<span class="am-branch-badge">default</span>
|
||||
</Show>
|
||||
<Show when={!branch.isLocal && branch.isRemote}>
|
||||
<span class="am-branch-badge am-branch-badge-remote">remote</span>
|
||||
</Show>
|
||||
<Show when={branch.lastCommitDate}>
|
||||
<span class="am-branch-item-time">{formatRelativeDate(branch.lastCommitDate!)}</span>
|
||||
</Show>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
<div class="am-branch-list">
|
||||
<For each={filteredBranches()}>
|
||||
{(branch, index) => (
|
||||
<button
|
||||
class="am-branch-item"
|
||||
classList={{
|
||||
"am-branch-item-active": effectiveBaseBranch() === branch.name,
|
||||
"am-branch-item-highlighted": highlightedIndex() === index(),
|
||||
}}
|
||||
data-index={index()}
|
||||
onClick={() => {
|
||||
setBaseBranch(branch.name)
|
||||
setBaseBranchOpen(false)
|
||||
setBranchSearch("")
|
||||
setHighlightedIndex(0)
|
||||
}}
|
||||
onMouseEnter={() => setHighlightedIndex(index())}
|
||||
type="button"
|
||||
>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Version selector + info */}
|
||||
<div class="am-nv-version-bar">
|
||||
<span class="am-nv-config-label">Versions</span>
|
||||
<div class="am-nv-pills">
|
||||
{VERSION_OPTIONS.map((count) => (
|
||||
<button
|
||||
class="am-nv-pill"
|
||||
classList={{ "am-nv-pill-active": versions() === count }}
|
||||
onClick={() => setVersions(count)}
|
||||
type="button"
|
||||
>
|
||||
{count}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Show when={versions() > 1}>
|
||||
<span class="am-nv-version-hint">{versions()} worktrees will run in parallel</span>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* Submit button */}
|
||||
<Button variant="primary" size="large" class="am-nv-submit" onClick={handleSubmit} disabled={!canSubmit()}>
|
||||
<Show
|
||||
when={!starting()}
|
||||
fallback={
|
||||
<>
|
||||
<Spinner class="am-nv-spinner" />
|
||||
<span>Creating...</span>
|
||||
</>
|
||||
}
|
||||
>
|
||||
Create Workspace
|
||||
</Show>
|
||||
</Button>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Import tab */}
|
||||
<Show when={tab() === "import"}>
|
||||
<div class="am-import-tab">
|
||||
{/* Pull Request section */}
|
||||
<div class="am-import-section">
|
||||
<span class="am-nv-config-label">Pull Request</span>
|
||||
<div class="am-pr-row">
|
||||
<div class="am-pr-input-wrapper">
|
||||
<Icon name="branch" size="small" />
|
||||
<input
|
||||
class="am-pr-input"
|
||||
type="text"
|
||||
placeholder="Paste PR URL..."
|
||||
value={prUrl()}
|
||||
onInput={(e) => setPrUrl(e.currentTarget.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
handlePRSubmit()
|
||||
}
|
||||
}}
|
||||
disabled={isPending()}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
onClick={handlePRSubmit}
|
||||
disabled={!prUrl().trim() || isPending()}
|
||||
>
|
||||
<Show when={prPending()} fallback="Open">
|
||||
<Spinner class="am-nv-spinner" />
|
||||
</Show>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="am-import-divider" />
|
||||
|
||||
{/* Branches section */}
|
||||
<div class="am-import-section">
|
||||
<span class="am-nv-config-label">Branches</span>
|
||||
<div class="am-selector-wrapper">
|
||||
<Popover
|
||||
open={branchOpen()}
|
||||
onOpenChange={setBranchOpen}
|
||||
placement="bottom-start"
|
||||
sameWidth
|
||||
class="am-dropdown"
|
||||
trigger={
|
||||
<button class="am-selector-trigger" disabled={isPending()} type="button">
|
||||
<span class="am-selector-left">
|
||||
<Icon name="branch" size="small" />
|
||||
<span class="am-selector-value">{branchesLoading() ? "Loading..." : "Select branch..."}</span>
|
||||
</span>
|
||||
<span class="am-selector-right">
|
||||
<Icon name="selector" size="small" />
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<div class="am-dropdown-search">
|
||||
<Icon name="magnifying-glass" size="small" />
|
||||
<input
|
||||
class="am-dropdown-search-input"
|
||||
type="text"
|
||||
placeholder="Search branches..."
|
||||
value={branchSearch()}
|
||||
onInput={(e) => setBranchSearch(e.currentTarget.value)}
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
<div class="am-dropdown-list">
|
||||
<Show
|
||||
when={filteredBranches().length > 0}
|
||||
fallback={
|
||||
<div class="am-dropdown-empty">
|
||||
{branchesLoading() ? "Loading branches..." : "No matching branches"}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<For each={filteredBranches()}>
|
||||
{(branch) => (
|
||||
<div class="am-branch-item" onClick={() => handleBranchSelect(branch.name)}>
|
||||
<span class="am-branch-item-left">
|
||||
<Icon name="branch" size="small" />
|
||||
<span class="am-branch-item-name">{branch.name}</span>
|
||||
<Show when={branch.isDefault}>
|
||||
<span class="am-branch-badge">default</span>
|
||||
</Show>
|
||||
<Show when={!branch.isLocal && branch.isRemote}>
|
||||
<span class="am-branch-badge am-branch-badge-remote">remote</span>
|
||||
<span class="am-branch-badge">remote</span>
|
||||
</Show>
|
||||
<span class="am-branch-item-time">{formatRelativeTime(branch.lastCommitDate)}</span>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</span>
|
||||
<Show when={branch.lastCommitDate}>
|
||||
<span class="am-branch-item-time">{formatRelativeDate(branch.lastCommitDate!)}</span>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Version selector + info */}
|
||||
<div class="am-nv-version-bar">
|
||||
<span class="am-nv-config-label">Versions</span>
|
||||
<div class="am-nv-pills">
|
||||
{VERSION_OPTIONS.map((count) => (
|
||||
<button
|
||||
class="am-nv-pill"
|
||||
classList={{ "am-nv-pill-active": versions() === count }}
|
||||
onClick={() => setVersions(count)}
|
||||
type="button"
|
||||
>
|
||||
{count}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Show when={versions() > 1}>
|
||||
<span class="am-nv-version-hint">{versions()} worktrees will run in parallel</span>
|
||||
{/* Empty state when no branches are available */}
|
||||
<Show when={!branchesLoading() && branches().length === 0}>
|
||||
<div class="am-import-empty">
|
||||
No branches found.
|
||||
<br />
|
||||
Paste a PR URL above or create a new worktree.
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* Submit button */}
|
||||
<Button variant="primary" size="large" class="am-nv-submit" onClick={handleSubmit} disabled={!canSubmit()}>
|
||||
<Show
|
||||
when={!starting()}
|
||||
fallback={
|
||||
<>
|
||||
<Spinner class="am-nv-spinner" />
|
||||
<span>Creating...</span>
|
||||
</>
|
||||
}
|
||||
>
|
||||
Create Workspace
|
||||
</Show>
|
||||
</Button>
|
||||
</div>
|
||||
</Show>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1387,3 +1387,294 @@ button.am-section-toggle:hover .am-section-label {
|
||||
.am-skeleton-session:nth-child(3) .am-skeleton-session-time {
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
|
||||
/* Tab switcher for New/Import tabs in the dialog */
|
||||
|
||||
.am-tab-switcher {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
padding: 0 20px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.am-tab-switcher-pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px 14px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--text-weak);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 100ms;
|
||||
}
|
||||
|
||||
.am-tab-switcher-pill:hover {
|
||||
background: var(--surface-inset-base-hover);
|
||||
color: var(--text-base);
|
||||
}
|
||||
|
||||
.am-tab-switcher-pill-active {
|
||||
background: var(--surface-interactive-base) !important;
|
||||
color: var(--text-on-interactive-base) !important;
|
||||
}
|
||||
|
||||
/* Import tab layout */
|
||||
|
||||
.am-import-tab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 0 16px 16px;
|
||||
min-width: 460px;
|
||||
max-height: 350px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.am-import-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.am-import-divider {
|
||||
height: 1px;
|
||||
background: var(--border-weak-base);
|
||||
}
|
||||
|
||||
/* PR URL input row */
|
||||
|
||||
.am-pr-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.am-pr-input-wrapper {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.am-pr-input-wrapper [data-component="icon"] {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--text-weaker);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.am-pr-input {
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
padding: 0 12px 0 32px;
|
||||
border: 1px solid var(--border-weak-base);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--vscode-input-background, var(--surface-inset-base));
|
||||
color: var(--vscode-input-foreground, var(--text-base));
|
||||
font-size: var(--font-size-base);
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.am-pr-input:focus {
|
||||
border-color: var(--border-focus, #007fd4);
|
||||
}
|
||||
|
||||
.am-pr-input::placeholder {
|
||||
color: var(--vscode-input-placeholderForeground, var(--text-weaker));
|
||||
}
|
||||
|
||||
.am-pr-input:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Branch/worktree selector — popover trigger must be full-width */
|
||||
|
||||
.am-selector-wrapper [data-slot="popover-trigger"] {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.am-selector-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--border-weak-base);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--vscode-input-background, var(--surface-inset-base));
|
||||
color: var(--text-base);
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: 400;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.am-selector-trigger:hover {
|
||||
border-color: var(--text-weaker);
|
||||
}
|
||||
|
||||
.am-selector-trigger:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.am-selector-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
flex-shrink: 1;
|
||||
}
|
||||
|
||||
.am-selector-left [data-component="icon"] {
|
||||
color: var(--text-weaker);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.am-selector-value {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--text-weaker);
|
||||
}
|
||||
|
||||
.am-selector-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.am-selector-right [data-component="icon"] {
|
||||
color: var(--text-weaker);
|
||||
}
|
||||
|
||||
/* Dropdown popover — must override kilo-ui defaults for max-width and body padding */
|
||||
|
||||
.am-dropdown[data-component="popover-content"] {
|
||||
max-width: none;
|
||||
width: var(--kb-popper-anchor-width);
|
||||
}
|
||||
|
||||
.am-dropdown [data-slot="popover-body"] {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.am-dropdown-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
border-bottom: 1px solid var(--border-weak-base);
|
||||
}
|
||||
|
||||
.am-dropdown-search [data-component="icon"] {
|
||||
color: var(--text-weaker);
|
||||
flex-shrink: 0;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.am-dropdown-search-input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--vscode-input-foreground, var(--text-base));
|
||||
font-size: var(--font-size-base);
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.am-dropdown-search-input::placeholder {
|
||||
color: var(--vscode-input-placeholderForeground, var(--text-weaker));
|
||||
}
|
||||
|
||||
.am-dropdown-list {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
/* Branch item in dropdown */
|
||||
|
||||
.am-branch-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: var(--font-size-base);
|
||||
}
|
||||
|
||||
.am-branch-item:hover {
|
||||
background: var(--surface-inset-base-hover);
|
||||
}
|
||||
|
||||
.am-branch-item-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.am-branch-item-left [data-component="icon"] {
|
||||
color: var(--text-weaker);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.am-branch-item-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.am-branch-item-time {
|
||||
font-size: 11px;
|
||||
color: var(--text-weaker);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.am-branch-badge {
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
background: var(--surface-inset-base);
|
||||
color: var(--text-weaker);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.am-dropdown-empty {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: var(--text-weaker);
|
||||
}
|
||||
|
||||
/* Import empty state */
|
||||
|
||||
.am-import-empty {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: var(--text-weaker);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@@ -649,28 +649,44 @@ export interface AgentManagerMultiVersionProgressMessage {
|
||||
groupId?: string
|
||||
}
|
||||
|
||||
// Branch info for branch selector (extension → webview)
|
||||
export interface AgentManagerBranchInfo {
|
||||
name: string
|
||||
isLocal: boolean
|
||||
isRemote: boolean
|
||||
lastCommitDate: number
|
||||
isDefault: boolean
|
||||
}
|
||||
|
||||
// Branch list response (extension → webview)
|
||||
export interface AgentManagerBranchesMessage {
|
||||
type: "agentManager.branches"
|
||||
branches: AgentManagerBranchInfo[]
|
||||
defaultBranch: string
|
||||
}
|
||||
|
||||
// Stored variant selections loaded from extension globalState (extension → webview)
|
||||
export interface VariantsLoadedMessage {
|
||||
type: "variantsLoaded"
|
||||
variants: Record<string, string>
|
||||
}
|
||||
|
||||
export interface BranchInfo {
|
||||
name: string
|
||||
isLocal: boolean
|
||||
isRemote: boolean
|
||||
isDefault: boolean
|
||||
lastCommitDate?: string
|
||||
}
|
||||
|
||||
export interface AgentManagerBranchesMessage {
|
||||
type: "agentManager.branches"
|
||||
branches: BranchInfo[]
|
||||
defaultBranch: string
|
||||
}
|
||||
|
||||
// Agent Manager Import tab: external worktrees (extension → webview)
|
||||
export interface ExternalWorktreeInfo {
|
||||
path: string
|
||||
branch: string
|
||||
}
|
||||
|
||||
export interface AgentManagerExternalWorktreesMessage {
|
||||
type: "agentManager.externalWorktrees"
|
||||
worktrees: ExternalWorktreeInfo[]
|
||||
}
|
||||
|
||||
// Agent Manager Import tab: result feedback (extension → webview)
|
||||
export interface AgentManagerImportResultMessage {
|
||||
type: "agentManager.importResult"
|
||||
success: boolean
|
||||
message: string
|
||||
}
|
||||
|
||||
// Request webview to send initial prompt to a newly created session (extension → webview)
|
||||
export interface AgentManagerSendInitialMessage {
|
||||
type: "agentManager.sendInitialMessage"
|
||||
@@ -733,6 +749,9 @@ export type ExtensionMessage =
|
||||
| CloudSessionDataLoadedMessage
|
||||
| CloudSessionImportedMessage
|
||||
| CloudSessionImportFailedMessage
|
||||
| AgentManagerBranchesMessage
|
||||
| AgentManagerExternalWorktreesMessage
|
||||
| AgentManagerImportResultMessage
|
||||
|
||||
// ============================================
|
||||
// Messages FROM webview TO extension
|
||||
@@ -1048,11 +1067,33 @@ export interface SetSessionsCollapsedRequest {
|
||||
collapsed: boolean
|
||||
}
|
||||
|
||||
// Request branch list for base branch selector
|
||||
export interface RequestBranchesMessage {
|
||||
type: "agentManager.requestBranches"
|
||||
}
|
||||
|
||||
export interface RequestExternalWorktreesMessage {
|
||||
type: "agentManager.requestExternalWorktrees"
|
||||
}
|
||||
|
||||
export interface ImportFromBranchRequest {
|
||||
type: "agentManager.importFromBranch"
|
||||
branch: string
|
||||
}
|
||||
|
||||
export interface ImportFromPRRequest {
|
||||
type: "agentManager.importFromPR"
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface ImportExternalWorktreeRequest {
|
||||
type: "agentManager.importExternalWorktree"
|
||||
path: string
|
||||
branch: string
|
||||
}
|
||||
|
||||
export interface ImportAllExternalWorktreesRequest {
|
||||
type: "agentManager.importAllExternalWorktrees"
|
||||
}
|
||||
// Variant persistence (webview → extension)
|
||||
export interface PersistVariantRequest {
|
||||
type: "persistVariant"
|
||||
@@ -1124,6 +1165,11 @@ export type WebviewMessage =
|
||||
| RequestCloudSessionDataMessage
|
||||
| ImportAndSendMessage
|
||||
| RequestBranchesMessage
|
||||
| RequestExternalWorktreesMessage
|
||||
| ImportFromBranchRequest
|
||||
| ImportFromPRRequest
|
||||
| ImportExternalWorktreeRequest
|
||||
| ImportAllExternalWorktreesRequest
|
||||
|
||||
// ============================================
|
||||
// VS Code API type
|
||||
|
||||
@@ -6,9 +6,9 @@ export interface EditorContext {
|
||||
timezone?: string
|
||||
}
|
||||
|
||||
function formatTime(timezone?: string): string[] {
|
||||
function formatDate(timezone?: string): string[] {
|
||||
const now = new Date()
|
||||
const lines = [` Current time: ${now.toISOString()}`]
|
||||
const lines = [` Today's date: ${now.toDateString()}`]
|
||||
if (timezone) {
|
||||
const offset = -now.getTimezoneOffset()
|
||||
const sign = offset >= 0 ? "+" : "-"
|
||||
@@ -24,7 +24,7 @@ function formatTime(timezone?: string): string[] {
|
||||
* Returns an array of pre-formatted ` key: value` strings.
|
||||
*/
|
||||
export function editorContextEnvLines(ctx?: EditorContext): string[] {
|
||||
const lines = formatTime(ctx?.timezone)
|
||||
const lines = formatDate(ctx?.timezone)
|
||||
if (ctx?.shell) {
|
||||
lines.push(` Default shell: ${ctx.shell}`)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user