Parcourir la source

feat(tui): delete working copies from move dialog (#31017)

James Long il y a 3 mois
Parent
commit
f591bf5f93

+ 15 - 4
packages/core/src/git.ts

@@ -30,6 +30,7 @@ export class WorktreeError extends Schema.TaggedErrorClass<WorktreeError>()("Git
   operation: Schema.Literals(["create", "remove", "list"]),
   message: Schema.String,
   directory: Schema.optional(AbsolutePath),
+  forceRequired: Schema.optional(Schema.Boolean),
   cause: Schema.optional(Schema.Defect),
 }) {}
 
@@ -64,7 +65,11 @@ export interface Interface {
   readonly resetChanges: (directory: AbsolutePath) => Effect.Effect<void, PatchError>
   readonly softResetChanges: (directory: AbsolutePath) => Effect.Effect<void, PatchError>
   readonly worktreeCreate: (input: { repo: Repo; directory: AbsolutePath }) => Effect.Effect<void, WorktreeError>
-  readonly worktreeRemove: (input: { repo: Repo; directory: AbsolutePath }) => Effect.Effect<void, WorktreeError>
+  readonly worktreeRemove: (input: {
+    repo: Repo
+    directory: AbsolutePath
+    force: boolean
+  }) => Effect.Effect<void, WorktreeError>
   readonly worktreeList: (repo: Repo) => Effect.Effect<AbsolutePath[], WorktreeError>
 }
 
@@ -335,10 +340,12 @@ export const layer = Layer.effect(
           ),
         )
       if (result.exitCode === 0) return result.stdout.toString("utf8")
+      const message = result.stderr.toString("utf8").trim() || result.stdout.toString("utf8").trim() || "Git failed"
       return yield* new WorktreeError({
         operation,
         directory: worktreeDirectory,
-        message: result.stderr.toString("utf8").trim() || result.stdout.toString("utf8").trim() || "Git failed",
+        message,
+        forceRequired: operation === "remove" && /contains modified or untracked files|is dirty/i.test(message),
       })
     })
 
@@ -346,11 +353,15 @@ export const layer = Layer.effect(
       yield* worktree("create", input.repo, ["worktree", "add", "--detach", input.directory, "HEAD"], input.directory)
     })
 
-    const worktreeRemove = Effect.fn("Git.worktreeRemove")(function* (input: { repo: Repo; directory: AbsolutePath }) {
+    const worktreeRemove = Effect.fn("Git.worktreeRemove")(function* (input: {
+      repo: Repo
+      directory: AbsolutePath
+      force: boolean
+    }) {
       yield* worktree(
         "remove",
         input.repo,
-        ["worktree", "remove", "--force", input.directory],
+        ["worktree", "remove", ...(input.force ? ["--force"] : []), input.directory],
         input.directory,
         input.repo.store,
       )

+ 4 - 4
packages/core/src/project/copy-strategies.ts

@@ -19,10 +19,10 @@ export function makeStrategies(input: {
       yield* input.git.worktreeCreate({ repo: repo(options.sourceDirectory), directory: options.directory })
       return { directory: yield* input.canonical(options.directory) }
     }),
-    remove: Effect.fn("ProjectCopy.GitWorktree.remove")(function* (directory) {
-      const found = yield* input.git.find(directory)
-      if (!found) return yield* new DirectoryUnavailableError({ directory })
-      yield* input.git.worktreeRemove({ repo: found, directory })
+    remove: Effect.fn("ProjectCopy.GitWorktree.remove")(function* (options) {
+      const found = yield* input.git.find(options.directory)
+      if (!found) return yield* new DirectoryUnavailableError({ directory: options.directory })
+      yield* input.git.worktreeRemove({ repo: found, directory: options.directory, force: options.force })
     }),
     list: Effect.fn("ProjectCopy.GitWorktree.list")(function* (directory) {
       const found = yield* input.git.find(directory)

+ 6 - 2
packages/core/src/project/copy.ts

@@ -34,6 +34,7 @@ export type CreateInput = typeof CreateInput.Type
 export const RemoveInput = Schema.Struct({
   projectID: Project.ID,
   directory: AbsolutePath,
+  force: Schema.Boolean,
 }).annotate({ identifier: "ProjectCopy.RemoveInput" })
 export type RemoveInput = typeof RemoveInput.Type
 
@@ -82,7 +83,10 @@ export interface Strategy {
     sourceDirectory: AbsolutePath
     directory: AbsolutePath
   }) => Effect.Effect<Copy, Git.WorktreeError | DirectoryUnavailableError>
-  readonly remove: (directory: AbsolutePath) => Effect.Effect<void, Git.WorktreeError | DirectoryUnavailableError>
+  readonly remove: (input: {
+    directory: AbsolutePath
+    force: boolean
+  }) => Effect.Effect<void, Git.WorktreeError | DirectoryUnavailableError>
   readonly list: (directory: AbsolutePath) => Effect.Effect<Copy[], Git.WorktreeError | DirectoryUnavailableError>
   readonly detect: (directory: AbsolutePath) => Effect.Effect<boolean>
 }
@@ -209,7 +213,7 @@ export const layer = Layer.effect(
       const copyDirectory = yield* canonical(input.directory)
       const id = yield* detect({ directory: copyDirectory })
       if (!id) return yield* new StrategyNotFoundError({ directory: copyDirectory })
-      yield* strategy(id).remove(copyDirectory)
+      yield* strategy(id).remove({ directory: copyDirectory, force: input.force })
       yield* changed(input.projectID, yield* removeStored(input.projectID, copyDirectory))
     })
 

+ 1 - 1
packages/core/test/git.test.ts

@@ -99,7 +99,7 @@ describe("Git worktrees", () => {
       expect(linked?.directory).toBe(AbsolutePath.make(yield* Effect.promise(() => fs.realpath(worktree))))
       expect(linked?.store).toBe(repo.store)
       if (!linked) throw new Error("Linked worktree not found")
-      yield* git.worktreeRemove({ repo: linked, directory: worktree })
+      yield* git.worktreeRemove({ repo: linked, directory: worktree, force: false })
       expect((yield* git.worktreeList(repo)).some((entry) => entry.endsWith("-git-worktree"))).toBe(false)
     }),
   )

+ 37 - 2
packages/core/test/project-copy.test.ts

@@ -124,13 +124,48 @@ describe("ProjectCopy", () => {
       )
       expect(Array.from(yield* Fiber.join(fiber))[0]?.data).toEqual({ projectID: input.projectID })
 
-      yield* copy.remove({ projectID: input.projectID, directory: created.directory })
+      yield* copy.remove({ projectID: input.projectID, directory: created.directory, force: false })
 
       expect(yield* stored(input.projectID)).toEqual([{ directory: input.sourceDirectory, type: "main" as const }])
       expect(yield* Effect.promise(() => Bun.file(target).exists())).toBe(false)
     }),
   )
 
+  it.live("requires force to remove a dirty git worktree", () =>
+    Effect.gen(function* () {
+      const input = yield* setup()
+      const copy = yield* ProjectCopy.Service
+      const temp = yield* Effect.promise(() => fs.realpath(path.dirname(input.root.path)))
+      const parent = abs(path.join(temp, path.basename(input.root.path) + "-copy-dirty"))
+      yield* Effect.addFinalizer(() =>
+        Effect.promise(() => fs.rm(parent, { recursive: true, force: true })).pipe(Effect.ignore),
+      )
+      const created = yield* copy.create({
+        projectID: input.projectID,
+        strategy: "git_worktree",
+        sourceDirectory: input.sourceDirectory,
+        directory: parent,
+        name: "copy",
+      })
+      yield* Effect.promise(() => Bun.write(path.join(created.directory, "dirty.txt"), "dirty"))
+
+      const error = yield* copy
+        .remove({ projectID: input.projectID, directory: created.directory, force: false })
+        .pipe(Effect.flip)
+
+      expect(error).toBeInstanceOf(Git.WorktreeError)
+      if (error instanceof Git.WorktreeError) {
+        expect(error.operation).toBe("remove")
+        expect(error.forceRequired).toBe(true)
+      }
+      expect(yield* stored(input.projectID)).toContainEqual({ directory: created.directory, type: "git_worktree" })
+      expect(yield* Effect.promise(() => Bun.file(path.join(created.directory, "dirty.txt")).exists())).toBe(true)
+
+      yield* copy.remove({ projectID: input.projectID, directory: created.directory, force: true })
+      expect(yield* Effect.promise(() => Bun.file(created.directory).exists())).toBe(false)
+    }),
+  )
+
   it.live("adds a numeric suffix when a copy directory already exists", () =>
     Effect.gen(function* () {
       const input = yield* setup()
@@ -160,7 +195,7 @@ describe("ProjectCopy", () => {
         true,
       )
 
-      yield* copy.remove({ projectID: input.projectID, directory: created.directory })
+      yield* copy.remove({ projectID: input.projectID, directory: created.directory, force: false })
     }),
   )
 

+ 1 - 1
packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx

@@ -157,7 +157,7 @@ export function DialogModel(props: { providerID?: string }) {
         {
           command: "model.dialog.favorite",
           title: "Favorite",
-          disabled: !connected(),
+          hidden: !connected(),
           onTrigger: (option) => {
             local.model.toggleFavorite(option.value as { providerID: string; modelID: string })
           },

+ 154 - 43
packages/opencode/src/cli/cmd/tui/component/dialog-move-session.tsx

@@ -1,56 +1,82 @@
 import { useTerminalDimensions } from "@opentui/solid"
+import { TextAttributes } from "@opentui/core"
 import { createMemo, createResource, createSignal, onMount, Show } from "solid-js"
 import path from "path"
 import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select"
 import { useDialog } from "@tui/ui/dialog"
 import { useSDK } from "@tui/context/sdk"
 import { useTheme } from "@tui/context/theme"
-import { useKV } from "@tui/context/kv"
 import { useSync } from "@tui/context/sync"
 import { Global } from "@opencode-ai/core/global"
 import { Locale } from "@/util/locale"
-import "opentui-spinner/solid"
+import { errorMessage } from "@/util/error"
+import { useToast } from "@tui/ui/toast"
+import { useCommandShortcut } from "@tui/keymap"
+import { useProject } from "@tui/context/project"
+import { Spinner } from "./spinner"
+import { DialogWorkspaceFileChanges } from "./dialog-workspace-file-changes"
 
-const REFRESH_FRAMES = ["■", "⬝"]
-
-export type MoveSessionSelection = { type: "directory"; directory: string } | { type: "new" }
+export type MoveSessionSelection =
+  | { type: "directory"; directory: string; subdirectory: boolean }
+  | { type: "new" }
 
 export function DialogMoveSession(props: {
   projectID: string
   current?: MoveSessionSelection
   onSelect: (selection: MoveSessionSelection) => void
+  initialDirectories?: string[]
+  initialRemoving?: string
 }) {
   const dialog = useDialog()
   const sdk = useSDK()
   const dimensions = useTerminalDimensions()
   const { theme } = useTheme()
-  const kv = useKV()
   const sync = useSync()
-  const [refreshing, setRefreshing] = createSignal(false)
+  const projectContext = useProject()
+  const toast = useToast()
+  const [working, setWorking] = createSignal(Boolean(props.initialRemoving))
+  const [toDelete, setToDelete] = createSignal<string>()
+  const [removing, setRemoving] = createSignal(props.initialRemoving)
+  const deleteHint = useCommandShortcut("dialog.move_session.delete")
+
+  function reopen(initialRemoving?: string) {
+    dialog.replace(() => (
+      <DialogMoveSession {...props} initialDirectories={directories()} initialRemoving={initialRemoving} />
+    ))
+  }
 
-  const [directories] = createResource(
-    () => props.projectID,
+  const [loadedProject] = createResource(
+    () => (projectContext.project() === props.projectID ? undefined : props.projectID),
     async (projectID) => {
-      setRefreshing(true)
-      const [, project] = await Promise.all([
-        sdk.client.experimental.projectCopy
-          .refresh({ projectID }, { throwOnError: true })
-          .finally(() => setRefreshing(false)),
-        sdk.client.project.current({}, { throwOnError: true }),
-      ])
-      const directories = await sdk.client.project.directories({ projectID }, { throwOnError: true })
-      return {
-        directories: directories.data ?? [],
-        main: project.data?.id === projectID ? project.data.worktree : undefined,
+      const result = await sdk.client.project.current({}, { throwOnError: true })
+      return result.data?.id === projectID ? result.data.worktree : undefined
+    },
+  )
+  const project = createMemo(() =>
+    projectContext.project() === props.projectID ? projectContext.data.project.worktree : loadedProject(),
+  )
+
+  const [directories, { refetch }] = createResource(
+    () => (props.initialRemoving ? undefined : props.projectID),
+    async (projectID) => {
+      setWorking(true)
+      try {
+        await sdk.client.experimental.projectCopy.refresh({ projectID }, { throwOnError: true })
+        const directories = await sdk.client.project.directories({ projectID }, { throwOnError: true })
+        return directories.data ?? []
+      } finally {
+        setWorking(false)
       }
     },
+    { initialValue: props.initialDirectories },
   )
 
   const options = createMemo<DialogSelectOption<MoveSessionSelection | undefined>[]>(() => {
-    if (directories.loading) return [{ title: "Loading project directories...", value: undefined }]
-    if (directories.error) return [{ title: "Failed to load project directories", value: undefined }]
     const data = directories()
-    const roots = data ? [...new Set(data.main ? [data.main, ...data.directories] : data.directories)] : []
+    const main = project()
+    if (directories.loading && !data && !main) return [{ title: "Loading project directories...", value: undefined }]
+    if (directories.error && !data && !main) return [{ title: "Failed to load project directories", value: undefined }]
+    const roots = [...new Set(main ? [main, ...(data ?? [])] : (data ?? []))]
     if (roots.length === 0) return [{ title: "No project directories found", value: undefined }]
     const subdirectories = sync.data.session
       .filter((session) => session.projectID === props.projectID && session.path && ![".", "/"].includes(session.path))
@@ -84,50 +110,135 @@ export function DialogMoveSession(props: {
       const suffix = item.location === item.root ? undefined : path.sep + path.relative(item.root, item.location)
       const visible = Locale.truncateLeft(title, titleWidth)
       const split = suffix ? Math.max(0, visible.length - suffix.length) : visible.length
+      const deleting = toDelete() === item.location
+      const isRemoving = removing() === item.location
       return {
-        title,
-        titleView: suffix ? (
-          <>
-            {visible.slice(0, split)}
-            <span style={{ fg: theme.textMuted }}>{visible.slice(split)}</span>
-          </>
-        ) : undefined,
-        value: { type: "directory", directory: item.location } as const,
-        category: item.root === data?.main ? "Project" : "Working copies",
+        title: isRemoving ? `Deleting ${item.location}` : deleting ? `Press ${deleteHint()} again to confirm` : title,
+        titleView:
+          isRemoving ? (
+            <span style={{ fg: theme.error }}>Deleting {item.location}</span>
+          ) : !deleting && suffix ? (
+            <>
+              {visible.slice(0, split)}
+              <span style={{ fg: theme.textMuted }}>{visible.slice(split)}</span>
+            </>
+          ) : undefined,
+        bg: deleting ? theme.error : undefined,
+        value: { type: "directory", directory: item.location, subdirectory: item.location !== item.root } as const,
+        category: item.root === main ? "Project" : "Working copies",
         titleWidth,
         truncateTitle: "left" as const,
       }
     })
   })
 
+  const current = createMemo(() => {
+    if (directories.loading || loadedProject.loading || !props.current) return
+    if (props.current.type === "new") return props.current
+    const directory = props.current.directory
+    return options().find(
+      (option) => option.value?.type === "directory" && option.value.directory === directory,
+    )?.value
+  })
+
+  async function remove(option: DialogSelectOption<MoveSessionSelection | undefined>) {
+    if (!option.value || option.value.type !== "directory" || option.value.subdirectory || removing()) return
+    const data = directories()
+    const main = project()
+    if (!data || !main || option.value.directory === main || !data.includes(option.value.directory)) return
+    if (toDelete() !== option.value.directory) {
+      setToDelete(option.value.directory)
+      return
+    }
+    setToDelete(undefined)
+    setRemoving(option.value.directory)
+    setWorking(true)
+    const result = await sdk.client.experimental.projectCopy
+      .remove({ projectID: props.projectID, directory: option.value.directory, force: false })
+      .catch((error) => ({ error }))
+    if (result.error) {
+      setRemoving(undefined)
+      setWorking(false)
+      if ("data" in result.error && result.error.data.forceRequired) {
+        const status = await sdk.client.vcs.status({ directory: option.value.directory }).catch(() => undefined)
+        const choice = await DialogWorkspaceFileChanges.show(dialog, status?.data ?? [], {
+          title: "Delete working copy?",
+          message: "This working copy has file changes. Do you want to delete it anyway?",
+        })
+        if (choice !== "yes") {
+          reopen()
+          return
+        }
+        reopen(option.value.directory)
+        const forced = await sdk.client.experimental.projectCopy
+          .remove({ projectID: props.projectID, directory: option.value.directory, force: true })
+          .catch((error) => ({ error }))
+        if (forced.error) {
+          toast.show({
+            variant: "error",
+            title: "Failed to delete project copy",
+            message: errorMessage(forced.error),
+          })
+        }
+        reopen()
+        return
+      }
+      toast.show({
+        variant: "error",
+        title: "Failed to delete project copy",
+        message: errorMessage(result.error),
+      })
+      return
+    }
+    await refetch()
+    setRemoving(undefined)
+  }
+
   onMount(() => dialog.setSize("xlarge"))
 
   return (
     <box minHeight={Math.max(8, Math.min(16, dimensions().height - Math.floor(dimensions().height / 4) - 2))}>
       <DialogSelect
         title="Move session"
+        titleView={
+          <box flexDirection="row" gap={1}>
+            <text fg={theme.text} attributes={TextAttributes.BOLD}>
+              Move session
+            </text>
+            <Show when={working()}>
+              <Spinner />
+            </Show>
+          </box>
+        }
         options={options()}
-        current={props.current}
+        locked={directories.loading || loadedProject.loading || Boolean(removing())}
+        current={current()}
         onSelect={(option) => {
           if (option.value) props.onSelect(option.value)
         }}
+        onMove={() => setToDelete(undefined)}
         actions={[
           {
             command: "dialog.move_session.new",
             title: "new",
             onTrigger: () => props.onSelect({ type: "new" }),
           },
+          {
+            command: "dialog.move_session.delete",
+            title: "delete",
+            disabled: (option) =>
+              !option?.value ||
+              option.value.type !== "directory" ||
+              option.value.subdirectory ||
+              option.value.directory === project(),
+            onTrigger: remove,
+          },
+          {
+            command: "dialog.move_session.refresh",
+            title: "refresh",
+            onTrigger: () => void refetch(),
+          },
         ]}
-        footer={
-          <Show when={refreshing()}>
-            <box flexDirection="row" gap={1}>
-              <Show when={kv.get("animations_enabled", true)} fallback={<text fg={theme.textMuted}>⬝</text>}>
-                <spinner color={theme.textMuted} frames={REFRESH_FRAMES} interval={160} />
-              </Show>
-              <text fg={theme.textMuted}>refreshing</text>
-            </box>
-          </Show>
-        }
       />
     </box>
   )

+ 14 - 8
packages/opencode/src/cli/cmd/tui/component/dialog-workspace-file-changes.tsx

@@ -27,6 +27,8 @@ function changeCountWidth(file: VcsFileStatus) {
 export function DialogWorkspaceFileChanges(props: {
   files: VcsFileStatus[]
   onSelect: (choice: WorkspaceFileChangesChoice) => void
+  title?: string
+  message?: string
 }) {
   const dialog = useDialog()
   const { theme } = useTheme()
@@ -67,12 +69,17 @@ export function DialogWorkspaceFileChanges(props: {
     <box gap={1}>
       <box flexDirection="row" justifyContent="space-between" paddingLeft={2} paddingRight={2}>
         <text attributes={TextAttributes.BOLD} fg={theme.text}>
-          File Changes Found
+          {props.title ?? "File Changes Found"}
         </text>
         <text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
           esc
         </text>
       </box>
+      <box paddingLeft={2} paddingRight={2}>
+        <text fg={theme.textMuted} wrapMode="word">
+          {props.message ?? "Do you want to move these changes with the session?"}
+        </text>
+      </box>
       <scrollbox
         height={height()}
         backgroundColor={theme.backgroundElement}
@@ -101,11 +108,6 @@ export function DialogWorkspaceFileChanges(props: {
           )}
         </For>
       </scrollbox>
-      <box paddingLeft={2} paddingRight={2}>
-        <text fg={theme.textMuted} wrapMode="word">
-          Do you want to move these changes with the session?
-        </text>
-      </box>
       <box flexDirection="row" justifyContent="flex-end" paddingLeft={2} paddingRight={2} paddingBottom={1}>
         <For each={options}>
           {(item) => (
@@ -128,10 +130,14 @@ export function DialogWorkspaceFileChanges(props: {
   )
 }
 
-DialogWorkspaceFileChanges.show = (dialog: DialogContext, files: VcsFileStatus[]) => {
+DialogWorkspaceFileChanges.show = (
+  dialog: DialogContext,
+  files: VcsFileStatus[],
+  options?: { title?: string; message?: string },
+) => {
   return new Promise<WorkspaceFileChangesChoice | undefined>((resolve) => {
     dialog.replace(
-      () => <DialogWorkspaceFileChanges files={files} onSelect={resolve} />,
+      () => <DialogWorkspaceFileChanges files={files} onSelect={resolve} {...options} />,
       () => resolve(undefined),
     )
   })

+ 8 - 1
packages/opencode/src/cli/cmd/tui/component/prompt/move.tsx

@@ -57,7 +57,14 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
       <DialogMoveSession
         projectID={projectID}
         current={
-          homeDestination?.destination() ?? (session ? { type: "directory", directory: session.directory } : undefined)
+          homeDestination?.destination() ??
+          (session
+            ? {
+                type: "directory",
+                directory: session.directory,
+                subdirectory: !!session.path,
+              }
+            : undefined)
         }
         onSelect={(selection) => {
           const sessionID = input.sessionID()

+ 2 - 0
packages/opencode/src/cli/cmd/tui/config/keybind.ts

@@ -208,6 +208,8 @@ export const Definitions = {
   "dialog.prompt.submit": keybind("return", "Submit dialog prompt"),
   "dialog.mcp.toggle": keybind("space", "Toggle MCP in MCP dialog"),
   "dialog.move_session.new": keybind("ctrl+m", "New project copy"),
+  "dialog.move_session.delete": keybind("ctrl+d", "Delete project copy"),
+  "dialog.move_session.refresh": keybind("ctrl+r", "Refresh project copies"),
   "prompt.autocomplete.prev": keybind("up,ctrl+p", "Move to previous autocomplete item"),
   "prompt.autocomplete.next": keybind("down,ctrl+n", "Move to next autocomplete item"),
   "prompt.autocomplete.hide": keybind("escape", "Hide autocomplete"),

+ 2 - 0
packages/opencode/src/cli/cmd/tui/context/project.tsx

@@ -22,6 +22,7 @@ export const { use: useProject, provider: ProjectProvider } = createSimpleContex
     const [store, setStore] = createStore({
       project: {
         id: undefined as string | undefined,
+        worktree: undefined as string | undefined,
       },
       instance: {
         path: defaultPath,
@@ -43,6 +44,7 @@ export const { use: useProject, provider: ProjectProvider } = createSimpleContex
       batch(() => {
         setStore("instance", "path", reconcile(path.data || defaultPath))
         setStore("project", "id", project.data?.id)
+        setStore("project", "worktree", project.data?.worktree)
       })
     }
 

+ 8 - 8
packages/opencode/src/cli/cmd/tui/feature-plugins/system/plugins.tsx

@@ -205,19 +205,19 @@ function View(props: { api: TuiPluginApi }) {
       current={cur()}
       onMove={(item) => setCur(item.value)}
       actions={[
-        {
-          title: "toggle",
-          command: "plugins.toggle",
-          disabled: lock(),
+          {
+            title: "toggle",
+            command: "plugins.toggle",
+            hidden: lock(),
           onTrigger: (item) => {
             setCur(item.value)
             flip(item.value)
           },
         },
-        {
-          title: "install",
-          command: "dialog.plugins.install",
-          disabled: lock(),
+          {
+            title: "install",
+            command: "dialog.plugins.install",
+            hidden: lock(),
           onTrigger: () => {
             showInstall(props.api)
           },

+ 5 - 3
packages/opencode/src/cli/cmd/tui/routes/home/session-destination.tsx

@@ -9,7 +9,9 @@ import {
 } from "solid-js"
 import { useSync } from "../../context/sync"
 
-export type HomeSessionDestination = { type: "directory"; directory: string } | { type: "new" }
+export type HomeSessionDestination =
+  | { type: "directory"; directory: string; subdirectory: boolean }
+  | { type: "new" }
 
 type Context = {
   destination: Accessor<HomeSessionDestination | undefined>
@@ -22,8 +24,8 @@ const HomeSessionDestinationContext = createContext<Context>()
 export function HomeSessionDestinationProvider(props: ParentProps) {
   const sync = useSync()
   const [selected, setDestination] = createSignal<HomeSessionDestination>()
-  const destination = createMemo<HomeSessionDestination>(
-    () => selected() ?? { type: "directory", directory: sync.path.directory || process.cwd() },
+  const destination = createMemo<HomeSessionDestination>(() =>
+    selected() ?? { type: "directory", directory: sync.path.directory || process.cwd(), subdirectory: false },
   )
   return (
     <HomeSessionDestinationContext.Provider

+ 50 - 21
packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx

@@ -22,6 +22,7 @@ import { formatKeyBindings, useBindings, useKeymapSelector } from "../keymap"
 
 export interface DialogSelectProps<T> {
   title: string
+  titleView?: JSX.Element
   placeholder?: string
   footer?: JSX.Element
   options: DialogSelectOption<T>[]
@@ -32,11 +33,13 @@ export interface DialogSelectProps<T> {
   onSelect?: (option: DialogSelectOption<T>) => void
   skipFilter?: boolean
   renderFilter?: boolean
+  locked?: boolean
   actions?: {
     command: string
     title: string
     side?: "left" | "right"
-    disabled?: boolean
+    hidden?: boolean
+    disabled?: boolean | ((option: DialogSelectOption<T> | undefined) => boolean)
     onTrigger: (option: DialogSelectOption<T>) => void
   }[]
   footerHints?: {
@@ -108,17 +111,18 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
   let input: InputRenderable
 
   const actions = createMemo(() => props.actions ?? [])
+  const shownActions = createMemo(() => actions().filter((item) => !item.hidden))
   const actionBindings = useKeymapSelector((keymap) =>
     keymap.getCommandBindings({
       visibility: "registered",
-      commands: actions().map((item) => item.command),
+      commands: shownActions().map((item) => item.command),
     }),
   )
 
   const actionLabels = createMemo(() => {
     const labels = new Map<string, string>()
 
-    for (const action of actions()) {
+    for (const action of shownActions()) {
       const label = formatKeyBindings(actionBindings().get(action.command), tuiConfig)
       if (label) labels.set(action.command, label)
     }
@@ -126,12 +130,12 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
     return labels
   })
   const visibleActions = createMemo(() => [
-    ...actions()
+    ...shownActions()
       .map((item) => ({ ...item, label: actionLabels().get(item.command) ?? "" }))
-      .filter((item) => !item.disabled && item.label),
+      .filter((item) => item.label),
     ...(props.footerHints ?? []),
   ])
-  const actionItems = createMemo(() => visibleActions().filter(isActionItem))
+  const actionItems = createMemo(() => visibleActions().filter(isActionItem).filter((item) => !isActionDisabled(item)))
 
   createEffect(() => {
     const index = focusedAction()
@@ -217,6 +221,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
   )
 
   function move(direction: number) {
+    if (props.locked) return
     if (flat().length === 0) return
     let next = store.selected + direction
     if (next < 0) next = flat().length - 1
@@ -252,6 +257,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
   }
 
   function submit() {
+    if (props.locked) return
     setStore("input", "keyboard")
     const index = focusedAction()
     if (index !== undefined) {
@@ -265,6 +271,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
   }
 
   function moveAction(direction: 1 | -1) {
+    if (props.locked) return
     const total = actionItems().length
     if (total === 0) return
     setFocusedAction((index) => {
@@ -275,7 +282,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
   }
 
   useBindings(() => {
-    const enabledActions = actions().filter((item) => !item.disabled)
+    const visible = shownActions()
 
     return {
       commands: [
@@ -320,6 +327,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
           title: "First item",
           category: "Dialog",
           run() {
+            if (props.locked) return
             setStore("input", "keyboard")
             moveTo(0)
           },
@@ -329,6 +337,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
           title: "Last item",
           category: "Dialog",
           run() {
+            if (props.locked) return
             setStore("input", "keyboard")
             moveTo(flat().length - 1)
           },
@@ -339,11 +348,13 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
           category: "Dialog",
           run: submit,
         },
-        ...enabledActions.map((item) => ({
+        ...visible.map((item) => ({
           name: item.command,
           title: item.title,
           category: "Dialog",
           run() {
+            if (props.locked) return
+            if (isActionDisabled(item)) return
             setStore("input", "keyboard")
             const option = selected()
             if (!option) return
@@ -361,8 +372,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
           "dialog.select.end",
           "dialog.select.submit",
         ]),
-        ...enabledActions.flatMap((item) => tuiConfig.keybinds.get(item.command)),
-        ...(enabledActions.length
+        ...visible.flatMap((item) => tuiConfig.keybinds.get(item.command)),
+        ...(visible.length
           ? [
               {
                 key: "tab",
@@ -380,7 +391,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
           : []),
         ...(props.bindings ?? []).filter((binding) => {
           if (typeof binding.cmd !== "string") return true
-          return enabledActions.some((item) => item.command === binding.cmd)
+          return visible.some((item) => item.command === binding.cmd)
         }),
       ],
     }
@@ -408,7 +419,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
   const right = createMemo(() => visibleActions().filter((item) => item.side === "right"))
 
   function triggerAction(item: VisibleAction | undefined) {
-    if (!item || !isActionItem(item)) return
+    if (props.locked) return
+    if (!item || !isActionItem(item) || isActionDisabled(item)) return
     setStore("input", "keyboard")
     const option = selected()
     if (!option) return
@@ -419,7 +431,12 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
     return "onTrigger" in item
   }
 
+  function isActionDisabled(item: Action) {
+    return typeof item.disabled === "function" ? item.disabled(selected()) : item.disabled
+  }
+
   function isActionFocused(item: VisibleAction) {
+    if (props.locked) return false
     if (!isActionItem(item)) return false
     return actionItems().indexOf(item) === focusedAction()
   }
@@ -434,19 +451,24 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
           <span style={{ fg: theme.textMuted }}>{action.item.label}</span>
         </text>
       )
-    const active = createMemo(() => isActionFocused(action.item))
+    const item = action.item
+    const active = createMemo(() => isActionFocused(item))
+    const disabled = createMemo(() => isActionDisabled(item))
     const fg = selectedForeground(theme)
     return (
       <box
         flexDirection="row"
         paddingRight={1}
         backgroundColor={active() ? theme.primary : RGBA.fromInts(0, 0, 0, 0)}
-        onMouseUp={() => triggerAction(action.item)}
+        onMouseUp={() => triggerAction(item)}
       >
-        <text fg={active() ? fg : theme.text} attributes={active() ? TextAttributes.BOLD : undefined}>
-          {action.item.title}
+        <text
+          fg={disabled() ? theme.textMuted : active() ? fg : theme.text}
+          attributes={active() ? TextAttributes.BOLD : undefined}
+        >
+          {item.title}
         </text>
-        <text fg={active() ? fg : theme.textMuted}> {action.item.label}</text>
+        <text fg={disabled() ? theme.textMuted : active() ? fg : theme.textMuted}> {item.label}</text>
       </box>
     )
   }
@@ -455,9 +477,11 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
     <box gap={1} paddingBottom={1} flexGrow={1}>
       <box paddingLeft={4} paddingRight={4}>
         <box flexDirection="row" justifyContent="space-between">
-          <text fg={theme.text} attributes={TextAttributes.BOLD}>
-            {props.title}
-          </text>
+          {props.titleView ?? (
+            <text fg={theme.text} attributes={TextAttributes.BOLD}>
+              {props.title}
+            </text>
+          )}
           <text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
             esc
           </text>
@@ -466,6 +490,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
           <box paddingTop={1}>
             <input
               onInput={(e) => {
+                if (props.locked) return
                 batch(() => {
                   setStore("filter", e)
                   props.onFilter?.(e)
@@ -525,7 +550,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
                   </Show>
                   <For each={options}>
                     {(option) => {
-                      const active = createMemo(() => isDeepEqual(option.value, selected()?.value))
+                       const active = createMemo(() => !props.locked && isDeepEqual(option.value, selected()?.value))
                       const current = createMemo(() => isDeepEqual(option.value, props.current))
                       return (
                         <box
@@ -533,20 +558,24 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
                           flexDirection="column"
                           position="relative"
                           onMouseMove={() => {
+                            if (props.locked) return
                             setStore("input", "mouse")
                             setFocusedAction(undefined)
                           }}
                           onMouseUp={() => {
+                            if (props.locked) return
                             option.onSelect?.(dialog)
                             props.onSelect?.(option)
                           }}
                           onMouseOver={() => {
+                            if (props.locked) return
                             if (store.input !== "mouse") return
                             const index = flat().findIndex((x) => isDeepEqual(x.value, option.value))
                             if (index === -1) return
                             moveTo(index)
                           }}
                           onMouseDown={() => {
+                            if (props.locked) return
                             const index = flat().findIndex((x) => isDeepEqual(x.value, option.value))
                             if (index === -1) return
                             moveTo(index)

+ 5 - 3
packages/opencode/src/server/routes/instance/httpapi/groups/project-copy.ts

@@ -12,7 +12,7 @@ import {
 import { described } from "./metadata"
 
 const root = "/experimental/project/:projectID/copy"
-const CreateQuery = Schema.Struct({
+const CopyQuery = Schema.Struct({
   workspace: WorkspaceRoutingQueryFields.workspace,
 })
 
@@ -24,6 +24,7 @@ export const CreatePayload = Schema.Struct({
 })
 export const RemovePayload = Schema.Struct({
   directory: ProjectCopy.RemoveInput.fields.directory,
+  force: ProjectCopy.RemoveInput.fields.force,
 })
 
 export class ApiProjectCopyError extends Schema.ErrorClass<ApiProjectCopyError>("ProjectCopyError")(
@@ -31,6 +32,7 @@ export class ApiProjectCopyError extends Schema.ErrorClass<ApiProjectCopyError>(
     name: Schema.Literal("ProjectCopyError"),
     data: Schema.Struct({
       message: Schema.String,
+      forceRequired: Schema.optional(Schema.Boolean),
     }),
   },
   { httpApiStatus: 400 },
@@ -41,7 +43,7 @@ export const ProjectCopyApi = HttpApi.make("projectCopy").add(
     .add(
       HttpApiEndpoint.post("create", root, {
         params: { projectID: ProjectV2.ID },
-        query: CreateQuery,
+        query: CopyQuery,
         payload: CreatePayload,
         success: described(ProjectCopy.Copy, "Project copy created"),
         error: ApiProjectCopyError,
@@ -54,7 +56,7 @@ export const ProjectCopyApi = HttpApi.make("projectCopy").add(
       ),
       HttpApiEndpoint.delete("remove", root, {
         params: { projectID: ProjectV2.ID },
-        query: WorkspaceRoutingQuery,
+        query: CopyQuery,
         payload: RemovePayload,
         success: described(HttpApiSchema.NoContent, "Project copy removed"),
         error: ApiProjectCopyError,

+ 5 - 1
packages/opencode/src/server/routes/instance/httpapi/handlers/project-copy.ts

@@ -1,4 +1,5 @@
 import { ProjectCopy } from "@opencode-ai/core/project/copy"
+import { Git } from "@opencode-ai/core/git"
 import { ProjectV2 } from "@opencode-ai/core/project"
 import { AbsolutePath } from "@opencode-ai/core/schema"
 import { InstanceState } from "@/effect/instance-state"
@@ -28,7 +29,10 @@ function badRequest<A, R>(effect: Effect.Effect<A, ProjectCopy.Error, R>) {
       (error) =>
         new ApiProjectCopyError({
           name: "ProjectCopyError",
-          data: { message: message(error) },
+          data: {
+            message: message(error),
+            forceRequired: error instanceof Git.WorktreeError ? error.forceRequired : undefined,
+          },
         }),
     ),
   )

+ 14 - 2
packages/opencode/test/server/project-copy.test.ts

@@ -65,12 +65,24 @@ describe("project directories and copies endpoints", () => {
         const listed = yield* request(test.directory, `${base}/directories`)
         expect(yield* json<string[]>(listed)).toContain(created.directory)
 
+        yield* Effect.promise(() => Bun.write(path.join(created.directory, "dirty.txt"), "dirty"))
+
         const remove = yield* request(test.directory, copies, {
           method: "DELETE",
           headers: { "content-type": "application/json" },
-          body: JSON.stringify({ directory: created.directory }),
+          body: JSON.stringify({ directory: created.directory, force: false }),
+        })
+        expect(remove.status).toBe(400)
+        expect(yield* json<{ data: { forceRequired?: boolean } }>(remove)).toMatchObject({
+          data: { forceRequired: true },
+        })
+
+        const forced = yield* request(test.directory, copies, {
+          method: "DELETE",
+          headers: { "content-type": "application/json" },
+          body: JSON.stringify({ directory: created.directory, force: true }),
         })
-        expect(remove.status).toBe(204)
+        expect(forced.status).toBe(204)
 
         const externalDirectory = path.join(test.directory, "..", path.basename(test.directory) + "-http-refresh")
         yield* Effect.addFinalizer(() =>

+ 4 - 12
packages/sdk/js/src/v2/gen/sdk.gen.ts

@@ -818,9 +818,9 @@ export class ProjectCopy extends HeyApiClient {
   public remove<ThrowOnError extends boolean = false>(
     parameters: {
       projectID: string
-      query_directory?: string
       workspace?: string
-      body_directory?: string
+      directory?: string
+      force?: boolean
     },
     options?: Options<never, ThrowOnError>,
   ) {
@@ -830,17 +830,9 @@ export class ProjectCopy extends HeyApiClient {
         {
           args: [
             { in: "path", key: "projectID" },
-            {
-              in: "query",
-              key: "query_directory",
-              map: "directory",
-            },
             { in: "query", key: "workspace" },
-            {
-              in: "body",
-              key: "body_directory",
-              map: "directory",
-            },
+            { in: "body", key: "directory" },
+            { in: "body", key: "force" },
           ],
         },
       ],

+ 2 - 1
packages/sdk/js/src/v2/gen/types.gen.ts

@@ -2484,6 +2484,7 @@ export type ProjectCopyError = {
   name: "ProjectCopyError"
   data: {
     message: string
+    forceRequired?: boolean
   }
 }
 
@@ -6971,12 +6972,12 @@ export type ProjectDirectoriesResponse = ProjectDirectoriesResponses[keyof Proje
 export type ExperimentalProjectCopyRemoveData = {
   body?: {
     directory: string
+    force: boolean
   }
   path: {
     projectID: string
   }
   query?: {
-    directory?: string
     workspace?: string
   }
   url: "/experimental/project/{projectID}/copy"