diff --git a/packages/server/src/workspaces/git-worktrees.ts b/packages/server/src/workspaces/git-worktrees.ts index dea6b934..f9b5998e 100644 --- a/packages/server/src/workspaces/git-worktrees.ts +++ b/packages/server/src/workspaces/git-worktrees.ts @@ -101,7 +101,7 @@ export async function listWorktrees(params: { const records = parseWorktreePorcelain(result.stdout) const worktrees: WorktreeDescriptor[] = [rootDescriptor] - const slugCounts = new Map([["root", 1]]) + const seen = new Set(["root"]) const normalizeSlug = (record: { branch?: string; head?: string; detached?: boolean; worktree: string }): string => { const branch = (record.branch ?? "").trim() @@ -117,14 +117,6 @@ export async function listWorktrees(params: { 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) { const abs = record.worktree @@ -135,13 +127,14 @@ export async function listWorktrees(params: { continue } - const slug = uniquify(normalizeSlug(record)) - // Never emit a worktree slug that collides with our reserved root slug. - if (slug === "root") { - worktrees.push({ slug: uniquify("root-worktree"), directory: abs, kind: "worktree", branch: record.branch }) + const slug = normalizeSlug(record) + if (!slug || slug === "root") { continue } - + if (seen.has(slug)) { + continue + } + seen.add(slug) worktrees.push({ slug, directory: abs, kind: "worktree", branch: record.branch }) } diff --git a/packages/ui/src/components/instance/instance-shell2.tsx b/packages/ui/src/components/instance/instance-shell2.tsx index f5c11219..f4349229 100644 --- a/packages/ui/src/components/instance/instance-shell2.tsx +++ b/packages/ui/src/components/instance/instance-shell2.tsx @@ -12,7 +12,6 @@ import { } from "solid-js" import type { ToolState } from "@opencode-ai/sdk" import { Accordion } from "@kobalte/core" -import { Dialog } from "@kobalte/core/dialog" import { ChevronDown, Search, TerminalSquare, Trash2, XOctagon } from "lucide-solid" import AppBar from "@suid/material/AppBar" import Box from "@suid/material/Box" @@ -65,17 +64,7 @@ import { formatTokenTotal } from "../../lib/formatters" import { sseManager } from "../../lib/sse-manager" import { getLogger } from "../../lib/logger" import { serverApi } from "../../lib/api-client" -import { showToastNotification } from "../../lib/notifications" -import { - createWorktree, - deleteWorktree, - getParentSessionId, - getWorktreeSlugForParentSession, - getWorktrees, - reloadWorktrees, - reloadWorktreeMap, - setWorktreeSlugForParentSession, -} from "../../stores/worktrees" +import WorktreeSelector from "../worktree-selector" import { getBackgroundProcesses, loadBackgroundProcesses } from "../../stores/background-processes" import { BackgroundProcessOutputDialog } from "../background-process-output-dialog" import { useI18n } from "../../lib/i18n" @@ -87,9 +76,6 @@ import { const log = getLogger("session") -const CREATE_WORKTREE_VALUE = "__codenomad_create_worktree__" -const DELETE_WORKTREE_VALUE = "__codenomad_delete_worktree__" - interface InstanceShellProps { instance: Instance escapeInDebounce: boolean @@ -166,13 +152,7 @@ const InstanceShell2: Component = (props) => { const [showBackgroundOutput, setShowBackgroundOutput] = createSignal(false) const [permissionModalOpen, setPermissionModalOpen] = createSignal(false) - const [createWorktreeOpen, setCreateWorktreeOpen] = createSignal(false) - const [createWorktreeSlug, setCreateWorktreeSlug] = createSignal("") - const [isCreatingWorktree, setIsCreatingWorktree] = createSignal(false) - - const [deleteWorktreeOpen, setDeleteWorktreeOpen] = createSignal(false) - const [isDeletingWorktree, setIsDeletingWorktree] = createSignal(false) - const [forceDeleteWorktree, setForceDeleteWorktree] = createSignal(false) + // Worktree selector manages its own dialogs. const [showSessionSearch, setShowSessionSearch] = createSignal(false) const messageStore = createMemo(() => messageStoreBus.getOrCreate(props.instance.id)) @@ -948,233 +928,7 @@ const InstanceShell2: Component = (props) => { {(activeSession) => ( <>
-
-
Worktree
- -
- - !open && setCreateWorktreeOpen(false)}> - - -
- -
- Create worktree - - Creates a git worktree under .codenomad/worktrees/<name> from HEAD. - -
- -
- - setCreateWorktreeSlug(e.currentTarget.value)} - placeholder="feature-x" - disabled={isCreatingWorktree()} - spellcheck={false} - autocapitalize="off" - autocomplete="off" - /> -
- Allowed: letters, numbers, _ . - / -
-
- -
- - -
-
-
-
-
- - !open && setDeleteWorktreeOpen(false)}> - - -
- -
- Delete worktree - - Removes the git worktree checkout directory for this branch. - -
- -
-

Worktree

-

- {getWorktreeSlugForParentSession( - props.instance.id, - getParentSessionId(props.instance.id, activeSession().id), - )} -

-
- - - -
- - -
-
-
-
-
+ (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(() => { + 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(() => { + 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 ( + + ) +} diff --git a/packages/ui/src/styles/components/selector.css b/packages/ui/src/styles/components/selector.css index f6ea59fd..a65cce76 100644 --- a/packages/ui/src/styles/components/selector.css +++ b/packages/ui/src/styles/components/selector.css @@ -171,6 +171,15 @@ 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 { @apply p-2 rounded border transition-colors flex items-center justify-center; background-color: var(--surface-base);