307 lines
10 KiB
TypeScript
307 lines
10 KiB
TypeScript
import { useDialog } from "../ui/dialog"
|
|
import { DialogSelect } from "../ui/dialog-select"
|
|
import { useRoute } from "../context/route"
|
|
import { useSync } from "../context/sync"
|
|
import { createMemo, createResource, createSignal, onMount } from "solid-js"
|
|
import path from "path"
|
|
import { Locale } from "../util/locale"
|
|
import { useProject } from "../context/project"
|
|
import { useTheme } from "../context/theme"
|
|
import { useSDK } from "../context/sdk"
|
|
import { useLocal } from "../context/local"
|
|
import { DialogSessionRename } from "./dialog-session-rename"
|
|
import { createDebouncedSignal } from "../util/signal"
|
|
import { useToast } from "../ui/toast"
|
|
import { openWorkspaceSelect, type WorkspaceSelection, warpWorkspaceSession } from "./dialog-workspace-create"
|
|
import { Spinner } from "./spinner"
|
|
import { errorMessage } from "../util/error"
|
|
import { DialogSessionDeleteFailed } from "./dialog-session-delete-failed"
|
|
import { useCommandShortcut } from "../keymap"
|
|
|
|
export function DialogSessionList() {
|
|
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 [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")
|
|
|
|
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)
|
|
|
|
function recover(session: NonNullable<ReturnType<typeof sessions>[number]>) {
|
|
const workspace = project.workspace.get(session.workspaceID!)
|
|
const list = () => dialog.replace(() => <DialogSessionList />)
|
|
const warp = async (selection: WorkspaceSelection) => {
|
|
const workspaceID = await (async () => {
|
|
if (selection.type === "none") return null
|
|
if (selection.type === "existing") return selection.workspaceID
|
|
let result
|
|
try {
|
|
result = await sdk.client.experimental.workspace.create({ type: selection.workspaceType, branch: null })
|
|
} catch (err) {
|
|
toast.show({
|
|
title: "Failed to create workspace",
|
|
message: errorMessage(err),
|
|
variant: "error",
|
|
})
|
|
return
|
|
}
|
|
const workspace = result?.data
|
|
if (!workspace) {
|
|
toast.show({
|
|
title: "Failed to create workspace",
|
|
message: errorMessage(result?.error ?? "no response"),
|
|
variant: "error",
|
|
})
|
|
return
|
|
}
|
|
await project.workspace.sync()
|
|
return workspace.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 quickSwitchFooterHints = createMemo(() => {
|
|
const hint = quickSwitchHint()
|
|
return hint && local.session.slots().length > 0 ? [{ title: "switch", label: hint }] : []
|
|
})
|
|
|
|
const options = createMemo(() => {
|
|
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) {
|
|
const x = sessionMap.get(id)
|
|
if (!x) return undefined
|
|
const directory = x.path
|
|
? x.directory.endsWith(x.path)
|
|
? x.directory.slice(0, -x.path.length).replace(/\/$/, "")
|
|
: undefined
|
|
: x.directory
|
|
const footer =
|
|
directory && directory !== project.data.project.mainDir ? Locale.truncate(path.basename(directory), 20) : ""
|
|
|
|
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 = isWorking
|
|
? () => <Spinner />
|
|
: slot !== undefined
|
|
? () => <text fg={theme.accent}>{slot}</text>
|
|
: undefined
|
|
return {
|
|
title: isDeleting ? `Press ${deleteHint()} again to confirm` : x.title,
|
|
bg: isDeleting ? theme.error : undefined,
|
|
value: x.id,
|
|
category,
|
|
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 !== undefined)
|
|
|
|
return [...pinned.map((id) => buildOption(id, "Pinned")).filter((x) => x !== undefined), ...remaining]
|
|
})
|
|
|
|
onMount(() => {
|
|
dialog.setSize("large")
|
|
})
|
|
|
|
return (
|
|
<DialogSelect
|
|
title="Sessions"
|
|
options={options()}
|
|
skipFilter={true}
|
|
current={currentSessionID()}
|
|
onFilter={setSearch}
|
|
onMove={() => {
|
|
setToDelete(undefined)
|
|
}}
|
|
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)
|
|
},
|
|
},
|
|
{
|
|
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) {
|
|
recover(session)
|
|
} else {
|
|
toast.show({
|
|
variant: "error",
|
|
title: "Failed to delete session",
|
|
message: errorMessage(result.error),
|
|
})
|
|
}
|
|
setToDelete(undefined)
|
|
return
|
|
}
|
|
} catch (err) {
|
|
if (session?.workspaceID) {
|
|
recover(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} />)
|
|
},
|
|
},
|
|
]}
|
|
footerHints={quickSwitchFooterHints()}
|
|
/>
|
|
)
|
|
}
|
|
|
|
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}`
|
|
}
|