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, onCleanup, 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" import { useEvent } from "../context/event" type SessionListFilter = { scope?: "project"; path?: string } export function createDialogSessionListQuery(input: { search?: string; filter: SessionListFilter }) { const search = input.search?.trim() return { roots: true, limit: search ? 30 : 100, ...(search ? { search } : {}), ...input.filter, } } export function loadDialogSessionList(input: { search?: string filter: SessionListFilter list: (query: ReturnType) => Promise<{ data?: T[] }> }) { return input.list(createDialogSessionListQuery(input)).then( (result) => result.data, () => undefined, ) } export function DialogSessionList() { const dialog = useDialog() const route = useRoute() const sync = useSync() const project = useProject() const { theme } = useTheme() const sdk = useSDK() const event = useEvent() const local = useLocal() const toast = useToast() const [toDelete, setToDelete] = createSignal() const [deleted, setDeleted] = createSignal(new Set()) 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 [browseResults, { refetch: refetchBrowse }] = createResource( () => sync.session.query(), (filter) => loadDialogSessionList({ filter, list: (query) => sdk.client.session.list(query) }), ) const [searchResults, { refetch }] = createResource( () => ({ query: search(), filter: sync.session.query() }), (input) => { if (!input.query) return undefined return loadDialogSessionList({ search: input.query, filter: input.filter, list: (query) => sdk.client.session.list(query), }) }, ) const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined)) const sessions = createMemo(() => { const result = searchResults() ?? browseResults() ?? sync.data.session const synced = new Map(sync.data.session.map((session) => [session.id, session])) const ids = new Set(result.map((session) => session.id)) const extra = [currentSessionID(), ...local.session.pinned()].flatMap((id) => { if (!id || ids.has(id)) return [] const session = synced.get(id) if (session) ids.add(id) return session ? [session] : [] }) const query = search().trim().toLowerCase() return [...result.map((session) => synced.get(session.id) ?? session), ...extra] .filter((session) => !deleted().has(session.id)) .filter((session) => !query || session.title.toLowerCase().includes(query)) }) onCleanup( event.on("session.deleted", (event) => { setDeleted((current) => new Set(current).add(event.properties.info.id)) }), ) function recover(session: NonNullable[number]>) { const workspace = project.workspace.get(session.workspaceID!) const list = () => dialog.replace(() => ) 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(() => ( { 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() await refetchBrowse() 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>) { return sessionsList .filter((x) => x.parentID === undefined) .toSorted((a, b) => b.time.updated - a.time.updated) .map((x) => x.id) } const browseOrder = createMemo(() => orderByRecency(browseResults() ?? 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 order = searchResult ? orderByRecency(sessions()) : browseOrder() const current = currentSessionID() const displayOrder = current && sessionMap.has(current) && !order.includes(current) ? [...order, current] : order const pinned = local.session.pinned().filter((id) => sessionMap.has(id)) const pinnedSet = new Set(pinned) const slotByID = new Map(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 ? () => : slot !== undefined ? () => {slot} : 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 ( { 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() } await refetchBrowse() if (search()) await refetch() setToDelete(undefined) return } setToDelete(option.value) }, }, { command: "session.rename", title: "rename", onTrigger: async (option) => { dialog.replace(() => ) }, }, ]} 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}` }