GHHG10/CodeServer / opencode /packages /tui /src /component /dialog-move-session.tsx
GHHG10's picture
download
raw
12.5 kB
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 "../ui/dialog-select"
import { useDialog } from "../ui/dialog"
import { useSDK } from "../context/sdk"
import { useTheme } from "../context/theme"
import { useSync } from "../context/sync"
import { abbreviateHome } from "../runtime"
import { useTuiPaths } from "../context/runtime"
import { Locale } from "../util/locale"
import { errorMessage } from "../util/error"
import { useToast } from "../ui/toast"
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
onCurrentChange?: (selection: MoveSessionSelection) => void
initialDirectories?: ProjectDirectory[]
initialRemoving?: string
}) {
const dialog = useDialog()
const sdk = useSDK()
const dimensions = useTerminalDimensions()
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) {
dialog.replace(() => (
<DialogMoveSession {...props} initialDirectories={directories()} initialRemoving={initialRemoving} />
))
}
const [loadedProject] = createResource(
() => (projectContext.project() === props.projectID ? undefined : props.projectID),
async (projectID) => {
const result = await sdk.client.project.current({}, { throwOnError: true })
return result.data?.id === projectID ? result.data.worktree : undefined
},
)
const currentCheckout = createMemo(() =>
projectContext.project() === props.projectID ? projectContext.instance.path().worktree : loadedProject(),
)
const [directories, { refetch }] = createResource(
() => (props.initialRemoving ? undefined : props.projectID),
async (projectID) => {
setWorking(true)
try {
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 ?? []
} finally {
setWorking(false)
}
},
{ 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 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.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.directory, location)
return relative && relative !== ".." && !relative.startsWith(".." + path.sep) && !path.isAbsolute(relative)
})
.toSorted((a, b) => b.directory.length - a.directory.length)[0],
}))
.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.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.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,
titleView: isRemoving ? (
<span style={{ fg: theme.error }}>Deleting {item.location}</span>
) : 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.directory,
} as const,
category: item.root.directory === current ? "Current" : "Other",
titleWidth,
truncateTitle: "left" as const,
}
})
})
const current = createMemo(() => {
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 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(selected.directory)
setWorking(true)
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: 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?",
})
if (choice !== "yes") {
reopen()
return
}
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({
variant: "error",
title: "Failed to delete project copy",
message: errorMessage(forced.error),
})
reopen()
return
}
setRemoving(undefined)
setWorking(false)
if (await removedCurrent(deletingCurrent)) return
reopen()
return
}
toast.show({
variant: "error",
title: "Failed to delete project copy",
message: errorMessage(result.error),
})
return
}
await refetch()
setRemoving(undefined)
setWorking(false)
if (await removedCurrent(deletingCurrent)) return
}
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()}
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) => {
const value = option?.value
if (!value || value.type !== "directory" || value.subdirectory) return true
return !directories()?.find((item) => item.directory === value.directory)?.strategy
},
onTrigger: remove,
},
{
command: "dialog.move_session.refresh",
title: "refresh",
onTrigger: () => void refetch(),
},
]}
/>
</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)
}

Xet Storage Details

Size:
12.5 kB
·
Xet hash:
4f140e75b4ff4da2983bd5532b70bdb64d099b9f62a5e3eb7e0dd32abdce1b08

Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.