|
|
@@ -1,356 +0,0 @@
|
|
|
-import { useDialog } from "../../ui/dialog"
|
|
|
-import { DialogSelect, type DialogSelectOption, type DialogSelectRef } from "../../ui/dialog-select"
|
|
|
-import { useRoute } from "../../context/route"
|
|
|
-import { useSync } from "../../context/sync"
|
|
|
-import { useProject } from "../../context/project"
|
|
|
-import { useTheme } from "../../context/theme"
|
|
|
-import { useSDK } from "../../context/sdk"
|
|
|
-import { useLocal } from "../../context/local"
|
|
|
-import { useToast } from "../../ui/toast"
|
|
|
-import { useCommandShortcut } from "../../keymap"
|
|
|
-import { createEffect, createMemo, createResource, createSignal, on, Show, untrack } from "solid-js"
|
|
|
-import { useTerminalDimensions } from "@opentui/solid"
|
|
|
-import { Spinner } from "../../component/spinner"
|
|
|
-import { DialogSessionRename } from "../../component/dialog-session-rename"
|
|
|
-import { DialogSessionDeleteFailed } from "../../component/dialog-session-delete-failed"
|
|
|
-import {
|
|
|
- openWorkspaceSelect,
|
|
|
- type WorkspaceSelection,
|
|
|
- warpWorkspaceSession,
|
|
|
-} from "../../component/dialog-workspace-create"
|
|
|
-import { createDebouncedSignal } from "../../util/signal"
|
|
|
-import { errorMessage } from "../../util/error"
|
|
|
-import { SessionPreviewPane, createLeadingTrailingSignal } from "./preview-pane"
|
|
|
-import { relativeTime } from "./util"
|
|
|
-
|
|
|
-export function SessionSwitcherDialog() {
|
|
|
- const dialog = useDialog()
|
|
|
- const route = useRoute()
|
|
|
- const sync = useSync()
|
|
|
- const project = useProject()
|
|
|
- const { theme } = useTheme()
|
|
|
- const sdk = useSDK()
|
|
|
- const local = useLocal()
|
|
|
- const toast = useToast()
|
|
|
- const dimensions = useTerminalDimensions()
|
|
|
- const [toDelete, setToDelete] = createSignal<string>()
|
|
|
- const [search, setSearch] = createDebouncedSignal("", 150)
|
|
|
- const deleteHint = useCommandShortcut("session.delete")
|
|
|
- const quickSwitch1 = useCommandShortcut("session.quick_switch.1")
|
|
|
- const quickSwitch9 = useCommandShortcut("session.quick_switch.9")
|
|
|
- let select: DialogSelectRef<string> | undefined
|
|
|
-
|
|
|
- const [searchResults, { refetch }] = createResource(
|
|
|
- () => ({ query: search(), filter: sync.session.query() }),
|
|
|
- async (input) => {
|
|
|
- if (!input.query) return undefined
|
|
|
- const result = await sdk.client.session.list({ search: input.query, limit: 30, ...input.filter })
|
|
|
- return result.data ?? []
|
|
|
- },
|
|
|
- )
|
|
|
-
|
|
|
- const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
|
|
|
- const sessions = createMemo(() => searchResults() ?? sync.data.session)
|
|
|
- const [focusedSession, setFocusedSession, scheduleFocused] = createLeadingTrailingSignal<string | undefined>(
|
|
|
- undefined,
|
|
|
- 150,
|
|
|
- )
|
|
|
- const focusedSessionInfo = createMemo(() => {
|
|
|
- const id = focusedSession()
|
|
|
- if (!id) return undefined
|
|
|
- return sessions().find((session) => session.id === id) ?? sync.data.session.find((session) => session.id === id)
|
|
|
- })
|
|
|
-
|
|
|
- function recoverFailed(session: NonNullable<ReturnType<typeof sessions>[number]>) {
|
|
|
- const workspace = project.workspace.get(session.workspaceID!)
|
|
|
- const list = () => dialog.replace(() => <SessionSwitcherDialog />)
|
|
|
- const warp = async (selection: WorkspaceSelection) => {
|
|
|
- const workspaceID = await (async () => {
|
|
|
- if (selection.type === "none") return null
|
|
|
- if (selection.type === "existing") return selection.workspaceID
|
|
|
- const result = await sdk.client.experimental.workspace
|
|
|
- .create({ type: selection.workspaceType, branch: null })
|
|
|
- .catch(() => undefined)
|
|
|
- const created = result?.data
|
|
|
- if (!created) {
|
|
|
- toast.show({
|
|
|
- message: `Failed to create workspace: ${errorMessage(result?.error ?? "no response")}`,
|
|
|
- variant: "error",
|
|
|
- })
|
|
|
- return
|
|
|
- }
|
|
|
- await project.workspace.sync()
|
|
|
- return created.id
|
|
|
- })()
|
|
|
- if (workspaceID === undefined) return
|
|
|
- await warpWorkspaceSession({
|
|
|
- dialog,
|
|
|
- sdk,
|
|
|
- sync,
|
|
|
- project,
|
|
|
- toast,
|
|
|
- sourceWorkspaceID: session.workspaceID,
|
|
|
- workspaceID,
|
|
|
- sessionID: session.id,
|
|
|
- copyChanges: false,
|
|
|
- done: list,
|
|
|
- })
|
|
|
- }
|
|
|
- dialog.replace(() => (
|
|
|
- <DialogSessionDeleteFailed
|
|
|
- session={session.title}
|
|
|
- workspace={workspace?.name ?? session.workspaceID!}
|
|
|
- onDone={list}
|
|
|
- onDelete={async () => {
|
|
|
- const current = currentSessionID()
|
|
|
- const info = current ? sync.data.session.find((item) => item.id === current) : undefined
|
|
|
- const result = await sdk.client.experimental.workspace.remove({ id: session.workspaceID! })
|
|
|
- if (result.error) {
|
|
|
- toast.show({
|
|
|
- variant: "error",
|
|
|
- title: "Failed to delete workspace",
|
|
|
- message: errorMessage(result.error),
|
|
|
- })
|
|
|
- return false
|
|
|
- }
|
|
|
- await project.workspace.sync()
|
|
|
- await sync.session.refresh()
|
|
|
- if (search()) await refetch()
|
|
|
- if (info?.workspaceID === session.workspaceID) {
|
|
|
- route.navigate({ type: "home" })
|
|
|
- }
|
|
|
- return true
|
|
|
- }}
|
|
|
- onRestore={() => {
|
|
|
- void openWorkspaceSelect({
|
|
|
- dialog,
|
|
|
- sdk,
|
|
|
- sync,
|
|
|
- project,
|
|
|
- toast,
|
|
|
- onSelect: (selection) => {
|
|
|
- void warp(selection)
|
|
|
- },
|
|
|
- })
|
|
|
- return false
|
|
|
- }}
|
|
|
- />
|
|
|
- ))
|
|
|
- }
|
|
|
-
|
|
|
- function orderByRecency(sessionsList: NonNullable<ReturnType<typeof sessions>>) {
|
|
|
- return sessionsList
|
|
|
- .filter((x) => x.parentID === undefined)
|
|
|
- .toSorted((a, b) => b.time.updated - a.time.updated)
|
|
|
- .map((x) => x.id)
|
|
|
- }
|
|
|
-
|
|
|
- const [browseOrder] = createSignal<string[]>(orderByRecency(sync.data.session))
|
|
|
-
|
|
|
- const quickSwitchHint = createMemo(() => {
|
|
|
- const first = quickSwitch1()
|
|
|
- const last = quickSwitch9()
|
|
|
- if (!first || !last) return undefined
|
|
|
- return quickSwitchRange(first, last)
|
|
|
- })
|
|
|
- const options = createMemo<DialogSelectOption<string>[]>(() => {
|
|
|
- const today = new Date().toDateString()
|
|
|
- const sessionMap = new Map(
|
|
|
- sessions()
|
|
|
- .filter((x) => x.parentID === undefined)
|
|
|
- .map((x) => [x.id, x]),
|
|
|
- )
|
|
|
-
|
|
|
- const searchResult = searchResults()
|
|
|
- const displayOrder = searchResult ? orderByRecency(searchResult) : browseOrder()
|
|
|
-
|
|
|
- const pinned = local.session.pinned().filter((id) => sessionMap.has(id))
|
|
|
- const pinnedSet = new Set(pinned)
|
|
|
- const slotByID = new Map<string, number>(local.session.slots().map((id, i) => [id, i + 1]))
|
|
|
-
|
|
|
- function buildOption(id: string, category: string): DialogSelectOption<string> | undefined {
|
|
|
- const x = sessionMap.get(id)
|
|
|
- if (!x) return undefined
|
|
|
- const workspace = x.workspaceID ? project.workspace.get(x.workspaceID) : undefined
|
|
|
-
|
|
|
- const footer = relativeTime(x.time.updated)
|
|
|
- const isWorktree = workspace?.type === "worktree"
|
|
|
-
|
|
|
- const isDeleting = toDelete() === x.id
|
|
|
- const status = sync.data.session_status?.[x.id]
|
|
|
- const isWorking = status?.type === "busy" || status?.type === "retry"
|
|
|
- const slot = slotByID.get(x.id)
|
|
|
- const gutter =
|
|
|
- slot !== undefined || isWorking
|
|
|
- ? () => (
|
|
|
- <box flexDirection="row" gap={1}>
|
|
|
- <Show when={slot !== undefined}>
|
|
|
- <text fg={theme.accent}>{slot}</text>
|
|
|
- </Show>
|
|
|
- <Show when={isWorking}>
|
|
|
- <Spinner />
|
|
|
- </Show>
|
|
|
- </box>
|
|
|
- )
|
|
|
- : undefined
|
|
|
- const titleText = isDeleting ? `Press ${deleteHint()} again to confirm` : isWorktree ? `⎇ ${x.title}` : x.title
|
|
|
- return {
|
|
|
- title: titleText,
|
|
|
- bg: isDeleting ? theme.error : undefined,
|
|
|
- value: x.id,
|
|
|
- category,
|
|
|
- categoryView:
|
|
|
- category === "Pinned" ? (
|
|
|
- <text>
|
|
|
- <span style={{ fg: theme.accent }}>
|
|
|
- <b>Pinned</b>
|
|
|
- </span>
|
|
|
- <Show when={quickSwitchHint()}>
|
|
|
- {(hint) => <span style={{ fg: theme.textMuted }}> · switch {hint()}</span>}
|
|
|
- </Show>
|
|
|
- </text>
|
|
|
- ) : undefined,
|
|
|
- footer,
|
|
|
- gutter,
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- const remaining = displayOrder
|
|
|
- .filter((id) => !pinnedSet.has(id))
|
|
|
- .map((id) => {
|
|
|
- const x = sessionMap.get(id)
|
|
|
- if (!x) return undefined
|
|
|
- const label = new Date(x.time.updated).toDateString()
|
|
|
- return buildOption(id, label === today ? "Today" : label)
|
|
|
- })
|
|
|
- .filter((x): x is DialogSelectOption<string> => x !== undefined)
|
|
|
-
|
|
|
- return [
|
|
|
- ...pinned.map((id) => buildOption(id, "Pinned")).filter((x): x is DialogSelectOption<string> => x !== undefined),
|
|
|
- ...remaining,
|
|
|
- ]
|
|
|
- })
|
|
|
-
|
|
|
- createEffect(
|
|
|
- on([options, currentSessionID], ([items, current]) => {
|
|
|
- const selected = untrack(() => select?.selected)
|
|
|
- const selectedID = selected && items.some((item) => item.value === selected.value) ? selected.value : undefined
|
|
|
- const currentID = current && items.some((item) => item.value === current) ? current : undefined
|
|
|
- setFocusedSession(selectedID ?? currentID ?? items[0]?.value)
|
|
|
- }),
|
|
|
- )
|
|
|
-
|
|
|
- const showPreview = createMemo(() => dimensions().width >= 100)
|
|
|
- const height = createMemo(() => Math.max(8, Math.floor(dimensions().height / 2) - 4))
|
|
|
-
|
|
|
- createEffect(() => {
|
|
|
- dialog.setSize(showPreview() ? "xlarge" : "large")
|
|
|
- })
|
|
|
-
|
|
|
- const list = (
|
|
|
- <DialogSelect
|
|
|
- ref={(value) => (select = value)}
|
|
|
- title="Sessions"
|
|
|
- options={options()}
|
|
|
- skipFilter={true}
|
|
|
- current={currentSessionID()}
|
|
|
- onFilter={setSearch}
|
|
|
- onMove={(option) => {
|
|
|
- setToDelete(undefined)
|
|
|
- scheduleFocused(option.value)
|
|
|
- }}
|
|
|
- onSelect={(option) => {
|
|
|
- route.navigate({
|
|
|
- type: "session",
|
|
|
- sessionID: option.value,
|
|
|
- })
|
|
|
- dialog.clear()
|
|
|
- }}
|
|
|
- actions={[
|
|
|
- {
|
|
|
- command: "session.pin.toggle",
|
|
|
- title: "pin/unpin",
|
|
|
- onTrigger: (option: { value: string }) => {
|
|
|
- local.session.togglePin(option.value)
|
|
|
- queueMicrotask(() => select?.moveTo(option.value))
|
|
|
- },
|
|
|
- },
|
|
|
- {
|
|
|
- command: "session.delete",
|
|
|
- title: "delete",
|
|
|
- onTrigger: async (option) => {
|
|
|
- if (toDelete() === option.value) {
|
|
|
- const session = sessions().find((item) => item.id === option.value)
|
|
|
- const status = session?.workspaceID ? project.workspace.status(session.workspaceID) : undefined
|
|
|
-
|
|
|
- try {
|
|
|
- const result = await sdk.client.session.delete({
|
|
|
- sessionID: option.value,
|
|
|
- })
|
|
|
- if (result.error) {
|
|
|
- if (session?.workspaceID) {
|
|
|
- recoverFailed(session)
|
|
|
- } else {
|
|
|
- toast.show({
|
|
|
- variant: "error",
|
|
|
- title: "Failed to delete session",
|
|
|
- message: errorMessage(result.error),
|
|
|
- })
|
|
|
- }
|
|
|
- setToDelete(undefined)
|
|
|
- return
|
|
|
- }
|
|
|
- } catch (err) {
|
|
|
- if (session?.workspaceID) {
|
|
|
- recoverFailed(session)
|
|
|
- } else {
|
|
|
- toast.show({
|
|
|
- variant: "error",
|
|
|
- title: "Failed to delete session",
|
|
|
- message: errorMessage(err),
|
|
|
- })
|
|
|
- }
|
|
|
- setToDelete(undefined)
|
|
|
- return
|
|
|
- }
|
|
|
- if (status && status !== "connected") {
|
|
|
- await sync.session.refresh()
|
|
|
- }
|
|
|
- if (search()) await refetch()
|
|
|
- setToDelete(undefined)
|
|
|
- return
|
|
|
- }
|
|
|
- setToDelete(option.value)
|
|
|
- },
|
|
|
- },
|
|
|
- {
|
|
|
- command: "session.rename",
|
|
|
- title: "rename",
|
|
|
- onTrigger: async (option) => {
|
|
|
- dialog.replace(() => <DialogSessionRename session={option.value} />)
|
|
|
- },
|
|
|
- },
|
|
|
- ]}
|
|
|
- />
|
|
|
- )
|
|
|
-
|
|
|
- return (
|
|
|
- <box flexDirection="row" width="100%" height={height()}>
|
|
|
- <box flexBasis={showPreview() ? 68 : undefined} flexGrow={showPreview() ? 0 : 1} flexShrink={0}>
|
|
|
- {list}
|
|
|
- </box>
|
|
|
- <Show when={showPreview()}>
|
|
|
- <box width={1} height={height() - 1} flexShrink={0} border={["left"]} borderColor={theme.borderSubtle} />
|
|
|
- <box flexGrow={1} flexShrink={1} flexDirection="column">
|
|
|
- <SessionPreviewPane sessionID={focusedSession} session={focusedSessionInfo} />
|
|
|
- </box>
|
|
|
- </Show>
|
|
|
- </box>
|
|
|
- )
|
|
|
-}
|
|
|
-
|
|
|
-function quickSwitchRange(first: string, last: string) {
|
|
|
- const prefix = first.slice(0, -1)
|
|
|
- if (first.endsWith("1") && last === `${prefix}9`) return `${prefix}1-9`
|
|
|
- return `${first} through ${last}`
|
|
|
-}
|