feat(tui): delete working copies from move dialog (#31017)
This commit is contained in:
@@ -30,6 +30,7 @@ export class WorktreeError extends Schema.TaggedErrorClass<WorktreeError>()("Git
|
|||||||
operation: Schema.Literals(["create", "remove", "list"]),
|
operation: Schema.Literals(["create", "remove", "list"]),
|
||||||
message: Schema.String,
|
message: Schema.String,
|
||||||
directory: Schema.optional(AbsolutePath),
|
directory: Schema.optional(AbsolutePath),
|
||||||
|
forceRequired: Schema.optional(Schema.Boolean),
|
||||||
cause: Schema.optional(Schema.Defect),
|
cause: Schema.optional(Schema.Defect),
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
@@ -64,7 +65,11 @@ export interface Interface {
|
|||||||
readonly resetChanges: (directory: AbsolutePath) => Effect.Effect<void, PatchError>
|
readonly resetChanges: (directory: AbsolutePath) => Effect.Effect<void, PatchError>
|
||||||
readonly softResetChanges: (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 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>
|
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")
|
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({
|
return yield* new WorktreeError({
|
||||||
operation,
|
operation,
|
||||||
directory: worktreeDirectory,
|
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)
|
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(
|
yield* worktree(
|
||||||
"remove",
|
"remove",
|
||||||
input.repo,
|
input.repo,
|
||||||
["worktree", "remove", "--force", input.directory],
|
["worktree", "remove", ...(input.force ? ["--force"] : []), input.directory],
|
||||||
input.directory,
|
input.directory,
|
||||||
input.repo.store,
|
input.repo.store,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -19,10 +19,10 @@ export function makeStrategies(input: {
|
|||||||
yield* input.git.worktreeCreate({ repo: repo(options.sourceDirectory), directory: options.directory })
|
yield* input.git.worktreeCreate({ repo: repo(options.sourceDirectory), directory: options.directory })
|
||||||
return { directory: yield* input.canonical(options.directory) }
|
return { directory: yield* input.canonical(options.directory) }
|
||||||
}),
|
}),
|
||||||
remove: Effect.fn("ProjectCopy.GitWorktree.remove")(function* (directory) {
|
remove: Effect.fn("ProjectCopy.GitWorktree.remove")(function* (options) {
|
||||||
const found = yield* input.git.find(directory)
|
const found = yield* input.git.find(options.directory)
|
||||||
if (!found) return yield* new DirectoryUnavailableError({ directory })
|
if (!found) return yield* new DirectoryUnavailableError({ directory: options.directory })
|
||||||
yield* input.git.worktreeRemove({ repo: found, directory })
|
yield* input.git.worktreeRemove({ repo: found, directory: options.directory, force: options.force })
|
||||||
}),
|
}),
|
||||||
list: Effect.fn("ProjectCopy.GitWorktree.list")(function* (directory) {
|
list: Effect.fn("ProjectCopy.GitWorktree.list")(function* (directory) {
|
||||||
const found = yield* input.git.find(directory)
|
const found = yield* input.git.find(directory)
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export type CreateInput = typeof CreateInput.Type
|
|||||||
export const RemoveInput = Schema.Struct({
|
export const RemoveInput = Schema.Struct({
|
||||||
projectID: Project.ID,
|
projectID: Project.ID,
|
||||||
directory: AbsolutePath,
|
directory: AbsolutePath,
|
||||||
|
force: Schema.Boolean,
|
||||||
}).annotate({ identifier: "ProjectCopy.RemoveInput" })
|
}).annotate({ identifier: "ProjectCopy.RemoveInput" })
|
||||||
export type RemoveInput = typeof RemoveInput.Type
|
export type RemoveInput = typeof RemoveInput.Type
|
||||||
|
|
||||||
@@ -82,7 +83,10 @@ export interface Strategy {
|
|||||||
sourceDirectory: AbsolutePath
|
sourceDirectory: AbsolutePath
|
||||||
directory: AbsolutePath
|
directory: AbsolutePath
|
||||||
}) => Effect.Effect<Copy, Git.WorktreeError | DirectoryUnavailableError>
|
}) => 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 list: (directory: AbsolutePath) => Effect.Effect<Copy[], Git.WorktreeError | DirectoryUnavailableError>
|
||||||
readonly detect: (directory: AbsolutePath) => Effect.Effect<boolean>
|
readonly detect: (directory: AbsolutePath) => Effect.Effect<boolean>
|
||||||
}
|
}
|
||||||
@@ -209,7 +213,7 @@ export const layer = Layer.effect(
|
|||||||
const copyDirectory = yield* canonical(input.directory)
|
const copyDirectory = yield* canonical(input.directory)
|
||||||
const id = yield* detect({ directory: copyDirectory })
|
const id = yield* detect({ directory: copyDirectory })
|
||||||
if (!id) return yield* new StrategyNotFoundError({ 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))
|
yield* changed(input.projectID, yield* removeStored(input.projectID, copyDirectory))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ describe("Git worktrees", () => {
|
|||||||
expect(linked?.directory).toBe(AbsolutePath.make(yield* Effect.promise(() => fs.realpath(worktree))))
|
expect(linked?.directory).toBe(AbsolutePath.make(yield* Effect.promise(() => fs.realpath(worktree))))
|
||||||
expect(linked?.store).toBe(repo.store)
|
expect(linked?.store).toBe(repo.store)
|
||||||
if (!linked) throw new Error("Linked worktree not found")
|
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)
|
expect((yield* git.worktreeList(repo)).some((entry) => entry.endsWith("-git-worktree"))).toBe(false)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -124,13 +124,48 @@ describe("ProjectCopy", () => {
|
|||||||
)
|
)
|
||||||
expect(Array.from(yield* Fiber.join(fiber))[0]?.data).toEqual({ projectID: input.projectID })
|
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* stored(input.projectID)).toEqual([{ directory: input.sourceDirectory, type: "main" as const }])
|
||||||
expect(yield* Effect.promise(() => Bun.file(target).exists())).toBe(false)
|
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", () =>
|
it.live("adds a numeric suffix when a copy directory already exists", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const input = yield* setup()
|
const input = yield* setup()
|
||||||
@@ -160,7 +195,7 @@ describe("ProjectCopy", () => {
|
|||||||
true,
|
true,
|
||||||
)
|
)
|
||||||
|
|
||||||
yield* copy.remove({ projectID: input.projectID, directory: created.directory })
|
yield* copy.remove({ projectID: input.projectID, directory: created.directory, force: false })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ export function DialogModel(props: { providerID?: string }) {
|
|||||||
{
|
{
|
||||||
command: "model.dialog.favorite",
|
command: "model.dialog.favorite",
|
||||||
title: "Favorite",
|
title: "Favorite",
|
||||||
disabled: !connected(),
|
hidden: !connected(),
|
||||||
onTrigger: (option) => {
|
onTrigger: (option) => {
|
||||||
local.model.toggleFavorite(option.value as { providerID: string; modelID: string })
|
local.model.toggleFavorite(option.value as { providerID: string; modelID: string })
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,56 +1,82 @@
|
|||||||
import { useTerminalDimensions } from "@opentui/solid"
|
import { useTerminalDimensions } from "@opentui/solid"
|
||||||
|
import { TextAttributes } from "@opentui/core"
|
||||||
import { createMemo, createResource, createSignal, onMount, Show } from "solid-js"
|
import { createMemo, createResource, createSignal, onMount, Show } from "solid-js"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select"
|
import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select"
|
||||||
import { useDialog } from "@tui/ui/dialog"
|
import { useDialog } from "@tui/ui/dialog"
|
||||||
import { useSDK } from "@tui/context/sdk"
|
import { useSDK } from "@tui/context/sdk"
|
||||||
import { useTheme } from "@tui/context/theme"
|
import { useTheme } from "@tui/context/theme"
|
||||||
import { useKV } from "@tui/context/kv"
|
|
||||||
import { useSync } from "@tui/context/sync"
|
import { useSync } from "@tui/context/sync"
|
||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/core/global"
|
||||||
import { Locale } from "@/util/locale"
|
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; subdirectory: boolean }
|
||||||
export type MoveSessionSelection = { type: "directory"; directory: string } | { type: "new" }
|
| { type: "new" }
|
||||||
|
|
||||||
export function DialogMoveSession(props: {
|
export function DialogMoveSession(props: {
|
||||||
projectID: string
|
projectID: string
|
||||||
current?: MoveSessionSelection
|
current?: MoveSessionSelection
|
||||||
onSelect: (selection: MoveSessionSelection) => void
|
onSelect: (selection: MoveSessionSelection) => void
|
||||||
|
initialDirectories?: string[]
|
||||||
|
initialRemoving?: string
|
||||||
}) {
|
}) {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const dimensions = useTerminalDimensions()
|
const dimensions = useTerminalDimensions()
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const kv = useKV()
|
|
||||||
const sync = useSync()
|
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")
|
||||||
|
|
||||||
const [directories] = createResource(
|
function reopen(initialRemoving?: string) {
|
||||||
() => props.projectID,
|
dialog.replace(() => (
|
||||||
|
<DialogMoveSession {...props} initialDirectories={directories()} initialRemoving={initialRemoving} />
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
const [loadedProject] = createResource(
|
||||||
|
() => (projectContext.project() === props.projectID ? undefined : props.projectID),
|
||||||
async (projectID) => {
|
async (projectID) => {
|
||||||
setRefreshing(true)
|
const result = await sdk.client.project.current({}, { throwOnError: true })
|
||||||
const [, project] = await Promise.all([
|
return result.data?.id === projectID ? result.data.worktree : undefined
|
||||||
sdk.client.experimental.projectCopy
|
},
|
||||||
.refresh({ projectID }, { throwOnError: true })
|
)
|
||||||
.finally(() => setRefreshing(false)),
|
const project = createMemo(() =>
|
||||||
sdk.client.project.current({}, { throwOnError: true }),
|
projectContext.project() === props.projectID ? projectContext.data.project.worktree : loadedProject(),
|
||||||
])
|
)
|
||||||
const directories = await sdk.client.project.directories({ projectID }, { throwOnError: true })
|
|
||||||
return {
|
const [directories, { refetch }] = createResource(
|
||||||
directories: directories.data ?? [],
|
() => (props.initialRemoving ? undefined : props.projectID),
|
||||||
main: project.data?.id === projectID ? project.data.worktree : undefined,
|
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>[]>(() => {
|
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 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 }]
|
if (roots.length === 0) return [{ title: "No project directories found", value: undefined }]
|
||||||
const subdirectories = sync.data.session
|
const subdirectories = sync.data.session
|
||||||
.filter((session) => session.projectID === props.projectID && session.path && ![".", "/"].includes(session.path))
|
.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 suffix = item.location === item.root ? undefined : path.sep + path.relative(item.root, item.location)
|
||||||
const visible = Locale.truncateLeft(title, titleWidth)
|
const visible = Locale.truncateLeft(title, titleWidth)
|
||||||
const split = suffix ? Math.max(0, visible.length - suffix.length) : visible.length
|
const split = suffix ? Math.max(0, visible.length - suffix.length) : visible.length
|
||||||
|
const deleting = toDelete() === item.location
|
||||||
|
const isRemoving = removing() === item.location
|
||||||
return {
|
return {
|
||||||
title,
|
title: isRemoving ? `Deleting ${item.location}` : deleting ? `Press ${deleteHint()} again to confirm` : title,
|
||||||
titleView: suffix ? (
|
titleView:
|
||||||
<>
|
isRemoving ? (
|
||||||
{visible.slice(0, split)}
|
<span style={{ fg: theme.error }}>Deleting {item.location}</span>
|
||||||
<span style={{ fg: theme.textMuted }}>{visible.slice(split)}</span>
|
) : !deleting && suffix ? (
|
||||||
</>
|
<>
|
||||||
) : undefined,
|
{visible.slice(0, split)}
|
||||||
value: { type: "directory", directory: item.location } as const,
|
<span style={{ fg: theme.textMuted }}>{visible.slice(split)}</span>
|
||||||
category: item.root === data?.main ? "Project" : "Working copies",
|
</>
|
||||||
|
) : 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,
|
titleWidth,
|
||||||
truncateTitle: "left" as const,
|
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"))
|
onMount(() => dialog.setSize("xlarge"))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<box minHeight={Math.max(8, Math.min(16, dimensions().height - Math.floor(dimensions().height / 4) - 2))}>
|
<box minHeight={Math.max(8, Math.min(16, dimensions().height - Math.floor(dimensions().height / 4) - 2))}>
|
||||||
<DialogSelect
|
<DialogSelect
|
||||||
title="Move session"
|
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()}
|
options={options()}
|
||||||
current={props.current}
|
locked={directories.loading || loadedProject.loading || Boolean(removing())}
|
||||||
|
current={current()}
|
||||||
onSelect={(option) => {
|
onSelect={(option) => {
|
||||||
if (option.value) props.onSelect(option.value)
|
if (option.value) props.onSelect(option.value)
|
||||||
}}
|
}}
|
||||||
|
onMove={() => setToDelete(undefined)}
|
||||||
actions={[
|
actions={[
|
||||||
{
|
{
|
||||||
command: "dialog.move_session.new",
|
command: "dialog.move_session.new",
|
||||||
title: "new",
|
title: "new",
|
||||||
onTrigger: () => props.onSelect({ type: "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>
|
</box>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ function changeCountWidth(file: VcsFileStatus) {
|
|||||||
export function DialogWorkspaceFileChanges(props: {
|
export function DialogWorkspaceFileChanges(props: {
|
||||||
files: VcsFileStatus[]
|
files: VcsFileStatus[]
|
||||||
onSelect: (choice: WorkspaceFileChangesChoice) => void
|
onSelect: (choice: WorkspaceFileChangesChoice) => void
|
||||||
|
title?: string
|
||||||
|
message?: string
|
||||||
}) {
|
}) {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
@@ -67,12 +69,17 @@ export function DialogWorkspaceFileChanges(props: {
|
|||||||
<box gap={1}>
|
<box gap={1}>
|
||||||
<box flexDirection="row" justifyContent="space-between" paddingLeft={2} paddingRight={2}>
|
<box flexDirection="row" justifyContent="space-between" paddingLeft={2} paddingRight={2}>
|
||||||
<text attributes={TextAttributes.BOLD} fg={theme.text}>
|
<text attributes={TextAttributes.BOLD} fg={theme.text}>
|
||||||
File Changes Found
|
{props.title ?? "File Changes Found"}
|
||||||
</text>
|
</text>
|
||||||
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
|
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
|
||||||
esc
|
esc
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</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
|
<scrollbox
|
||||||
height={height()}
|
height={height()}
|
||||||
backgroundColor={theme.backgroundElement}
|
backgroundColor={theme.backgroundElement}
|
||||||
@@ -101,11 +108,6 @@ export function DialogWorkspaceFileChanges(props: {
|
|||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
</scrollbox>
|
</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}>
|
<box flexDirection="row" justifyContent="flex-end" paddingLeft={2} paddingRight={2} paddingBottom={1}>
|
||||||
<For each={options}>
|
<For each={options}>
|
||||||
{(item) => (
|
{(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) => {
|
return new Promise<WorkspaceFileChangesChoice | undefined>((resolve) => {
|
||||||
dialog.replace(
|
dialog.replace(
|
||||||
() => <DialogWorkspaceFileChanges files={files} onSelect={resolve} />,
|
() => <DialogWorkspaceFileChanges files={files} onSelect={resolve} {...options} />,
|
||||||
() => resolve(undefined),
|
() => resolve(undefined),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -57,7 +57,14 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
|
|||||||
<DialogMoveSession
|
<DialogMoveSession
|
||||||
projectID={projectID}
|
projectID={projectID}
|
||||||
current={
|
current={
|
||||||
homeDestination?.destination() ?? (session ? { type: "directory", directory: session.directory } : undefined)
|
homeDestination?.destination() ??
|
||||||
|
(session
|
||||||
|
? {
|
||||||
|
type: "directory",
|
||||||
|
directory: session.directory,
|
||||||
|
subdirectory: !!session.path,
|
||||||
|
}
|
||||||
|
: undefined)
|
||||||
}
|
}
|
||||||
onSelect={(selection) => {
|
onSelect={(selection) => {
|
||||||
const sessionID = input.sessionID()
|
const sessionID = input.sessionID()
|
||||||
|
|||||||
@@ -208,6 +208,8 @@ export const Definitions = {
|
|||||||
"dialog.prompt.submit": keybind("return", "Submit dialog prompt"),
|
"dialog.prompt.submit": keybind("return", "Submit dialog prompt"),
|
||||||
"dialog.mcp.toggle": keybind("space", "Toggle MCP in MCP dialog"),
|
"dialog.mcp.toggle": keybind("space", "Toggle MCP in MCP dialog"),
|
||||||
"dialog.move_session.new": keybind("ctrl+m", "New project copy"),
|
"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.prev": keybind("up,ctrl+p", "Move to previous autocomplete item"),
|
||||||
"prompt.autocomplete.next": keybind("down,ctrl+n", "Move to next autocomplete item"),
|
"prompt.autocomplete.next": keybind("down,ctrl+n", "Move to next autocomplete item"),
|
||||||
"prompt.autocomplete.hide": keybind("escape", "Hide autocomplete"),
|
"prompt.autocomplete.hide": keybind("escape", "Hide autocomplete"),
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export const { use: useProject, provider: ProjectProvider } = createSimpleContex
|
|||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
project: {
|
project: {
|
||||||
id: undefined as string | undefined,
|
id: undefined as string | undefined,
|
||||||
|
worktree: undefined as string | undefined,
|
||||||
},
|
},
|
||||||
instance: {
|
instance: {
|
||||||
path: defaultPath,
|
path: defaultPath,
|
||||||
@@ -43,6 +44,7 @@ export const { use: useProject, provider: ProjectProvider } = createSimpleContex
|
|||||||
batch(() => {
|
batch(() => {
|
||||||
setStore("instance", "path", reconcile(path.data || defaultPath))
|
setStore("instance", "path", reconcile(path.data || defaultPath))
|
||||||
setStore("project", "id", project.data?.id)
|
setStore("project", "id", project.data?.id)
|
||||||
|
setStore("project", "worktree", project.data?.worktree)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -205,19 +205,19 @@ function View(props: { api: TuiPluginApi }) {
|
|||||||
current={cur()}
|
current={cur()}
|
||||||
onMove={(item) => setCur(item.value)}
|
onMove={(item) => setCur(item.value)}
|
||||||
actions={[
|
actions={[
|
||||||
{
|
{
|
||||||
title: "toggle",
|
title: "toggle",
|
||||||
command: "plugins.toggle",
|
command: "plugins.toggle",
|
||||||
disabled: lock(),
|
hidden: lock(),
|
||||||
onTrigger: (item) => {
|
onTrigger: (item) => {
|
||||||
setCur(item.value)
|
setCur(item.value)
|
||||||
flip(item.value)
|
flip(item.value)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "install",
|
title: "install",
|
||||||
command: "dialog.plugins.install",
|
command: "dialog.plugins.install",
|
||||||
disabled: lock(),
|
hidden: lock(),
|
||||||
onTrigger: () => {
|
onTrigger: () => {
|
||||||
showInstall(props.api)
|
showInstall(props.api)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ import {
|
|||||||
} from "solid-js"
|
} from "solid-js"
|
||||||
import { useSync } from "../../context/sync"
|
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 = {
|
type Context = {
|
||||||
destination: Accessor<HomeSessionDestination | undefined>
|
destination: Accessor<HomeSessionDestination | undefined>
|
||||||
@@ -22,8 +24,8 @@ const HomeSessionDestinationContext = createContext<Context>()
|
|||||||
export function HomeSessionDestinationProvider(props: ParentProps) {
|
export function HomeSessionDestinationProvider(props: ParentProps) {
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const [selected, setDestination] = createSignal<HomeSessionDestination>()
|
const [selected, setDestination] = createSignal<HomeSessionDestination>()
|
||||||
const destination = createMemo<HomeSessionDestination>(
|
const destination = createMemo<HomeSessionDestination>(() =>
|
||||||
() => selected() ?? { type: "directory", directory: sync.path.directory || process.cwd() },
|
selected() ?? { type: "directory", directory: sync.path.directory || process.cwd(), subdirectory: false },
|
||||||
)
|
)
|
||||||
return (
|
return (
|
||||||
<HomeSessionDestinationContext.Provider
|
<HomeSessionDestinationContext.Provider
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { formatKeyBindings, useBindings, useKeymapSelector } from "../keymap"
|
|||||||
|
|
||||||
export interface DialogSelectProps<T> {
|
export interface DialogSelectProps<T> {
|
||||||
title: string
|
title: string
|
||||||
|
titleView?: JSX.Element
|
||||||
placeholder?: string
|
placeholder?: string
|
||||||
footer?: JSX.Element
|
footer?: JSX.Element
|
||||||
options: DialogSelectOption<T>[]
|
options: DialogSelectOption<T>[]
|
||||||
@@ -32,11 +33,13 @@ export interface DialogSelectProps<T> {
|
|||||||
onSelect?: (option: DialogSelectOption<T>) => void
|
onSelect?: (option: DialogSelectOption<T>) => void
|
||||||
skipFilter?: boolean
|
skipFilter?: boolean
|
||||||
renderFilter?: boolean
|
renderFilter?: boolean
|
||||||
|
locked?: boolean
|
||||||
actions?: {
|
actions?: {
|
||||||
command: string
|
command: string
|
||||||
title: string
|
title: string
|
||||||
side?: "left" | "right"
|
side?: "left" | "right"
|
||||||
disabled?: boolean
|
hidden?: boolean
|
||||||
|
disabled?: boolean | ((option: DialogSelectOption<T> | undefined) => boolean)
|
||||||
onTrigger: (option: DialogSelectOption<T>) => void
|
onTrigger: (option: DialogSelectOption<T>) => void
|
||||||
}[]
|
}[]
|
||||||
footerHints?: {
|
footerHints?: {
|
||||||
@@ -108,17 +111,18 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
|||||||
let input: InputRenderable
|
let input: InputRenderable
|
||||||
|
|
||||||
const actions = createMemo(() => props.actions ?? [])
|
const actions = createMemo(() => props.actions ?? [])
|
||||||
|
const shownActions = createMemo(() => actions().filter((item) => !item.hidden))
|
||||||
const actionBindings = useKeymapSelector((keymap) =>
|
const actionBindings = useKeymapSelector((keymap) =>
|
||||||
keymap.getCommandBindings({
|
keymap.getCommandBindings({
|
||||||
visibility: "registered",
|
visibility: "registered",
|
||||||
commands: actions().map((item) => item.command),
|
commands: shownActions().map((item) => item.command),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const actionLabels = createMemo(() => {
|
const actionLabels = createMemo(() => {
|
||||||
const labels = new Map<string, string>()
|
const labels = new Map<string, string>()
|
||||||
|
|
||||||
for (const action of actions()) {
|
for (const action of shownActions()) {
|
||||||
const label = formatKeyBindings(actionBindings().get(action.command), tuiConfig)
|
const label = formatKeyBindings(actionBindings().get(action.command), tuiConfig)
|
||||||
if (label) labels.set(action.command, label)
|
if (label) labels.set(action.command, label)
|
||||||
}
|
}
|
||||||
@@ -126,12 +130,12 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
|||||||
return labels
|
return labels
|
||||||
})
|
})
|
||||||
const visibleActions = createMemo(() => [
|
const visibleActions = createMemo(() => [
|
||||||
...actions()
|
...shownActions()
|
||||||
.map((item) => ({ ...item, label: actionLabels().get(item.command) ?? "" }))
|
.map((item) => ({ ...item, label: actionLabels().get(item.command) ?? "" }))
|
||||||
.filter((item) => !item.disabled && item.label),
|
.filter((item) => item.label),
|
||||||
...(props.footerHints ?? []),
|
...(props.footerHints ?? []),
|
||||||
])
|
])
|
||||||
const actionItems = createMemo(() => visibleActions().filter(isActionItem))
|
const actionItems = createMemo(() => visibleActions().filter(isActionItem).filter((item) => !isActionDisabled(item)))
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const index = focusedAction()
|
const index = focusedAction()
|
||||||
@@ -217,6 +221,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
function move(direction: number) {
|
function move(direction: number) {
|
||||||
|
if (props.locked) return
|
||||||
if (flat().length === 0) return
|
if (flat().length === 0) return
|
||||||
let next = store.selected + direction
|
let next = store.selected + direction
|
||||||
if (next < 0) next = flat().length - 1
|
if (next < 0) next = flat().length - 1
|
||||||
@@ -252,6 +257,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function submit() {
|
function submit() {
|
||||||
|
if (props.locked) return
|
||||||
setStore("input", "keyboard")
|
setStore("input", "keyboard")
|
||||||
const index = focusedAction()
|
const index = focusedAction()
|
||||||
if (index !== undefined) {
|
if (index !== undefined) {
|
||||||
@@ -265,6 +271,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function moveAction(direction: 1 | -1) {
|
function moveAction(direction: 1 | -1) {
|
||||||
|
if (props.locked) return
|
||||||
const total = actionItems().length
|
const total = actionItems().length
|
||||||
if (total === 0) return
|
if (total === 0) return
|
||||||
setFocusedAction((index) => {
|
setFocusedAction((index) => {
|
||||||
@@ -275,7 +282,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useBindings(() => {
|
useBindings(() => {
|
||||||
const enabledActions = actions().filter((item) => !item.disabled)
|
const visible = shownActions()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
commands: [
|
commands: [
|
||||||
@@ -320,6 +327,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
|||||||
title: "First item",
|
title: "First item",
|
||||||
category: "Dialog",
|
category: "Dialog",
|
||||||
run() {
|
run() {
|
||||||
|
if (props.locked) return
|
||||||
setStore("input", "keyboard")
|
setStore("input", "keyboard")
|
||||||
moveTo(0)
|
moveTo(0)
|
||||||
},
|
},
|
||||||
@@ -329,6 +337,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
|||||||
title: "Last item",
|
title: "Last item",
|
||||||
category: "Dialog",
|
category: "Dialog",
|
||||||
run() {
|
run() {
|
||||||
|
if (props.locked) return
|
||||||
setStore("input", "keyboard")
|
setStore("input", "keyboard")
|
||||||
moveTo(flat().length - 1)
|
moveTo(flat().length - 1)
|
||||||
},
|
},
|
||||||
@@ -339,11 +348,13 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
|||||||
category: "Dialog",
|
category: "Dialog",
|
||||||
run: submit,
|
run: submit,
|
||||||
},
|
},
|
||||||
...enabledActions.map((item) => ({
|
...visible.map((item) => ({
|
||||||
name: item.command,
|
name: item.command,
|
||||||
title: item.title,
|
title: item.title,
|
||||||
category: "Dialog",
|
category: "Dialog",
|
||||||
run() {
|
run() {
|
||||||
|
if (props.locked) return
|
||||||
|
if (isActionDisabled(item)) return
|
||||||
setStore("input", "keyboard")
|
setStore("input", "keyboard")
|
||||||
const option = selected()
|
const option = selected()
|
||||||
if (!option) return
|
if (!option) return
|
||||||
@@ -361,8 +372,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
|||||||
"dialog.select.end",
|
"dialog.select.end",
|
||||||
"dialog.select.submit",
|
"dialog.select.submit",
|
||||||
]),
|
]),
|
||||||
...enabledActions.flatMap((item) => tuiConfig.keybinds.get(item.command)),
|
...visible.flatMap((item) => tuiConfig.keybinds.get(item.command)),
|
||||||
...(enabledActions.length
|
...(visible.length
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
key: "tab",
|
key: "tab",
|
||||||
@@ -380,7 +391,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
|||||||
: []),
|
: []),
|
||||||
...(props.bindings ?? []).filter((binding) => {
|
...(props.bindings ?? []).filter((binding) => {
|
||||||
if (typeof binding.cmd !== "string") return true
|
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"))
|
const right = createMemo(() => visibleActions().filter((item) => item.side === "right"))
|
||||||
|
|
||||||
function triggerAction(item: VisibleAction | undefined) {
|
function triggerAction(item: VisibleAction | undefined) {
|
||||||
if (!item || !isActionItem(item)) return
|
if (props.locked) return
|
||||||
|
if (!item || !isActionItem(item) || isActionDisabled(item)) return
|
||||||
setStore("input", "keyboard")
|
setStore("input", "keyboard")
|
||||||
const option = selected()
|
const option = selected()
|
||||||
if (!option) return
|
if (!option) return
|
||||||
@@ -419,7 +431,12 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
|||||||
return "onTrigger" in item
|
return "onTrigger" in item
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isActionDisabled(item: Action) {
|
||||||
|
return typeof item.disabled === "function" ? item.disabled(selected()) : item.disabled
|
||||||
|
}
|
||||||
|
|
||||||
function isActionFocused(item: VisibleAction) {
|
function isActionFocused(item: VisibleAction) {
|
||||||
|
if (props.locked) return false
|
||||||
if (!isActionItem(item)) return false
|
if (!isActionItem(item)) return false
|
||||||
return actionItems().indexOf(item) === focusedAction()
|
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>
|
<span style={{ fg: theme.textMuted }}>{action.item.label}</span>
|
||||||
</text>
|
</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)
|
const fg = selectedForeground(theme)
|
||||||
return (
|
return (
|
||||||
<box
|
<box
|
||||||
flexDirection="row"
|
flexDirection="row"
|
||||||
paddingRight={1}
|
paddingRight={1}
|
||||||
backgroundColor={active() ? theme.primary : RGBA.fromInts(0, 0, 0, 0)}
|
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}>
|
<text
|
||||||
{action.item.title}
|
fg={disabled() ? theme.textMuted : active() ? fg : theme.text}
|
||||||
|
attributes={active() ? TextAttributes.BOLD : undefined}
|
||||||
|
>
|
||||||
|
{item.title}
|
||||||
</text>
|
</text>
|
||||||
<text fg={active() ? fg : theme.textMuted}> {action.item.label}</text>
|
<text fg={disabled() ? theme.textMuted : active() ? fg : theme.textMuted}> {item.label}</text>
|
||||||
</box>
|
</box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -455,9 +477,11 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
|||||||
<box gap={1} paddingBottom={1} flexGrow={1}>
|
<box gap={1} paddingBottom={1} flexGrow={1}>
|
||||||
<box paddingLeft={4} paddingRight={4}>
|
<box paddingLeft={4} paddingRight={4}>
|
||||||
<box flexDirection="row" justifyContent="space-between">
|
<box flexDirection="row" justifyContent="space-between">
|
||||||
<text fg={theme.text} attributes={TextAttributes.BOLD}>
|
{props.titleView ?? (
|
||||||
{props.title}
|
<text fg={theme.text} attributes={TextAttributes.BOLD}>
|
||||||
</text>
|
{props.title}
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
|
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
|
||||||
esc
|
esc
|
||||||
</text>
|
</text>
|
||||||
@@ -466,6 +490,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
|||||||
<box paddingTop={1}>
|
<box paddingTop={1}>
|
||||||
<input
|
<input
|
||||||
onInput={(e) => {
|
onInput={(e) => {
|
||||||
|
if (props.locked) return
|
||||||
batch(() => {
|
batch(() => {
|
||||||
setStore("filter", e)
|
setStore("filter", e)
|
||||||
props.onFilter?.(e)
|
props.onFilter?.(e)
|
||||||
@@ -525,7 +550,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
|||||||
</Show>
|
</Show>
|
||||||
<For each={options}>
|
<For each={options}>
|
||||||
{(option) => {
|
{(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))
|
const current = createMemo(() => isDeepEqual(option.value, props.current))
|
||||||
return (
|
return (
|
||||||
<box
|
<box
|
||||||
@@ -533,20 +558,24 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
|||||||
flexDirection="column"
|
flexDirection="column"
|
||||||
position="relative"
|
position="relative"
|
||||||
onMouseMove={() => {
|
onMouseMove={() => {
|
||||||
|
if (props.locked) return
|
||||||
setStore("input", "mouse")
|
setStore("input", "mouse")
|
||||||
setFocusedAction(undefined)
|
setFocusedAction(undefined)
|
||||||
}}
|
}}
|
||||||
onMouseUp={() => {
|
onMouseUp={() => {
|
||||||
|
if (props.locked) return
|
||||||
option.onSelect?.(dialog)
|
option.onSelect?.(dialog)
|
||||||
props.onSelect?.(option)
|
props.onSelect?.(option)
|
||||||
}}
|
}}
|
||||||
onMouseOver={() => {
|
onMouseOver={() => {
|
||||||
|
if (props.locked) return
|
||||||
if (store.input !== "mouse") return
|
if (store.input !== "mouse") return
|
||||||
const index = flat().findIndex((x) => isDeepEqual(x.value, option.value))
|
const index = flat().findIndex((x) => isDeepEqual(x.value, option.value))
|
||||||
if (index === -1) return
|
if (index === -1) return
|
||||||
moveTo(index)
|
moveTo(index)
|
||||||
}}
|
}}
|
||||||
onMouseDown={() => {
|
onMouseDown={() => {
|
||||||
|
if (props.locked) return
|
||||||
const index = flat().findIndex((x) => isDeepEqual(x.value, option.value))
|
const index = flat().findIndex((x) => isDeepEqual(x.value, option.value))
|
||||||
if (index === -1) return
|
if (index === -1) return
|
||||||
moveTo(index)
|
moveTo(index)
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
import { described } from "./metadata"
|
import { described } from "./metadata"
|
||||||
|
|
||||||
const root = "/experimental/project/:projectID/copy"
|
const root = "/experimental/project/:projectID/copy"
|
||||||
const CreateQuery = Schema.Struct({
|
const CopyQuery = Schema.Struct({
|
||||||
workspace: WorkspaceRoutingQueryFields.workspace,
|
workspace: WorkspaceRoutingQueryFields.workspace,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -24,6 +24,7 @@ export const CreatePayload = Schema.Struct({
|
|||||||
})
|
})
|
||||||
export const RemovePayload = Schema.Struct({
|
export const RemovePayload = Schema.Struct({
|
||||||
directory: ProjectCopy.RemoveInput.fields.directory,
|
directory: ProjectCopy.RemoveInput.fields.directory,
|
||||||
|
force: ProjectCopy.RemoveInput.fields.force,
|
||||||
})
|
})
|
||||||
|
|
||||||
export class ApiProjectCopyError extends Schema.ErrorClass<ApiProjectCopyError>("ProjectCopyError")(
|
export class ApiProjectCopyError extends Schema.ErrorClass<ApiProjectCopyError>("ProjectCopyError")(
|
||||||
@@ -31,6 +32,7 @@ export class ApiProjectCopyError extends Schema.ErrorClass<ApiProjectCopyError>(
|
|||||||
name: Schema.Literal("ProjectCopyError"),
|
name: Schema.Literal("ProjectCopyError"),
|
||||||
data: Schema.Struct({
|
data: Schema.Struct({
|
||||||
message: Schema.String,
|
message: Schema.String,
|
||||||
|
forceRequired: Schema.optional(Schema.Boolean),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
{ httpApiStatus: 400 },
|
{ httpApiStatus: 400 },
|
||||||
@@ -41,7 +43,7 @@ export const ProjectCopyApi = HttpApi.make("projectCopy").add(
|
|||||||
.add(
|
.add(
|
||||||
HttpApiEndpoint.post("create", root, {
|
HttpApiEndpoint.post("create", root, {
|
||||||
params: { projectID: ProjectV2.ID },
|
params: { projectID: ProjectV2.ID },
|
||||||
query: CreateQuery,
|
query: CopyQuery,
|
||||||
payload: CreatePayload,
|
payload: CreatePayload,
|
||||||
success: described(ProjectCopy.Copy, "Project copy created"),
|
success: described(ProjectCopy.Copy, "Project copy created"),
|
||||||
error: ApiProjectCopyError,
|
error: ApiProjectCopyError,
|
||||||
@@ -54,7 +56,7 @@ export const ProjectCopyApi = HttpApi.make("projectCopy").add(
|
|||||||
),
|
),
|
||||||
HttpApiEndpoint.delete("remove", root, {
|
HttpApiEndpoint.delete("remove", root, {
|
||||||
params: { projectID: ProjectV2.ID },
|
params: { projectID: ProjectV2.ID },
|
||||||
query: WorkspaceRoutingQuery,
|
query: CopyQuery,
|
||||||
payload: RemovePayload,
|
payload: RemovePayload,
|
||||||
success: described(HttpApiSchema.NoContent, "Project copy removed"),
|
success: described(HttpApiSchema.NoContent, "Project copy removed"),
|
||||||
error: ApiProjectCopyError,
|
error: ApiProjectCopyError,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ProjectCopy } from "@opencode-ai/core/project/copy"
|
import { ProjectCopy } from "@opencode-ai/core/project/copy"
|
||||||
|
import { Git } from "@opencode-ai/core/git"
|
||||||
import { ProjectV2 } from "@opencode-ai/core/project"
|
import { ProjectV2 } from "@opencode-ai/core/project"
|
||||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
@@ -28,7 +29,10 @@ function badRequest<A, R>(effect: Effect.Effect<A, ProjectCopy.Error, R>) {
|
|||||||
(error) =>
|
(error) =>
|
||||||
new ApiProjectCopyError({
|
new ApiProjectCopyError({
|
||||||
name: "ProjectCopyError",
|
name: "ProjectCopyError",
|
||||||
data: { message: message(error) },
|
data: {
|
||||||
|
message: message(error),
|
||||||
|
forceRequired: error instanceof Git.WorktreeError ? error.forceRequired : undefined,
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -65,12 +65,24 @@ describe("project directories and copies endpoints", () => {
|
|||||||
const listed = yield* request(test.directory, `${base}/directories`)
|
const listed = yield* request(test.directory, `${base}/directories`)
|
||||||
expect(yield* json<string[]>(listed)).toContain(created.directory)
|
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, {
|
const remove = yield* request(test.directory, copies, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
body: JSON.stringify({ directory: created.directory }),
|
body: JSON.stringify({ directory: created.directory, force: false }),
|
||||||
})
|
})
|
||||||
expect(remove.status).toBe(204)
|
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(forced.status).toBe(204)
|
||||||
|
|
||||||
const externalDirectory = path.join(test.directory, "..", path.basename(test.directory) + "-http-refresh")
|
const externalDirectory = path.join(test.directory, "..", path.basename(test.directory) + "-http-refresh")
|
||||||
yield* Effect.addFinalizer(() =>
|
yield* Effect.addFinalizer(() =>
|
||||||
|
|||||||
@@ -818,9 +818,9 @@ export class ProjectCopy extends HeyApiClient {
|
|||||||
public remove<ThrowOnError extends boolean = false>(
|
public remove<ThrowOnError extends boolean = false>(
|
||||||
parameters: {
|
parameters: {
|
||||||
projectID: string
|
projectID: string
|
||||||
query_directory?: string
|
|
||||||
workspace?: string
|
workspace?: string
|
||||||
body_directory?: string
|
directory?: string
|
||||||
|
force?: boolean
|
||||||
},
|
},
|
||||||
options?: Options<never, ThrowOnError>,
|
options?: Options<never, ThrowOnError>,
|
||||||
) {
|
) {
|
||||||
@@ -830,17 +830,9 @@ export class ProjectCopy extends HeyApiClient {
|
|||||||
{
|
{
|
||||||
args: [
|
args: [
|
||||||
{ in: "path", key: "projectID" },
|
{ in: "path", key: "projectID" },
|
||||||
{
|
|
||||||
in: "query",
|
|
||||||
key: "query_directory",
|
|
||||||
map: "directory",
|
|
||||||
},
|
|
||||||
{ in: "query", key: "workspace" },
|
{ in: "query", key: "workspace" },
|
||||||
{
|
{ in: "body", key: "directory" },
|
||||||
in: "body",
|
{ in: "body", key: "force" },
|
||||||
key: "body_directory",
|
|
||||||
map: "directory",
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -2484,6 +2484,7 @@ export type ProjectCopyError = {
|
|||||||
name: "ProjectCopyError"
|
name: "ProjectCopyError"
|
||||||
data: {
|
data: {
|
||||||
message: string
|
message: string
|
||||||
|
forceRequired?: boolean
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6971,12 +6972,12 @@ export type ProjectDirectoriesResponse = ProjectDirectoriesResponses[keyof Proje
|
|||||||
export type ExperimentalProjectCopyRemoveData = {
|
export type ExperimentalProjectCopyRemoveData = {
|
||||||
body?: {
|
body?: {
|
||||||
directory: string
|
directory: string
|
||||||
|
force: boolean
|
||||||
}
|
}
|
||||||
path: {
|
path: {
|
||||||
projectID: string
|
projectID: string
|
||||||
}
|
}
|
||||||
query?: {
|
query?: {
|
||||||
directory?: string
|
|
||||||
workspace?: string
|
workspace?: string
|
||||||
}
|
}
|
||||||
url: "/experimental/project/{projectID}/copy"
|
url: "/experimental/project/{projectID}/copy"
|
||||||
|
|||||||
Reference in New Issue
Block a user