Request root sessions before applying the session list limit so child sessions cannot crowd roots out of the switcher. Keep the synchronized cache available while requests are pending or fail, reconcile results with live updates, and retain current and pinned sessions. Preserve selection by session ID when asynchronous results reorder the list. Closes #16270 Closes #32725
364 lines
12 KiB
TypeScript
364 lines
12 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, 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<T>(input: {
|
|
search?: string
|
|
filter: SessionListFilter
|
|
list: (query: ReturnType<typeof createDialogSessionListQuery>) => 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<string>()
|
|
const [deleted, setDeleted] = createSignal(new Set<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 [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<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()
|
|
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<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 = 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<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}
|
|
preserveSelection={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()
|
|
}
|
|
await refetchBrowse()
|
|
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}`
|
|
}
|