feat(worktrees): refine worktree selector UX
This commit is contained in:
@@ -101,7 +101,7 @@ export async function listWorktrees(params: {
|
|||||||
const records = parseWorktreePorcelain(result.stdout)
|
const records = parseWorktreePorcelain(result.stdout)
|
||||||
|
|
||||||
const worktrees: WorktreeDescriptor[] = [rootDescriptor]
|
const worktrees: WorktreeDescriptor[] = [rootDescriptor]
|
||||||
const slugCounts = new Map<string, number>([["root", 1]])
|
const seen = new Set<string>(["root"])
|
||||||
|
|
||||||
const normalizeSlug = (record: { branch?: string; head?: string; detached?: boolean; worktree: string }): string => {
|
const normalizeSlug = (record: { branch?: string; head?: string; detached?: boolean; worktree: string }): string => {
|
||||||
const branch = (record.branch ?? "").trim()
|
const branch = (record.branch ?? "").trim()
|
||||||
@@ -117,14 +117,6 @@ export async function listWorktrees(params: {
|
|||||||
return base ? `worktree-${base}` : "worktree"
|
return base ? `worktree-${base}` : "worktree"
|
||||||
}
|
}
|
||||||
|
|
||||||
const uniquify = (slug: string): string => {
|
|
||||||
const count = slugCounts.get(slug) ?? 0
|
|
||||||
slugCounts.set(slug, count + 1)
|
|
||||||
if (count === 0) {
|
|
||||||
return slug
|
|
||||||
}
|
|
||||||
return `${slug}@${count + 1}`
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const record of records) {
|
for (const record of records) {
|
||||||
const abs = record.worktree
|
const abs = record.worktree
|
||||||
@@ -135,13 +127,14 @@ export async function listWorktrees(params: {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const slug = uniquify(normalizeSlug(record))
|
const slug = normalizeSlug(record)
|
||||||
// Never emit a worktree slug that collides with our reserved root slug.
|
if (!slug || slug === "root") {
|
||||||
if (slug === "root") {
|
|
||||||
worktrees.push({ slug: uniquify("root-worktree"), directory: abs, kind: "worktree", branch: record.branch })
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if (seen.has(slug)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen.add(slug)
|
||||||
worktrees.push({ slug, directory: abs, kind: "worktree", branch: record.branch })
|
worktrees.push({ slug, directory: abs, kind: "worktree", branch: record.branch })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import {
|
|||||||
} from "solid-js"
|
} from "solid-js"
|
||||||
import type { ToolState } from "@opencode-ai/sdk"
|
import type { ToolState } from "@opencode-ai/sdk"
|
||||||
import { Accordion } from "@kobalte/core"
|
import { Accordion } from "@kobalte/core"
|
||||||
import { Dialog } from "@kobalte/core/dialog"
|
|
||||||
import { ChevronDown, Search, TerminalSquare, Trash2, XOctagon } from "lucide-solid"
|
import { ChevronDown, Search, TerminalSquare, Trash2, XOctagon } from "lucide-solid"
|
||||||
import AppBar from "@suid/material/AppBar"
|
import AppBar from "@suid/material/AppBar"
|
||||||
import Box from "@suid/material/Box"
|
import Box from "@suid/material/Box"
|
||||||
@@ -65,17 +64,7 @@ import { formatTokenTotal } from "../../lib/formatters"
|
|||||||
import { sseManager } from "../../lib/sse-manager"
|
import { sseManager } from "../../lib/sse-manager"
|
||||||
import { getLogger } from "../../lib/logger"
|
import { getLogger } from "../../lib/logger"
|
||||||
import { serverApi } from "../../lib/api-client"
|
import { serverApi } from "../../lib/api-client"
|
||||||
import { showToastNotification } from "../../lib/notifications"
|
import WorktreeSelector from "../worktree-selector"
|
||||||
import {
|
|
||||||
createWorktree,
|
|
||||||
deleteWorktree,
|
|
||||||
getParentSessionId,
|
|
||||||
getWorktreeSlugForParentSession,
|
|
||||||
getWorktrees,
|
|
||||||
reloadWorktrees,
|
|
||||||
reloadWorktreeMap,
|
|
||||||
setWorktreeSlugForParentSession,
|
|
||||||
} from "../../stores/worktrees"
|
|
||||||
import { getBackgroundProcesses, loadBackgroundProcesses } from "../../stores/background-processes"
|
import { getBackgroundProcesses, loadBackgroundProcesses } from "../../stores/background-processes"
|
||||||
import { BackgroundProcessOutputDialog } from "../background-process-output-dialog"
|
import { BackgroundProcessOutputDialog } from "../background-process-output-dialog"
|
||||||
import { useI18n } from "../../lib/i18n"
|
import { useI18n } from "../../lib/i18n"
|
||||||
@@ -87,9 +76,6 @@ import {
|
|||||||
|
|
||||||
const log = getLogger("session")
|
const log = getLogger("session")
|
||||||
|
|
||||||
const CREATE_WORKTREE_VALUE = "__codenomad_create_worktree__"
|
|
||||||
const DELETE_WORKTREE_VALUE = "__codenomad_delete_worktree__"
|
|
||||||
|
|
||||||
interface InstanceShellProps {
|
interface InstanceShellProps {
|
||||||
instance: Instance
|
instance: Instance
|
||||||
escapeInDebounce: boolean
|
escapeInDebounce: boolean
|
||||||
@@ -166,13 +152,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
const [showBackgroundOutput, setShowBackgroundOutput] = createSignal(false)
|
const [showBackgroundOutput, setShowBackgroundOutput] = createSignal(false)
|
||||||
const [permissionModalOpen, setPermissionModalOpen] = createSignal(false)
|
const [permissionModalOpen, setPermissionModalOpen] = createSignal(false)
|
||||||
|
|
||||||
const [createWorktreeOpen, setCreateWorktreeOpen] = createSignal(false)
|
// Worktree selector manages its own dialogs.
|
||||||
const [createWorktreeSlug, setCreateWorktreeSlug] = createSignal("")
|
|
||||||
const [isCreatingWorktree, setIsCreatingWorktree] = createSignal(false)
|
|
||||||
|
|
||||||
const [deleteWorktreeOpen, setDeleteWorktreeOpen] = createSignal(false)
|
|
||||||
const [isDeletingWorktree, setIsDeletingWorktree] = createSignal(false)
|
|
||||||
const [forceDeleteWorktree, setForceDeleteWorktree] = createSignal(false)
|
|
||||||
const [showSessionSearch, setShowSessionSearch] = createSignal(false)
|
const [showSessionSearch, setShowSessionSearch] = createSignal(false)
|
||||||
|
|
||||||
const messageStore = createMemo(() => messageStoreBus.getOrCreate(props.instance.id))
|
const messageStore = createMemo(() => messageStoreBus.getOrCreate(props.instance.id))
|
||||||
@@ -948,233 +928,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
{(activeSession) => (
|
{(activeSession) => (
|
||||||
<>
|
<>
|
||||||
<div class="session-sidebar-controls px-4 py-4 border-t border-base flex flex-col gap-3">
|
<div class="session-sidebar-controls px-4 py-4 border-t border-base flex flex-col gap-3">
|
||||||
<div class="space-y-1">
|
<WorktreeSelector instanceId={props.instance.id} sessionId={activeSession().id} />
|
||||||
<div class="text-xs font-medium text-muted uppercase tracking-wide">Worktree</div>
|
|
||||||
<select
|
|
||||||
class="selector-input w-full"
|
|
||||||
value={getWorktreeSlugForParentSession(
|
|
||||||
props.instance.id,
|
|
||||||
getParentSessionId(props.instance.id, activeSession().id),
|
|
||||||
)}
|
|
||||||
disabled={Boolean(activeSession().parentId)}
|
|
||||||
onChange={(e) => {
|
|
||||||
const nextSlug = e.currentTarget.value
|
|
||||||
const sessionId = activeSession().id
|
|
||||||
const parentId = getParentSessionId(props.instance.id, sessionId)
|
|
||||||
|
|
||||||
if (nextSlug === CREATE_WORKTREE_VALUE) {
|
|
||||||
setCreateWorktreeSlug("")
|
|
||||||
setCreateWorktreeOpen(true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (nextSlug === DELETE_WORKTREE_VALUE) {
|
|
||||||
const currentSlug = getWorktreeSlugForParentSession(props.instance.id, parentId)
|
|
||||||
if (currentSlug && currentSlug !== "root") {
|
|
||||||
setForceDeleteWorktree(false)
|
|
||||||
setDeleteWorktreeOpen(true)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
void (async () => {
|
|
||||||
await setWorktreeSlugForParentSession(props.instance.id, parentId, nextSlug)
|
|
||||||
})().catch((error) => {
|
|
||||||
log.warn("Failed to apply worktree change", error)
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<For each={getWorktrees(props.instance.id)}>
|
|
||||||
{(wt) => (
|
|
||||||
<option value={wt.slug}>{wt.slug === "root" ? "root" : wt.slug}</option>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
<Show when={getWorktrees(props.instance.id).length === 0}>
|
|
||||||
<option value="root">root</option>
|
|
||||||
</Show>
|
|
||||||
<option value={CREATE_WORKTREE_VALUE}>+ Create worktree…</option>
|
|
||||||
<option
|
|
||||||
value={DELETE_WORKTREE_VALUE}
|
|
||||||
disabled={
|
|
||||||
Boolean(activeSession().parentId) ||
|
|
||||||
getWorktreeSlugForParentSession(
|
|
||||||
props.instance.id,
|
|
||||||
getParentSessionId(props.instance.id, activeSession().id),
|
|
||||||
) === "root"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Delete worktree…
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Dialog open={createWorktreeOpen()} onOpenChange={(open) => !open && setCreateWorktreeOpen(false)}>
|
|
||||||
<Dialog.Portal>
|
|
||||||
<Dialog.Overlay class="modal-overlay" />
|
|
||||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
|
||||||
<Dialog.Content class="modal-surface w-full max-w-md p-6 flex flex-col gap-5">
|
|
||||||
<div>
|
|
||||||
<Dialog.Title class="text-xl font-semibold text-primary">Create worktree</Dialog.Title>
|
|
||||||
<Dialog.Description class="text-sm text-secondary mt-2">
|
|
||||||
Creates a git worktree under <span class="font-mono">.codenomad/worktrees/<name></span> from HEAD.
|
|
||||||
</Dialog.Description>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-2">
|
|
||||||
<label class="text-xs font-medium text-muted uppercase tracking-wide">Worktree name</label>
|
|
||||||
<input
|
|
||||||
class="form-input w-full"
|
|
||||||
value={createWorktreeSlug()}
|
|
||||||
onInput={(e) => setCreateWorktreeSlug(e.currentTarget.value)}
|
|
||||||
placeholder="feature-x"
|
|
||||||
disabled={isCreatingWorktree()}
|
|
||||||
spellcheck={false}
|
|
||||||
autocapitalize="off"
|
|
||||||
autocomplete="off"
|
|
||||||
/>
|
|
||||||
<div class="text-[11px] text-secondary">
|
|
||||||
Allowed: letters, numbers, <span class="font-mono">_ . - /</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex justify-end gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="selector-button selector-button-secondary"
|
|
||||||
onClick={() => setCreateWorktreeOpen(false)}
|
|
||||||
disabled={isCreatingWorktree()}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="selector-button selector-button-primary"
|
|
||||||
disabled={
|
|
||||||
isCreatingWorktree() ||
|
|
||||||
!createWorktreeSlug().trim() ||
|
|
||||||
createWorktreeSlug().trim() === "root" ||
|
|
||||||
!/^[a-zA-Z0-9_.\/-]+$/.test(createWorktreeSlug().trim())
|
|
||||||
}
|
|
||||||
onClick={() => {
|
|
||||||
const slug = createWorktreeSlug().trim()
|
|
||||||
|
|
||||||
void (async () => {
|
|
||||||
setIsCreatingWorktree(true)
|
|
||||||
await createWorktree(props.instance.id, slug)
|
|
||||||
await reloadWorktrees(props.instance.id)
|
|
||||||
|
|
||||||
const sessionId = activeSession().id
|
|
||||||
const parentId = getParentSessionId(props.instance.id, sessionId)
|
|
||||||
await setWorktreeSlugForParentSession(props.instance.id, parentId, slug)
|
|
||||||
|
|
||||||
setCreateWorktreeOpen(false)
|
|
||||||
showToastNotification({ message: `Created worktree ${slug}`, variant: "success" })
|
|
||||||
})()
|
|
||||||
.catch((error) => {
|
|
||||||
log.warn("Failed to create worktree", error)
|
|
||||||
showToastNotification({
|
|
||||||
message: error instanceof Error ? error.message : "Failed to create worktree",
|
|
||||||
variant: "error",
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
setIsCreatingWorktree(false)
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isCreatingWorktree() ? "Creating…" : "Create"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</Dialog.Content>
|
|
||||||
</div>
|
|
||||||
</Dialog.Portal>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<Dialog open={deleteWorktreeOpen()} onOpenChange={(open) => !open && setDeleteWorktreeOpen(false)}>
|
|
||||||
<Dialog.Portal>
|
|
||||||
<Dialog.Overlay class="modal-overlay" />
|
|
||||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
|
||||||
<Dialog.Content class="modal-surface w-full max-w-md p-6 flex flex-col gap-5">
|
|
||||||
<div>
|
|
||||||
<Dialog.Title class="text-xl font-semibold text-primary">Delete worktree</Dialog.Title>
|
|
||||||
<Dialog.Description class="text-sm text-secondary mt-2">
|
|
||||||
Removes the git worktree checkout directory for this branch.
|
|
||||||
</Dialog.Description>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="rounded-lg border border-base bg-surface-secondary p-4">
|
|
||||||
<p class="text-xs font-medium text-muted uppercase tracking-wide mb-1">Worktree</p>
|
|
||||||
<p class="text-sm font-mono text-primary break-all">
|
|
||||||
{getWorktreeSlugForParentSession(
|
|
||||||
props.instance.id,
|
|
||||||
getParentSessionId(props.instance.id, activeSession().id),
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label class="flex items-center gap-2 text-sm text-secondary">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={forceDeleteWorktree()}
|
|
||||||
onChange={(e) => setForceDeleteWorktree(e.currentTarget.checked)}
|
|
||||||
disabled={isDeletingWorktree()}
|
|
||||||
/>
|
|
||||||
Force delete (discard local changes)
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div class="flex justify-end gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="selector-button selector-button-secondary"
|
|
||||||
onClick={() => setDeleteWorktreeOpen(false)}
|
|
||||||
disabled={isDeletingWorktree()}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="selector-button selector-button-primary"
|
|
||||||
disabled={isDeletingWorktree()}
|
|
||||||
onClick={() => {
|
|
||||||
const sessionId = activeSession().id
|
|
||||||
const parentId = getParentSessionId(props.instance.id, sessionId)
|
|
||||||
const currentSlug = getWorktreeSlugForParentSession(props.instance.id, parentId)
|
|
||||||
if (!currentSlug || currentSlug === "root") {
|
|
||||||
setDeleteWorktreeOpen(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
void (async () => {
|
|
||||||
setIsDeletingWorktree(true)
|
|
||||||
await deleteWorktree(props.instance.id, currentSlug, { force: forceDeleteWorktree() })
|
|
||||||
await reloadWorktrees(props.instance.id)
|
|
||||||
await reloadWorktreeMap(props.instance.id)
|
|
||||||
|
|
||||||
// If the active session mapped to the deleted worktree, switch to root.
|
|
||||||
await setWorktreeSlugForParentSession(props.instance.id, parentId, "root")
|
|
||||||
|
|
||||||
setDeleteWorktreeOpen(false)
|
|
||||||
showToastNotification({ message: `Deleted worktree ${currentSlug}`, variant: "success" })
|
|
||||||
})()
|
|
||||||
.catch((error) => {
|
|
||||||
log.warn("Failed to delete worktree", error)
|
|
||||||
showToastNotification({
|
|
||||||
message: error instanceof Error ? error.message : "Failed to delete worktree",
|
|
||||||
variant: "error",
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
setIsDeletingWorktree(false)
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isDeletingWorktree() ? "Deleting…" : "Delete"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</Dialog.Content>
|
|
||||||
</div>
|
|
||||||
</Dialog.Portal>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<AgentSelector
|
<AgentSelector
|
||||||
instanceId={props.instance.id}
|
instanceId={props.instance.id}
|
||||||
|
|||||||
415
packages/ui/src/components/worktree-selector.tsx
Normal file
415
packages/ui/src/components/worktree-selector.tsx
Normal file
@@ -0,0 +1,415 @@
|
|||||||
|
import { Select } from "@kobalte/core/select"
|
||||||
|
import { Dialog } from "@kobalte/core/dialog"
|
||||||
|
import { For, Show, createMemo, createSignal } from "solid-js"
|
||||||
|
import { ChevronDown, Copy, Trash2 } from "lucide-solid"
|
||||||
|
import type { WorktreeDescriptor } from "../../../server/src/api-types"
|
||||||
|
import { getLogger } from "../lib/logger"
|
||||||
|
import { copyToClipboard } from "../lib/clipboard"
|
||||||
|
import { showToastNotification } from "../lib/notifications"
|
||||||
|
import {
|
||||||
|
createWorktree,
|
||||||
|
deleteWorktree,
|
||||||
|
getParentSessionId,
|
||||||
|
getWorktreeSlugForParentSession,
|
||||||
|
getWorktrees,
|
||||||
|
reloadWorktreeMap,
|
||||||
|
reloadWorktrees,
|
||||||
|
setWorktreeSlugForParentSession,
|
||||||
|
} from "../stores/worktrees"
|
||||||
|
import { sessions } from "../stores/sessions"
|
||||||
|
|
||||||
|
const log = getLogger("session")
|
||||||
|
|
||||||
|
type WorktreeOption =
|
||||||
|
| { kind: "action"; key: "__create__"; label: string }
|
||||||
|
| { kind: "worktree"; key: string; slug: string; directory: string; raw: WorktreeDescriptor }
|
||||||
|
|
||||||
|
const CREATE_OPTION: WorktreeOption = { kind: "action", key: "__create__", label: "+ Create worktree" }
|
||||||
|
|
||||||
|
function preventSelectPress(event: PointerEvent | MouseEvent) {
|
||||||
|
// Prevent Select.Item from treating this as a selection.
|
||||||
|
// We intentionally prevent default to stop Kobalte's internal press handling.
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopImmediatePropagation?.()
|
||||||
|
event.stopPropagation()
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePath(input: string): string {
|
||||||
|
return (input ?? "").replace(/\\/g, "/").replace(/\/+$/, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
function relativePath(fromDir: string, toDir: string): string {
|
||||||
|
const from = normalizePath(fromDir)
|
||||||
|
const to = normalizePath(toDir)
|
||||||
|
if (!from || !to) return to || from || ""
|
||||||
|
if (from === to) return "."
|
||||||
|
|
||||||
|
const fromParts = from.split("/").filter(Boolean)
|
||||||
|
const toParts = to.split("/").filter(Boolean)
|
||||||
|
|
||||||
|
let i = 0
|
||||||
|
while (i < fromParts.length && i < toParts.length) {
|
||||||
|
const a = fromParts[i]
|
||||||
|
const b = toParts[i]
|
||||||
|
if (!a || !b) break
|
||||||
|
if (a.toLowerCase() !== b.toLowerCase()) break
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
|
||||||
|
const up = fromParts.length - i
|
||||||
|
const down = toParts.slice(i)
|
||||||
|
const relParts: string[] = []
|
||||||
|
for (let j = 0; j < up; j++) relParts.push("..")
|
||||||
|
relParts.push(...down)
|
||||||
|
return relParts.join("/") || "."
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WorktreeSelectorProps {
|
||||||
|
instanceId: string
|
||||||
|
sessionId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WorktreeSelector(props: WorktreeSelectorProps) {
|
||||||
|
const [isOpen, setIsOpen] = createSignal(false)
|
||||||
|
const [createOpen, setCreateOpen] = createSignal(false)
|
||||||
|
const [createSlug, setCreateSlug] = createSignal("")
|
||||||
|
const [isCreating, setIsCreating] = createSignal(false)
|
||||||
|
|
||||||
|
const [deleteOpen, setDeleteOpen] = createSignal(false)
|
||||||
|
const [deleteTarget, setDeleteTarget] = createSignal<WorktreeOption & { kind: "worktree" } | null>(null)
|
||||||
|
const [forceDelete, setForceDelete] = createSignal(false)
|
||||||
|
const [isDeleting, setIsDeleting] = createSignal(false)
|
||||||
|
|
||||||
|
const session = createMemo(() => sessions().get(props.instanceId)?.get(props.sessionId))
|
||||||
|
const isChildSession = createMemo(() => Boolean(session()?.parentId))
|
||||||
|
const parentId = createMemo(() => getParentSessionId(props.instanceId, props.sessionId))
|
||||||
|
const currentSlug = createMemo(() => getWorktreeSlugForParentSession(props.instanceId, parentId()))
|
||||||
|
|
||||||
|
const worktreeOptions = createMemo<WorktreeOption[]>(() => {
|
||||||
|
const list = getWorktrees(props.instanceId)
|
||||||
|
const mapped: WorktreeOption[] = list.map((wt) => ({
|
||||||
|
kind: "worktree",
|
||||||
|
key: wt.slug,
|
||||||
|
slug: wt.slug,
|
||||||
|
directory: wt.directory,
|
||||||
|
raw: wt,
|
||||||
|
}))
|
||||||
|
return [CREATE_OPTION, ...mapped]
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectedOption = createMemo<WorktreeOption | undefined>(() => {
|
||||||
|
const slug = currentSlug()
|
||||||
|
const match = worktreeOptions().find((opt) => opt.kind === "worktree" && opt.slug === slug)
|
||||||
|
if (match) return match
|
||||||
|
// Fallback to root if mapped slug is missing.
|
||||||
|
return worktreeOptions().find((opt) => opt.kind === "worktree" && opt.slug === "root")
|
||||||
|
})
|
||||||
|
|
||||||
|
const openDeleteDialog = (opt: WorktreeOption & { kind: "worktree" }) => {
|
||||||
|
if (opt.slug === "root") return
|
||||||
|
setForceDelete(false)
|
||||||
|
setDeleteTarget(opt)
|
||||||
|
setDeleteOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const repoRoot = createMemo(() => {
|
||||||
|
const list = getWorktrees(props.instanceId)
|
||||||
|
return list.find((wt) => wt.slug === "root")?.directory ?? ""
|
||||||
|
})
|
||||||
|
|
||||||
|
const displayPathFor = (directory: string) => {
|
||||||
|
const base = repoRoot()
|
||||||
|
if (!base) return directory
|
||||||
|
return relativePath(base, directory)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCopyPath = async (directory: string) => {
|
||||||
|
try {
|
||||||
|
const ok = await copyToClipboard(directory)
|
||||||
|
showToastNotification({ message: ok ? "Copied worktree path" : "Failed to copy path", variant: ok ? "success" : "error" })
|
||||||
|
} catch (error) {
|
||||||
|
log.error("Failed to copy worktree path", error)
|
||||||
|
showToastNotification({ message: "Failed to copy path", variant: "error" })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleChange = async (value: WorktreeOption | null) => {
|
||||||
|
if (!value) return
|
||||||
|
if (value.kind === "action") {
|
||||||
|
setIsOpen(false)
|
||||||
|
setCreateSlug("")
|
||||||
|
setCreateOpen(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await setWorktreeSlugForParentSession(props.instanceId, parentId(), value.slug)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="sidebar-selector">
|
||||||
|
<Select<WorktreeOption>
|
||||||
|
open={isOpen()}
|
||||||
|
onOpenChange={setIsOpen}
|
||||||
|
value={selectedOption() ?? null}
|
||||||
|
onChange={(value) => {
|
||||||
|
void handleChange(value).catch((error) => log.warn("Failed to change worktree", error))
|
||||||
|
}}
|
||||||
|
options={worktreeOptions()}
|
||||||
|
optionValue="key"
|
||||||
|
optionTextValue={(opt) => (opt.kind === "action" ? opt.label : opt.slug)}
|
||||||
|
placeholder="Worktree"
|
||||||
|
disabled={isChildSession()}
|
||||||
|
itemComponent={(itemProps) => {
|
||||||
|
const opt = itemProps.item.rawValue
|
||||||
|
if (opt.kind === "action") {
|
||||||
|
return (
|
||||||
|
<Select.Item item={itemProps.item} class="selector-option worktree-selector-item">
|
||||||
|
<div class="selector-option-content w-full">
|
||||||
|
<Select.ItemLabel class="selector-option-label">{opt.label}</Select.ItemLabel>
|
||||||
|
<Select.ItemDescription class="selector-option-description">New from current branch</Select.ItemDescription>
|
||||||
|
</div>
|
||||||
|
</Select.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Select.Item item={itemProps.item} class="selector-option worktree-selector-item">
|
||||||
|
<div class="flex flex-col gap-1 flex-1 min-w-0">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Select.ItemLabel class="selector-option-label flex-1 min-w-0 truncate">
|
||||||
|
{opt.slug === "root" ? "root" : opt.slug}
|
||||||
|
</Select.ItemLabel>
|
||||||
|
<Show when={opt.slug !== "root"}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="session-item-close opacity-80 hover:opacity-100 hover:bg-surface-hover"
|
||||||
|
aria-label="Delete worktree"
|
||||||
|
title="Delete worktree"
|
||||||
|
onPointerDown={(event) => {
|
||||||
|
preventSelectPress(event)
|
||||||
|
setIsOpen(false)
|
||||||
|
openDeleteDialog(opt)
|
||||||
|
}}
|
||||||
|
onPointerUp={preventSelectPress}
|
||||||
|
onMouseDown={preventSelectPress}
|
||||||
|
onMouseUp={preventSelectPress}
|
||||||
|
onClick={preventSelectPress}
|
||||||
|
>
|
||||||
|
<Trash2 class="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
|
<span
|
||||||
|
class="selector-option-description flex-1 min-w-0 truncate font-mono"
|
||||||
|
title={opt.directory}
|
||||||
|
>
|
||||||
|
{displayPathFor(opt.directory)}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="session-item-close opacity-80 hover:opacity-100 hover:bg-surface-hover"
|
||||||
|
aria-label="Copy path"
|
||||||
|
title="Copy path"
|
||||||
|
onPointerDown={(event) => {
|
||||||
|
preventSelectPress(event)
|
||||||
|
void (async () => {
|
||||||
|
await handleCopyPath(opt.directory)
|
||||||
|
setIsOpen(false)
|
||||||
|
})()
|
||||||
|
}}
|
||||||
|
onPointerUp={preventSelectPress}
|
||||||
|
onMouseDown={preventSelectPress}
|
||||||
|
onMouseUp={preventSelectPress}
|
||||||
|
onClick={preventSelectPress}
|
||||||
|
>
|
||||||
|
<Copy class="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Select.Item>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Select.Trigger class="selector-trigger">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<Select.Value<WorktreeOption>>
|
||||||
|
{(state) => {
|
||||||
|
const value = state.selectedOption()
|
||||||
|
const label = value && value.kind === "worktree" ? (value.slug === "root" ? "root" : value.slug) : "root"
|
||||||
|
return (
|
||||||
|
<div class="selector-trigger-label selector-trigger-label--stacked">
|
||||||
|
<span class="selector-trigger-primary selector-trigger-primary--align-left">Worktree: {label}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</Select.Value>
|
||||||
|
</div>
|
||||||
|
<Select.Icon class="selector-trigger-icon">
|
||||||
|
<ChevronDown class="w-3 h-3" />
|
||||||
|
</Select.Icon>
|
||||||
|
</Select.Trigger>
|
||||||
|
|
||||||
|
<Select.Portal>
|
||||||
|
<Select.Content class="selector-popover max-h-80 overflow-auto p-1">
|
||||||
|
<Select.Listbox class="selector-listbox" />
|
||||||
|
</Select.Content>
|
||||||
|
</Select.Portal>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Dialog open={createOpen()} onOpenChange={(open) => !open && setCreateOpen(false)}>
|
||||||
|
<Dialog.Portal>
|
||||||
|
<Dialog.Overlay class="modal-overlay" />
|
||||||
|
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
|
<Dialog.Content class="modal-surface w-full max-w-md p-6 flex flex-col gap-5">
|
||||||
|
<div>
|
||||||
|
<Dialog.Title class="text-xl font-semibold text-primary">Create worktree</Dialog.Title>
|
||||||
|
<Dialog.Description class="text-sm text-secondary mt-2">Creates a git worktree</Dialog.Description>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="text-xs font-medium text-muted uppercase tracking-wide">Name</label>
|
||||||
|
<input
|
||||||
|
class="form-input w-full"
|
||||||
|
value={createSlug()}
|
||||||
|
onInput={(e) => setCreateSlug(e.currentTarget.value)}
|
||||||
|
placeholder="worktree-name"
|
||||||
|
disabled={isCreating()}
|
||||||
|
spellcheck={false}
|
||||||
|
autocapitalize="off"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="selector-button selector-button-secondary"
|
||||||
|
onClick={() => setCreateOpen(false)}
|
||||||
|
disabled={isCreating()}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="selector-button selector-button-primary"
|
||||||
|
disabled={
|
||||||
|
isCreating() ||
|
||||||
|
!createSlug().trim() ||
|
||||||
|
createSlug().trim() === "root" ||
|
||||||
|
/[\x00-\x1F\x7F]/.test(createSlug())
|
||||||
|
}
|
||||||
|
onClick={() => {
|
||||||
|
const slug = createSlug().trim()
|
||||||
|
void (async () => {
|
||||||
|
setIsCreating(true)
|
||||||
|
await createWorktree(props.instanceId, slug)
|
||||||
|
await reloadWorktrees(props.instanceId)
|
||||||
|
await setWorktreeSlugForParentSession(props.instanceId, parentId(), slug)
|
||||||
|
setCreateOpen(false)
|
||||||
|
showToastNotification({ message: `Created worktree ${slug}`, variant: "success" })
|
||||||
|
})()
|
||||||
|
.catch((error) => {
|
||||||
|
log.warn("Failed to create worktree", error)
|
||||||
|
showToastNotification({
|
||||||
|
message: error instanceof Error ? error.message : "Failed to create worktree",
|
||||||
|
variant: "error",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsCreating(false)
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isCreating() ? "Creating..." : "Create"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Dialog.Content>
|
||||||
|
</div>
|
||||||
|
</Dialog.Portal>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog open={deleteOpen()} onOpenChange={(open) => !open && setDeleteOpen(false)}>
|
||||||
|
<Dialog.Portal>
|
||||||
|
<Dialog.Overlay class="modal-overlay" />
|
||||||
|
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
|
<Dialog.Content class="modal-surface w-full max-w-md p-6 flex flex-col gap-5">
|
||||||
|
<div>
|
||||||
|
<Dialog.Title class="text-xl font-semibold text-primary">Delete worktree</Dialog.Title>
|
||||||
|
<Dialog.Description class="text-sm text-secondary mt-2">Removes the git worktree checkout directory for this branch.</Dialog.Description>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={deleteTarget()}>
|
||||||
|
{(target) => (
|
||||||
|
<div class="rounded-lg border border-base bg-surface-secondary p-4">
|
||||||
|
<p class="text-xs font-medium text-muted uppercase tracking-wide mb-1">Worktree</p>
|
||||||
|
<p class="text-sm font-mono text-primary break-all">{target().slug}</p>
|
||||||
|
<p class="text-[11px] text-secondary mt-2 break-all font-mono">{target().directory}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<label class="flex items-center gap-2 text-sm text-secondary">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={forceDelete()}
|
||||||
|
onChange={(e) => setForceDelete(e.currentTarget.checked)}
|
||||||
|
disabled={isDeleting()}
|
||||||
|
/>
|
||||||
|
Force delete (discard local changes)
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="selector-button selector-button-secondary"
|
||||||
|
onClick={() => setDeleteOpen(false)}
|
||||||
|
disabled={isDeleting()}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="selector-button selector-button-primary"
|
||||||
|
disabled={isDeleting() || !deleteTarget()}
|
||||||
|
onClick={() => {
|
||||||
|
const target = deleteTarget()
|
||||||
|
if (!target) {
|
||||||
|
setDeleteOpen(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
setIsDeleting(true)
|
||||||
|
await deleteWorktree(props.instanceId, target.slug, { force: forceDelete() })
|
||||||
|
await reloadWorktrees(props.instanceId)
|
||||||
|
await reloadWorktreeMap(props.instanceId)
|
||||||
|
|
||||||
|
if (currentSlug() === target.slug) {
|
||||||
|
await setWorktreeSlugForParentSession(props.instanceId, parentId(), "root")
|
||||||
|
}
|
||||||
|
|
||||||
|
setDeleteOpen(false)
|
||||||
|
showToastNotification({ message: `Deleted worktree ${target.slug}`, variant: "success" })
|
||||||
|
})()
|
||||||
|
.catch((error) => {
|
||||||
|
log.warn("Failed to delete worktree", error)
|
||||||
|
showToastNotification({
|
||||||
|
message: error instanceof Error ? error.message : "Failed to delete worktree",
|
||||||
|
variant: "error",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsDeleting(false)
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isDeleting() ? "Deleting..." : "Delete"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Dialog.Content>
|
||||||
|
</div>
|
||||||
|
</Dialog.Portal>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -171,6 +171,15 @@
|
|||||||
ring-color: var(--accent-primary);
|
ring-color: var(--accent-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Worktree selector separators */
|
||||||
|
.worktree-selector-item {
|
||||||
|
border-bottom: 1px solid var(--border-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.worktree-selector-item:last-of-type {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
.selector-favorites-toggle {
|
.selector-favorites-toggle {
|
||||||
@apply p-2 rounded border transition-colors flex items-center justify-center;
|
@apply p-2 rounded border transition-colors flex items-center justify-center;
|
||||||
background-color: var(--surface-base);
|
background-color: var(--surface-base);
|
||||||
|
|||||||
Reference in New Issue
Block a user