Download packages/tui/src/component/dialog-session-list.tsx from SaylorTwift/opencode: direct link, hf CLI and curl.
- Browser
- Download file 12.5 kB
-
https://huggingface.co/SaylorTwift/opencode/resolve/main/packages/tui/src/component/dialog-session-list.tsx
- Command line
-
hf download hf://SaylorTwift/opencode/packages/tui/src/component/dialog-session-list.tsx
-
curl -L -o dialog-session-list.tsx https://huggingface.co/SaylorTwift/opencode/resolve/main/packages/tui/src/component/dialog-session-list.tsx
12.5 kB
| 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}` | |
| } | |