mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-11 04:13:52 +00:00
475 lines
19 KiB
TypeScript
475 lines
19 KiB
TypeScript
import type { Component } from "solid-js"
|
|
import { For, Show, createMemo } from "solid-js"
|
|
import { createStore, produce } from "solid-js/store"
|
|
import type { Project, Session } from "@opencode-ai/sdk/v2/client"
|
|
import { useQuery } from "@tanstack/solid-query"
|
|
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
|
import { Dialog, DialogFooter, DialogHeader, DialogTitleGroup } from "@opencode-ai/ui/v2/dialog-v2"
|
|
import { Icon } from "@opencode-ai/ui/v2/icon"
|
|
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
|
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
|
import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
|
|
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
|
import { getFilename } from "@opencode-ai/core/util/path"
|
|
import { useLanguage } from "@/context/language"
|
|
import { useServerSDK } from "@/context/server-sdk"
|
|
import { useServerSync } from "@/context/server-sync"
|
|
import { showToast } from "@/utils/toast"
|
|
import { getRelativeTime } from "@/utils/time"
|
|
import { pathKey } from "@/utils/path-key"
|
|
import { SettingsListV2 } from "./parts/list"
|
|
import { useTabs } from "@/context/tabs"
|
|
import { usePlatform } from "@/context/platform"
|
|
import { clearWorkspaceTerminals } from "@/context/terminal"
|
|
import { ServerConnection } from "@/context/server"
|
|
import {
|
|
containsDirectory,
|
|
filterWorkspaceInventory,
|
|
inspectWorkspaceDeletion,
|
|
mergeWorkspaceSessionInventory,
|
|
removeWorkspacesSequentially,
|
|
sessionsForWorkspace,
|
|
type WorkspaceDeleteInspection,
|
|
workspaceInventory,
|
|
} from "@/utils/workspace"
|
|
import { listAllSessions, normalizeSessionInfo } from "@/utils/session"
|
|
import type { ServerScope } from "@/utils/server-scope"
|
|
import "./settings-v2.css"
|
|
|
|
type Workspace = {
|
|
directory: string
|
|
project: Project
|
|
}
|
|
|
|
export const SettingsWorkspacesV2: Component<{ activeDirectory?: string }> = (props) => {
|
|
const dialog = useDialog()
|
|
const language = useLanguage()
|
|
const serverSDK = useServerSDK()
|
|
const serverSync = useServerSync()
|
|
const tabs = useTabs()
|
|
const platform = usePlatform()
|
|
const [store, setStore] = createStore({
|
|
project: "all",
|
|
transaction: undefined as "confirm" | "running" | undefined,
|
|
})
|
|
|
|
const workspaces = createMemo(() => workspaceInventory(serverSync().data.project))
|
|
const projects = createMemo(() => serverSync().data.project.filter((project) => project.sandboxes?.length))
|
|
const projectName = (project: Project) => project.name || getFilename(project.worktree)
|
|
const projectOptions = createMemo(() => [
|
|
{ id: "all", label: language.t("settings.workspaces.filter.all") },
|
|
...projects().map((project) => ({ id: project.id, label: projectName(project) })),
|
|
])
|
|
const selectedProject = createMemo(() =>
|
|
store.project === "all" || projects().some((project) => project.id === store.project) ? store.project : "all",
|
|
)
|
|
const filtered = createMemo(() => filterWorkspaceInventory(workspaces(), selectedProject()))
|
|
const captureDeleteContext = () => {
|
|
const sdk = serverSDK()
|
|
return { sdk, sync: serverSync(), server: ServerConnection.key(sdk.server), activeDirectory: props.activeDirectory }
|
|
}
|
|
const loadSessions = async (context = captureDeleteContext()) => {
|
|
const protocol = await context.sdk.protocol
|
|
const fetched =
|
|
protocol === "v1"
|
|
? await context.sdk.api.session.list({ limit: 1000, order: "desc" }).then((response) => {
|
|
if (response.data.length >= 1000) throw new Error("Incomplete legacy session inventory")
|
|
return response.data.map(normalizeSessionInfo)
|
|
})
|
|
: await listAllSessions(context.sdk.api.session, { order: "desc" })
|
|
return mergeWorkspaceSessionInventory(
|
|
fetched,
|
|
Object.values(context.sync.session.data.info).filter((session): session is Session => !!session),
|
|
)
|
|
}
|
|
const sessionQuery = useQuery(() => ({
|
|
queryKey: [serverSDK().scope, null, "settings-workspace-sessions"] as const,
|
|
queryFn: () => loadSessions(),
|
|
refetchOnMount: "always",
|
|
}))
|
|
const workspaceSessions = (workspace: Workspace) => {
|
|
if (!sessionQuery.isSuccess) return []
|
|
return sessionsForWorkspace(sessionQuery.data ?? [], workspace.directory)
|
|
}
|
|
const sessionCount = (workspace: Workspace) => {
|
|
if (sessionQuery.isPending) return language.t("session.messages.loading")
|
|
if (sessionQuery.isError) return language.t("common.requestFailed")
|
|
const count = workspaceSessions(workspace).length
|
|
return language.plural("settings.workspaces.sessions", count, {
|
|
count,
|
|
project: projectName(workspace.project),
|
|
})
|
|
}
|
|
const lastActive = (workspace: Workspace) => {
|
|
const updated = workspaceSessions(workspace)[0]?.time.updated
|
|
if (!updated) return undefined
|
|
return getRelativeTime(new Date(updated).toISOString(), language.t)
|
|
}
|
|
const sessionTime = (session: Session) => {
|
|
if (!session.time.updated) return undefined
|
|
return getRelativeTime(new Date(session.time.updated).toISOString(), language.t)
|
|
}
|
|
|
|
const inspect = async (workspace: Workspace, context = captureDeleteContext()) => {
|
|
const [working, branch, sessions] = await Promise.all([
|
|
context.sdk.api.vcs.status({ location: { directory: workspace.directory } }),
|
|
context.sdk.api.vcs.diff({ location: { directory: workspace.directory }, mode: "branch" }),
|
|
loadSessions(context),
|
|
])
|
|
const result = inspectWorkspaceDeletion({
|
|
workspace: workspace.directory,
|
|
activeDirectory: context.activeDirectory,
|
|
sessions,
|
|
status: working.data.length > 0 || branch.data.length > 0 ? "dirty" : "clean",
|
|
})
|
|
return { result, sessions }
|
|
}
|
|
const inspectionMessage = (result: WorkspaceDeleteInspection) => {
|
|
if (result === "active") return language.t("settings.workspaces.delete.blocked.active")
|
|
if (result === "linked") return language.t("settings.workspaces.delete.blocked.linked")
|
|
if (result === "dirty") return language.t("workspace.status.dirty")
|
|
return language.t("workspace.status.clean")
|
|
}
|
|
const blocked = (result: WorkspaceDeleteInspection) => {
|
|
showToast({
|
|
variant: "error",
|
|
title: language.t("workspace.delete.failed.title"),
|
|
description: inspectionMessage(result),
|
|
})
|
|
}
|
|
|
|
const remove = async (workspace: Workspace, allowDirty = false, context = captureDeleteContext()) => {
|
|
const preflight = await inspect(workspace, context)
|
|
if (preflight.result !== "safe" && (!allowDirty || preflight.result !== "dirty")) {
|
|
blocked(preflight.result)
|
|
return
|
|
}
|
|
const removed = await context.sdk.client.worktree
|
|
.remove({
|
|
directory: workspace.project.worktree,
|
|
worktreeRemoveInput: { directory: workspace.directory },
|
|
})
|
|
.then((result) => result.data)
|
|
.catch((error) => {
|
|
showToast({
|
|
variant: "error",
|
|
title: language.t("workspace.delete.failed.title"),
|
|
description: error instanceof Error ? error.message : language.t("common.requestFailed"),
|
|
})
|
|
return false
|
|
})
|
|
if (!removed) return
|
|
tabs.store.forEach((tab) => {
|
|
if (tab.type !== "draft" || tab.server !== context.server) return
|
|
const directoryMatches = containsDirectory(workspace.directory, tab.directory)
|
|
const worktreeMatches = tab.worktree && containsDirectory(workspace.directory, tab.worktree)
|
|
if (!directoryMatches && !worktreeMatches) return
|
|
tabs.updateDraft(tab.draftID, {
|
|
directory: directoryMatches ? workspace.project.worktree : tab.directory,
|
|
worktree: undefined,
|
|
})
|
|
})
|
|
clearWorkspaceTerminals(
|
|
workspace.directory,
|
|
preflight.sessions.map((session) => session.id),
|
|
platform,
|
|
context.sdk.scope,
|
|
)
|
|
context.sync.set(
|
|
"project",
|
|
produce((draft) => {
|
|
const project = draft.find((item) => item.id === workspace.project.id)
|
|
if (!project) return
|
|
project.sandboxes = (project.sandboxes ?? []).filter(
|
|
(directory) => pathKey(directory) !== pathKey(workspace.directory),
|
|
)
|
|
}),
|
|
)
|
|
}
|
|
|
|
let inspectionID = 0
|
|
const releaseConfirmation = () => {
|
|
if (store.transaction === "confirm") setStore("transaction", undefined)
|
|
}
|
|
const transact = async (task: () => Promise<void>) => {
|
|
if (store.transaction !== "confirm") return
|
|
setStore("transaction", "running")
|
|
try {
|
|
await task()
|
|
} catch (error) {
|
|
showToast({
|
|
variant: "error",
|
|
title: language.t("workspace.delete.failed.title"),
|
|
description: error instanceof Error ? error.message : language.t("common.requestFailed"),
|
|
})
|
|
} finally {
|
|
setStore("transaction", undefined)
|
|
}
|
|
}
|
|
const confirmDelete = (workspace: Workspace) => {
|
|
if (store.transaction) return
|
|
const context = captureDeleteContext()
|
|
const current = ++inspectionID
|
|
setStore("transaction", "confirm")
|
|
void dialog.push(
|
|
() => (
|
|
<DialogDeleteWorkspace
|
|
workspace={workspace}
|
|
scope={context.sdk.scope}
|
|
inspectionID={current}
|
|
inspect={() => inspect(workspace, context)}
|
|
inspectionMessage={inspectionMessage}
|
|
onDelete={() => transact(() => remove(workspace, true, context))}
|
|
/>
|
|
),
|
|
releaseConfirmation,
|
|
)
|
|
}
|
|
const removeAll = async (inventory: Workspace[], context: ReturnType<typeof captureDeleteContext>) => {
|
|
await removeWorkspacesSequentially(inventory, (workspace) => remove(workspace, false, context))
|
|
}
|
|
const confirmDeleteAll = () => {
|
|
if (store.transaction) return
|
|
const context = captureDeleteContext()
|
|
const inventory = [...filtered()]
|
|
const project = projectOptions().find((option) => option.id === selectedProject())?.label ?? selectedProject()
|
|
setStore("transaction", "confirm")
|
|
void dialog.push(
|
|
() => (
|
|
<DialogDeleteAllWorkspaces
|
|
count={inventory.length}
|
|
project={project}
|
|
onDelete={() => transact(() => removeAll(inventory, context))}
|
|
/>
|
|
),
|
|
releaseConfirmation,
|
|
)
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<div class="settings-v2-tab-header settings-v2-workspaces-header">
|
|
<h2 class="settings-v2-tab-title">{language.t("settings.tab.workspaces")}</h2>
|
|
</div>
|
|
|
|
<div class="settings-v2-tab-body settings-v2-workspaces">
|
|
<div class="settings-v2-workspaces-toolbar">
|
|
<span class="settings-v2-workspaces-count">
|
|
{language.plural("settings.workspaces.count", filtered().length)}
|
|
</span>
|
|
<div class="settings-v2-workspaces-toolbar-actions">
|
|
<Show when={projects().length > 1}>
|
|
<SelectV2
|
|
appearance="inline"
|
|
options={projectOptions()}
|
|
current={projectOptions().find((option) => option.id === selectedProject())}
|
|
value={(option) => option.id}
|
|
label={(option) => option.label}
|
|
placement="bottom-end"
|
|
gutter={6}
|
|
onSelect={(option) => option && setStore("project", option.id)}
|
|
/>
|
|
</Show>
|
|
<Show when={filtered().length > 0}>
|
|
<MenuV2 placement="bottom-end" gutter={4}>
|
|
<MenuV2.Trigger
|
|
as={IconButtonV2}
|
|
type="button"
|
|
variant="ghost-muted"
|
|
size="small"
|
|
aria-label={language.t("common.moreOptions")}
|
|
disabled={!!store.transaction}
|
|
icon={<Icon name="outline-dots" size="small" />}
|
|
/>
|
|
<MenuV2.Portal>
|
|
<MenuV2.Content>
|
|
<MenuV2.Item onSelect={confirmDeleteAll}>
|
|
<span class="settings-v2-workspaces-delete-all">
|
|
{language.t("settings.workspaces.deleteAll")}
|
|
</span>
|
|
</MenuV2.Item>
|
|
</MenuV2.Content>
|
|
</MenuV2.Portal>
|
|
</MenuV2>
|
|
</Show>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="settings-v2-workspaces-inventory">
|
|
<Show
|
|
when={filtered().length > 0}
|
|
fallback={<div class="settings-v2-workspaces-empty">{language.t("settings.workspaces.empty")}</div>}
|
|
>
|
|
<SettingsListV2>
|
|
<For each={filtered()}>
|
|
{(workspace) => {
|
|
const linked = () => workspaceSessions(workspace)
|
|
return (
|
|
<div class="settings-v2-workspaces-row">
|
|
<div class="settings-v2-workspaces-row-header">
|
|
<div class="settings-v2-workspaces-copy">
|
|
<div class="settings-v2-workspaces-main">
|
|
<TooltipV2
|
|
value={workspace.directory}
|
|
placement="top-start"
|
|
contentClass="max-w-[calc(100vw-32px)] break-all"
|
|
>
|
|
<span tabIndex={0} aria-label={workspace.directory} class="settings-v2-workspaces-path">
|
|
{workspace.directory}
|
|
</span>
|
|
</TooltipV2>
|
|
</div>
|
|
<span class="settings-v2-workspaces-meta">{sessionCount(workspace)}</span>
|
|
</div>
|
|
<div class="settings-v2-workspaces-row-actions">
|
|
<Show when={lastActive(workspace)}>
|
|
{(value) => (
|
|
<TooltipV2
|
|
value={language.t("settings.workspaces.lastActiveSession")}
|
|
placement="top-end"
|
|
>
|
|
<span tabIndex={0} class="settings-v2-workspaces-active">
|
|
{value()}
|
|
</span>
|
|
</TooltipV2>
|
|
)}
|
|
</Show>
|
|
<IconButtonV2
|
|
type="button"
|
|
variant="ghost-muted"
|
|
size="small"
|
|
aria-label={language.t("workspace.delete.confirm", {
|
|
name: getFilename(workspace.directory),
|
|
})}
|
|
disabled={!!store.transaction}
|
|
icon={<Icon name="trash" size="small" />}
|
|
onClick={() => confirmDelete(workspace)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<Show when={linked().length > 0}>
|
|
<div class="settings-v2-workspaces-sessions">
|
|
<For each={linked()}>
|
|
{(session) => (
|
|
<div class="settings-v2-workspaces-session">
|
|
<span>{session.title}</span>
|
|
<Show when={sessionTime(session)}>
|
|
{(time) => <span class="settings-v2-workspaces-session-time">{time()}</span>}
|
|
</Show>
|
|
</div>
|
|
)}
|
|
</For>
|
|
</div>
|
|
</Show>
|
|
</div>
|
|
)
|
|
}}
|
|
</For>
|
|
</SettingsListV2>
|
|
</Show>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)
|
|
}
|
|
|
|
function DialogDeleteAllWorkspaces(props: { count: number; project: string; onDelete: () => Promise<void> }) {
|
|
const dialog = useDialog()
|
|
const language = useLanguage()
|
|
const remove = () => {
|
|
const deleting = props.onDelete()
|
|
dialog.close()
|
|
void deleting
|
|
}
|
|
|
|
return (
|
|
<Dialog fit>
|
|
<DialogHeader>
|
|
<DialogTitleGroup
|
|
title={language.t("settings.workspaces.deleteAll")}
|
|
description={
|
|
<>
|
|
{language.t("settings.workspaces.deleteAll.confirm", { count: props.count })}
|
|
<br />
|
|
{language.t("settings.workspaces.deleteAll.warning", { count: props.count, project: props.project })}
|
|
</>
|
|
}
|
|
/>
|
|
</DialogHeader>
|
|
<DialogFooter>
|
|
<ButtonV2 type="button" variant="neutral" onClick={() => dialog.close()}>
|
|
{language.t("common.cancel")}
|
|
</ButtonV2>
|
|
<ButtonV2 type="button" variant="danger" onClick={remove}>
|
|
{language.t("settings.workspaces.deleteAll")}
|
|
</ButtonV2>
|
|
</DialogFooter>
|
|
</Dialog>
|
|
)
|
|
}
|
|
|
|
function DialogDeleteWorkspace(props: {
|
|
workspace: Workspace
|
|
scope: ServerScope
|
|
inspectionID: number
|
|
inspect: () => Promise<{ result: WorkspaceDeleteInspection; sessions: Session[] }>
|
|
inspectionMessage: (result: WorkspaceDeleteInspection) => string
|
|
onDelete: () => Promise<void>
|
|
}) {
|
|
const dialog = useDialog()
|
|
const language = useLanguage()
|
|
const status = useQuery(() => ({
|
|
queryKey: [props.scope, pathKey(props.workspace.directory), "workspace-delete-status", props.inspectionID] as const,
|
|
queryFn: props.inspect,
|
|
staleTime: 0,
|
|
}))
|
|
const description = () => {
|
|
if (status.isPending) return language.t("workspace.status.checking")
|
|
if (status.isError) return language.t("workspace.status.error")
|
|
return props.inspectionMessage(status.data?.result ?? "unknown")
|
|
}
|
|
const remove = () => {
|
|
const deleting = props.onDelete()
|
|
dialog.close()
|
|
void deleting
|
|
}
|
|
|
|
return (
|
|
<Dialog fit>
|
|
<DialogHeader>
|
|
<DialogTitleGroup
|
|
title={language.t("workspace.delete.title")}
|
|
description={
|
|
<>
|
|
{language.t("workspace.delete.confirm", { name: getFilename(props.workspace.directory) })}
|
|
<br />
|
|
<code class="max-w-full rounded-[4px] bg-[color-mix(in_oklch,var(--v2-text-text-base)_8%,transparent)] px-1 py-0.5 font-mono text-xs font-medium leading-4 text-v2-text-text-base break-all">
|
|
{props.workspace.directory}
|
|
</code>
|
|
<br />
|
|
{language.t("settings.workspaces.delete.warning")}
|
|
<br />
|
|
{description()}
|
|
</>
|
|
}
|
|
/>
|
|
</DialogHeader>
|
|
<DialogFooter>
|
|
<ButtonV2 type="button" variant="neutral" onClick={() => dialog.close()}>
|
|
{language.t("common.cancel")}
|
|
</ButtonV2>
|
|
<ButtonV2
|
|
type="button"
|
|
variant="danger"
|
|
disabled={
|
|
status.isPending || status.isError || (status.data?.result !== "safe" && status.data?.result !== "dirty")
|
|
}
|
|
onClick={remove}
|
|
>
|
|
{language.t("workspace.delete.button")}
|
|
</ButtonV2>
|
|
</DialogFooter>
|
|
</Dialog>
|
|
)
|
|
}
|