| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364 |
- 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}`
- }
|