feat(core): refactor project copies for v2 (#31943)
This commit is contained in:
parent
8d97c8d412
commit
c2e6b18076
33 changed files with 1461 additions and 829 deletions
|
|
@ -16,14 +16,18 @@ import { useCommandShortcut } from "../keymap"
|
|||
import { useProject } from "../context/project"
|
||||
import { Spinner } from "./spinner"
|
||||
import { DialogWorkspaceFileChanges } from "./dialog-workspace-file-changes"
|
||||
import type { ProjectDirectories } from "@opencode-ai/sdk/v2"
|
||||
import { useRoute } from "../context/route"
|
||||
|
||||
export type MoveSessionSelection = { type: "directory"; directory: string; subdirectory: boolean } | { type: "new" }
|
||||
type ProjectDirectory = ProjectDirectories[number]
|
||||
|
||||
export function DialogMoveSession(props: {
|
||||
projectID: string
|
||||
current?: MoveSessionSelection
|
||||
onSelect: (selection: MoveSessionSelection) => void
|
||||
initialDirectories?: string[]
|
||||
onCurrentChange?: (selection: MoveSessionSelection) => void
|
||||
initialDirectories?: ProjectDirectory[]
|
||||
initialRemoving?: string
|
||||
}) {
|
||||
const dialog = useDialog()
|
||||
|
|
@ -32,11 +36,13 @@ export function DialogMoveSession(props: {
|
|||
const { theme } = useTheme()
|
||||
const sync = useSync()
|
||||
const projectContext = useProject()
|
||||
const route = useRoute()
|
||||
const toast = useToast()
|
||||
const paths = useTuiPaths()
|
||||
const [working, setWorking] = createSignal(Boolean(props.initialRemoving))
|
||||
const [toDelete, setToDelete] = createSignal<string>()
|
||||
const [removing, setRemoving] = createSignal(props.initialRemoving)
|
||||
const [replacementCurrent, setReplacementCurrent] = createSignal<string>()
|
||||
const deleteHint = useCommandShortcut("dialog.move_session.delete")
|
||||
|
||||
function reopen(initialRemoving?: string) {
|
||||
|
|
@ -52,8 +58,8 @@ export function DialogMoveSession(props: {
|
|||
return result.data?.id === projectID ? result.data.worktree : undefined
|
||||
},
|
||||
)
|
||||
const project = createMemo(() =>
|
||||
projectContext.project() === props.projectID ? projectContext.data.project.worktree : loadedProject(),
|
||||
const currentCheckout = createMemo(() =>
|
||||
projectContext.project() === props.projectID ? projectContext.instance.path().worktree : loadedProject(),
|
||||
)
|
||||
|
||||
const [directories, { refetch }] = createResource(
|
||||
|
|
@ -61,9 +67,12 @@ export function DialogMoveSession(props: {
|
|||
async (projectID) => {
|
||||
setWorking(true)
|
||||
try {
|
||||
await sdk.client.experimental.projectCopy.refresh({ projectID }, { throwOnError: true })
|
||||
await sdk.client.v2.projectCopy.refresh(
|
||||
{ projectID, location: { directory: sdk.directory } },
|
||||
{ throwOnError: true },
|
||||
)
|
||||
const directories = await sdk.client.project.directories({ projectID }, { throwOnError: true })
|
||||
return directories.data?.map((item) => item.directory) ?? []
|
||||
return directories.data ?? []
|
||||
} finally {
|
||||
setWorking(false)
|
||||
}
|
||||
|
|
@ -71,56 +80,88 @@ export function DialogMoveSession(props: {
|
|||
{ initialValue: props.initialDirectories },
|
||||
)
|
||||
|
||||
const currentDirectory = createMemo(() =>
|
||||
replacementCurrent() ?? (props.current?.type === "directory" ? props.current.directory : currentCheckout()),
|
||||
)
|
||||
const currentRoot = createMemo<ProjectDirectory | undefined>(() => {
|
||||
const directory = currentDirectory()
|
||||
if (!directory) return
|
||||
return (
|
||||
directories()
|
||||
?.filter((root) => contains(root.directory, directory))
|
||||
.toSorted((a, b) => b.directory.length - a.directory.length)[0] ?? { directory }
|
||||
)
|
||||
})
|
||||
|
||||
const options = createMemo<DialogSelectOption<MoveSessionSelection | undefined>[]>(() => {
|
||||
const 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 ?? []))]
|
||||
const current = currentRoot()?.directory
|
||||
if (directories.loading && !data && !current) return [{ title: "Loading project directories...", value: undefined }]
|
||||
if (directories.error && !data && !current)
|
||||
return [{ title: "Failed to load project directories", value: undefined }]
|
||||
const roots = [...(data ?? [])]
|
||||
if (current && !roots.some((item) => item.directory === current)) roots.unshift({ directory: current })
|
||||
roots.sort((a, b) => {
|
||||
if (a.directory === current) return -1
|
||||
if (b.directory === current) return 1
|
||||
if (Boolean(a.strategy) !== Boolean(b.strategy)) return a.strategy ? 1 : -1
|
||||
if (!a.strategy && !b.strategy) return a.directory.length - b.directory.length
|
||||
return 0
|
||||
})
|
||||
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))
|
||||
.map((session) => session.directory)
|
||||
.filter((directory) => !roots.includes(directory))
|
||||
.filter((directory) => !roots.some((root) => root.directory === directory))
|
||||
.filter((directory, index, directories) => directories.indexOf(directory) === index)
|
||||
.map((location) => ({
|
||||
location,
|
||||
root: roots
|
||||
.filter((root) => {
|
||||
const relative = path.relative(root, location)
|
||||
const relative = path.relative(root.directory, location)
|
||||
return relative && relative !== ".." && !relative.startsWith(".." + path.sep) && !path.isAbsolute(relative)
|
||||
})
|
||||
.toSorted((a, b) => b.length - a.length)[0],
|
||||
.toSorted((a, b) => b.directory.length - a.directory.length)[0],
|
||||
}))
|
||||
.filter((item): item is { location: string; root: string } => item.root !== undefined)
|
||||
const list = [...roots.map((location) => ({ location, root: location })), ...subdirectories].toSorted((a, b) => {
|
||||
.filter((item): item is { location: string; root: ProjectDirectory } => item.root !== undefined)
|
||||
|
||||
const list = [...roots.map((root) => ({ location: root.directory, root })), ...subdirectories].toSorted((a, b) => {
|
||||
const root = roots.indexOf(a.root) - roots.indexOf(b.root)
|
||||
if (root !== 0) return root
|
||||
if (a.location === a.root) return -1
|
||||
if (b.location === b.root) return 1
|
||||
if (a.location === a.root.directory) return -1
|
||||
if (b.location === b.root.directory) return 1
|
||||
return a.location.localeCompare(b.location)
|
||||
})
|
||||
const titleWidth = Math.max(1, Math.min(116, dimensions().width - 2) - 12)
|
||||
|
||||
return list.map((item) => {
|
||||
const title = abbreviateHome(item.location, paths.home)
|
||||
const suffix = item.location === item.root ? undefined : path.sep + path.relative(item.root, item.location)
|
||||
const suffix =
|
||||
item.location === item.root.directory ? undefined : path.sep + path.relative(item.root.directory, 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: isRemoving ? `Deleting ${item.location}` : deleting ? `Press ${deleteHint()} again to confirm` : title,
|
||||
title,
|
||||
titleView: isRemoving ? (
|
||||
<span style={{ fg: theme.error }}>Deleting {item.location}</span>
|
||||
) : !deleting && suffix ? (
|
||||
) : deleting ? (
|
||||
<span style={{ fg: theme.text }}>Press {deleteHint()} again to confirm</span>
|
||||
) : 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",
|
||||
value: {
|
||||
type: "directory",
|
||||
directory: item.location,
|
||||
subdirectory: item.location !== item.root.directory,
|
||||
} as const,
|
||||
category: item.root.directory === current ? "Current" : "Other",
|
||||
titleWidth,
|
||||
truncateTitle: "left" as const,
|
||||
}
|
||||
|
|
@ -128,32 +169,56 @@ export function DialogMoveSession(props: {
|
|||
})
|
||||
|
||||
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
|
||||
if (directories.loading || loadedProject.loading) return
|
||||
const replacement = replacementCurrent()
|
||||
if (replacement) return { type: "directory", directory: replacement, subdirectory: false } as const
|
||||
return props.current
|
||||
})
|
||||
|
||||
async function removedCurrent(current: boolean) {
|
||||
if (!current) return false
|
||||
const fallback = projectContext.data.project.mainDir
|
||||
if (fallback) setReplacementCurrent(fallback)
|
||||
if (route.data.type === "session") {
|
||||
route.navigate({ type: "home" })
|
||||
dialog.clear()
|
||||
return true
|
||||
}
|
||||
if (fallback) {
|
||||
props.onCurrentChange?.({ type: "directory", directory: fallback, subdirectory: false })
|
||||
return true
|
||||
}
|
||||
dialog.clear()
|
||||
return true
|
||||
}
|
||||
|
||||
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)
|
||||
const selected = option.value
|
||||
const root = data?.find((item) => item.directory === selected.directory)
|
||||
if (!root?.strategy) return
|
||||
const deletingCurrent = selected.directory === currentRoot()?.directory
|
||||
if (toDelete() !== selected.directory) {
|
||||
setToDelete(selected.directory)
|
||||
return
|
||||
}
|
||||
setToDelete(undefined)
|
||||
setRemoving(option.value.directory)
|
||||
setRemoving(selected.directory)
|
||||
setWorking(true)
|
||||
const result = await sdk.client.experimental.projectCopy
|
||||
.remove({ projectID: props.projectID, directory: option.value.directory, force: false })
|
||||
const result = await sdk.client.v2.projectCopy
|
||||
.remove({
|
||||
projectID: props.projectID,
|
||||
location: { directory: sdk.directory },
|
||||
directory: selected.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 status = await sdk.client.vcs.status({ directory: selected.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?",
|
||||
|
|
@ -162,9 +227,14 @@ export function DialogMoveSession(props: {
|
|||
reopen()
|
||||
return
|
||||
}
|
||||
reopen(option.value.directory)
|
||||
const forced = await sdk.client.experimental.projectCopy
|
||||
.remove({ projectID: props.projectID, directory: option.value.directory, force: true })
|
||||
reopen(selected.directory)
|
||||
const forced = await sdk.client.v2.projectCopy
|
||||
.remove({
|
||||
projectID: props.projectID,
|
||||
location: { directory: sdk.directory },
|
||||
directory: selected.directory,
|
||||
force: true,
|
||||
})
|
||||
.catch((error) => ({ error }))
|
||||
if (forced.error) {
|
||||
toast.show({
|
||||
|
|
@ -172,7 +242,12 @@ export function DialogMoveSession(props: {
|
|||
title: "Failed to delete project copy",
|
||||
message: errorMessage(forced.error),
|
||||
})
|
||||
reopen()
|
||||
return
|
||||
}
|
||||
setRemoving(undefined)
|
||||
setWorking(false)
|
||||
if (await removedCurrent(deletingCurrent)) return
|
||||
reopen()
|
||||
return
|
||||
}
|
||||
|
|
@ -185,6 +260,8 @@ export function DialogMoveSession(props: {
|
|||
}
|
||||
await refetch()
|
||||
setRemoving(undefined)
|
||||
setWorking(false)
|
||||
if (await removedCurrent(deletingCurrent)) return
|
||||
}
|
||||
|
||||
onMount(() => dialog.setSize("xlarge"))
|
||||
|
|
@ -219,11 +296,11 @@ export function DialogMoveSession(props: {
|
|||
{
|
||||
command: "dialog.move_session.delete",
|
||||
title: "delete",
|
||||
disabled: (option) =>
|
||||
!option?.value ||
|
||||
option.value.type !== "directory" ||
|
||||
option.value.subdirectory ||
|
||||
option.value.directory === project(),
|
||||
disabled: (option) => {
|
||||
const value = option?.value
|
||||
if (!value || value.type !== "directory" || value.subdirectory) return true
|
||||
return !directories()?.find((item) => item.directory === value.directory)?.strategy
|
||||
},
|
||||
onTrigger: remove,
|
||||
},
|
||||
{
|
||||
|
|
@ -236,3 +313,9 @@ export function DialogMoveSession(props: {
|
|||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function contains(root: string, directory: string) {
|
||||
if (root === directory) return true
|
||||
const relative = path.relative(root, directory)
|
||||
return relative && relative !== ".." && !relative.startsWith(".." + path.sep) && !path.isAbsolute(relative)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { useToast } from "../../ui/toast"
|
|||
import { DialogMoveSession, type MoveSessionSelection } from "../dialog-move-session"
|
||||
import { DialogWorkspaceFileChanges } from "../dialog-workspace-file-changes"
|
||||
import { useHomeSessionDestination } from "../../routes/home/session-destination"
|
||||
import { useProject } from "../../context/project"
|
||||
|
||||
function moveReminderText(directory: string) {
|
||||
return `<system-reminder>The user has changed the current working directory to "${directory}". This is still the same project but at a possibly new location; take this into account when working with any files from now on.</system-reminder>`
|
||||
|
|
@ -20,6 +21,7 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
|
|||
const sync = useSync()
|
||||
const toast = useToast()
|
||||
const homeDestination = useHomeSessionDestination()
|
||||
const project = useProject()
|
||||
const paths = useTuiPaths()
|
||||
const [creating, setCreating] = createSignal(false)
|
||||
const [creatingDots, setCreatingDots] = createSignal(3)
|
||||
|
|
@ -31,12 +33,17 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
|
|||
setCreating(true)
|
||||
setProgress("Creating copy")
|
||||
try {
|
||||
const result = await sdk.client.experimental.projectCopy.create(
|
||||
const generated = await sdk.client.experimental.projectCopy.generateName(
|
||||
{ projectID, context },
|
||||
{ throwOnError: true },
|
||||
)
|
||||
const result = await sdk.client.v2.projectCopy.create(
|
||||
{
|
||||
projectID,
|
||||
location: { directory: sdk.directory },
|
||||
strategy: "git_worktree",
|
||||
directory: path.join(paths.worktree, projectID.slice(0, 6)),
|
||||
context,
|
||||
name: generated.data.name,
|
||||
},
|
||||
{ throwOnError: true },
|
||||
)
|
||||
|
|
@ -74,8 +81,13 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
|
|||
directory: session.directory,
|
||||
subdirectory: !!session.path,
|
||||
}
|
||||
: undefined)
|
||||
: {
|
||||
type: "directory",
|
||||
directory: project.instance.directory(),
|
||||
subdirectory: project.instance.directory() !== project.instance.path().worktree,
|
||||
})
|
||||
}
|
||||
onCurrentChange={(selection) => homeDestination?.setDestination(selection)}
|
||||
onSelect={(selection) => {
|
||||
const sessionID = input.sessionID()
|
||||
if (!sessionID) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue