198 lines
7.3 KiB
TypeScript
198 lines
7.3 KiB
TypeScript
import { createMemo, createResource, createSignal, onMount } from "solid-js"
|
|
import path from "path"
|
|
import type { SessionInfo } from "@opencode-ai/client"
|
|
import { useDialog } from "../ui/dialog"
|
|
import { DialogSelect } from "../ui/dialog-select"
|
|
import { useRoute } from "../context/route"
|
|
import { useData } from "../context/data"
|
|
import { Keymap } from "../context/keymap"
|
|
import { Locale } from "../util/locale"
|
|
import { useTheme } from "../context/theme"
|
|
import { useClient } from "../context/client"
|
|
import { useLocal } from "../context/local"
|
|
import { createDebouncedSignal } from "../util/signal"
|
|
import { useToast } from "../ui/toast"
|
|
import { DialogSessionRename } from "./dialog-session-rename"
|
|
import { Spinner } from "./spinner"
|
|
import { errorMessage } from "../util/error"
|
|
|
|
export function DialogSessionList() {
|
|
const dialog = useDialog()
|
|
const route = useRoute()
|
|
const data = useData()
|
|
const { theme } = useTheme()
|
|
const client = useClient()
|
|
const local = useLocal()
|
|
const toast = useToast()
|
|
const [filter, setFilter] = createSignal("")
|
|
const shortcuts = Keymap.useShortcuts()
|
|
const [search, setSearch] = createDebouncedSignal("", 150)
|
|
const [toDelete, setToDelete] = createSignal<string>()
|
|
|
|
const [searchResults] = createResource(search, async (query) => {
|
|
if (!query) return
|
|
try {
|
|
if (!data.location.info()) await data.location.sync()
|
|
const current = data.location.info()
|
|
if (!current) throw new Error("Location unavailable")
|
|
const response = await client.api.session.list({
|
|
project: current.project.id,
|
|
search: query,
|
|
limit: 50,
|
|
order: "desc",
|
|
parentID: null,
|
|
})
|
|
return { query, sessions: response.data, error: undefined }
|
|
} catch (error) {
|
|
// A transient transport failure must degrade search, not crash the TUI
|
|
// through the root ErrorBoundary when the errored resource is read.
|
|
return { query, sessions: [] as SessionInfo[], error }
|
|
}
|
|
})
|
|
|
|
const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
|
|
const localSessions = createMemo(() => {
|
|
const query = filter().trim().toLowerCase()
|
|
const sessions = data.session.list()
|
|
if (!query) return sessions
|
|
return sessions.filter((session) => !session.parentID && session.title.toLowerCase().includes(query))
|
|
})
|
|
const sessions = createMemo(() => {
|
|
const query = filter()
|
|
const local = localSessions()
|
|
if (!query) return local
|
|
if (query !== search() || searchResults.loading) return local
|
|
const result = searchResults()
|
|
if (result?.query !== query || result.error) return local
|
|
return result.sessions
|
|
})
|
|
const searchState = createMemo(() => {
|
|
const query = filter()
|
|
if (!query) return { message: "No sessions available", error: false }
|
|
if (query !== search() || searchResults.loading) return { message: "Searching sessions…", error: false }
|
|
const result = searchResults()
|
|
if (result?.query === query && result.error)
|
|
return { message: "Could not search sessions. Change the search to try again.", error: true }
|
|
return { message: "No sessions found", error: false }
|
|
})
|
|
|
|
const quickSwitchHint = createMemo(() => {
|
|
const first = shortcuts.get("session.quick_switch.1")
|
|
const last = shortcuts.get("session.quick_switch.9")
|
|
if (!first || !last) return
|
|
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((session) => !session.parentID)
|
|
.map((session) => [session.id, session]),
|
|
)
|
|
const pinned = local.session.pinned().filter((sessionID) => sessionMap.has(sessionID))
|
|
const pinnedSet = new Set(pinned)
|
|
const slotByID = new Map(local.session.slots().map((sessionID, index) => [sessionID, index + 1]))
|
|
|
|
const option = (session: SessionInfo, category: string) => {
|
|
const directory = session.location.directory
|
|
const footer =
|
|
directory !== data.location.info()?.project.directory ? Locale.truncate(path.basename(directory), 20) : ""
|
|
const slot = slotByID.get(session.id)
|
|
const deleting = toDelete() === session.id
|
|
return {
|
|
title: deleting ? `Press ${shortcuts.get("session.delete")} again to confirm` : session.title,
|
|
value: session.id,
|
|
category,
|
|
footer,
|
|
bg: deleting ? theme.error : undefined,
|
|
gutter: data.session.family(session.id).some((id) => data.session.status(id) === "running")
|
|
? () => <Spinner />
|
|
: slot === undefined
|
|
? undefined
|
|
: () => <text fg={theme.accent}>{slot}</text>,
|
|
}
|
|
}
|
|
|
|
const remaining = sessions()
|
|
.filter((session) => !session.parentID && !pinnedSet.has(session.id))
|
|
.map((session) => {
|
|
const date = new Date(session.time.updated).toDateString()
|
|
return option(session, date === today ? "Today" : date)
|
|
})
|
|
|
|
return [...pinned.map((sessionID) => option(sessionMap.get(sessionID)!, "Pinned")), ...remaining]
|
|
})
|
|
|
|
onMount(() => dialog.setSize("large"))
|
|
|
|
return (
|
|
<DialogSelect
|
|
title="Sessions"
|
|
options={options()}
|
|
skipFilter={true}
|
|
current={currentSessionID()}
|
|
onFilter={(query) => {
|
|
setFilter(query)
|
|
setSearch(query)
|
|
}}
|
|
emptyView={
|
|
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
|
<text fg={theme.textMuted}>No sessions available</text>
|
|
</box>
|
|
}
|
|
noMatchView={
|
|
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
|
|
<text fg={searchState().error ? theme.error : theme.textMuted}>{searchState().message}</text>
|
|
</box>
|
|
}
|
|
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: (option: { value: string }) => {
|
|
if (toDelete() !== option.value) {
|
|
setToDelete(option.value)
|
|
return
|
|
}
|
|
void client.api.session.remove({ sessionID: option.value }).catch((error) => {
|
|
setToDelete(undefined)
|
|
toast.show({
|
|
message: `Failed to delete session: ${errorMessage(error)}`,
|
|
variant: "error",
|
|
duration: 5000,
|
|
})
|
|
})
|
|
},
|
|
},
|
|
{
|
|
command: "session.rename",
|
|
title: "rename",
|
|
onTrigger: (option: { value: string }) =>
|
|
DialogSessionRename.show(dialog, option.value, data.session.get(option.value)?.title),
|
|
},
|
|
]}
|
|
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}`
|
|
}
|