dialog-session-list.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364
  1. import { useDialog } from "../ui/dialog"
  2. import { DialogSelect } from "../ui/dialog-select"
  3. import { useRoute } from "../context/route"
  4. import { useSync } from "../context/sync"
  5. import { createMemo, createResource, createSignal, onCleanup, onMount } from "solid-js"
  6. import path from "path"
  7. import { Locale } from "../util/locale"
  8. import { useProject } from "../context/project"
  9. import { useTheme } from "../context/theme"
  10. import { useSDK } from "../context/sdk"
  11. import { useLocal } from "../context/local"
  12. import { DialogSessionRename } from "./dialog-session-rename"
  13. import { createDebouncedSignal } from "../util/signal"
  14. import { useToast } from "../ui/toast"
  15. import { openWorkspaceSelect, type WorkspaceSelection, warpWorkspaceSession } from "./dialog-workspace-create"
  16. import { Spinner } from "./spinner"
  17. import { errorMessage } from "../util/error"
  18. import { DialogSessionDeleteFailed } from "./dialog-session-delete-failed"
  19. import { useCommandShortcut } from "../keymap"
  20. import { useEvent } from "../context/event"
  21. type SessionListFilter = { scope?: "project"; path?: string }
  22. export function createDialogSessionListQuery(input: { search?: string; filter: SessionListFilter }) {
  23. const search = input.search?.trim()
  24. return {
  25. roots: true,
  26. limit: search ? 30 : 100,
  27. ...(search ? { search } : {}),
  28. ...input.filter,
  29. }
  30. }
  31. export function loadDialogSessionList<T>(input: {
  32. search?: string
  33. filter: SessionListFilter
  34. list: (query: ReturnType<typeof createDialogSessionListQuery>) => Promise<{ data?: T[] }>
  35. }) {
  36. return input.list(createDialogSessionListQuery(input)).then(
  37. (result) => result.data,
  38. () => undefined,
  39. )
  40. }
  41. export function DialogSessionList() {
  42. const dialog = useDialog()
  43. const route = useRoute()
  44. const sync = useSync()
  45. const project = useProject()
  46. const { theme } = useTheme()
  47. const sdk = useSDK()
  48. const event = useEvent()
  49. const local = useLocal()
  50. const toast = useToast()
  51. const [toDelete, setToDelete] = createSignal<string>()
  52. const [deleted, setDeleted] = createSignal(new Set<string>())
  53. const [search, setSearch] = createDebouncedSignal("", 150)
  54. const deleteHint = useCommandShortcut("session.delete")
  55. const quickSwitch1 = useCommandShortcut("session.quick_switch.1")
  56. const quickSwitch9 = useCommandShortcut("session.quick_switch.9")
  57. const [browseResults, { refetch: refetchBrowse }] = createResource(
  58. () => sync.session.query(),
  59. (filter) => loadDialogSessionList({ filter, list: (query) => sdk.client.session.list(query) }),
  60. )
  61. const [searchResults, { refetch }] = createResource(
  62. () => ({ query: search(), filter: sync.session.query() }),
  63. (input) => {
  64. if (!input.query) return undefined
  65. return loadDialogSessionList({
  66. search: input.query,
  67. filter: input.filter,
  68. list: (query) => sdk.client.session.list(query),
  69. })
  70. },
  71. )
  72. const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
  73. const sessions = createMemo(() => {
  74. const result = searchResults() ?? browseResults() ?? sync.data.session
  75. const synced = new Map(sync.data.session.map((session) => [session.id, session]))
  76. const ids = new Set(result.map((session) => session.id))
  77. const extra = [currentSessionID(), ...local.session.pinned()].flatMap((id) => {
  78. if (!id || ids.has(id)) return []
  79. const session = synced.get(id)
  80. if (session) ids.add(id)
  81. return session ? [session] : []
  82. })
  83. const query = search().trim().toLowerCase()
  84. return [...result.map((session) => synced.get(session.id) ?? session), ...extra]
  85. .filter((session) => !deleted().has(session.id))
  86. .filter((session) => !query || session.title.toLowerCase().includes(query))
  87. })
  88. onCleanup(
  89. event.on("session.deleted", (event) => {
  90. setDeleted((current) => new Set(current).add(event.properties.info.id))
  91. }),
  92. )
  93. function recover(session: NonNullable<ReturnType<typeof sessions>[number]>) {
  94. const workspace = project.workspace.get(session.workspaceID!)
  95. const list = () => dialog.replace(() => <DialogSessionList />)
  96. const warp = async (selection: WorkspaceSelection) => {
  97. const workspaceID = await (async () => {
  98. if (selection.type === "none") return null
  99. if (selection.type === "existing") return selection.workspaceID
  100. let result
  101. try {
  102. result = await sdk.client.experimental.workspace.create({ type: selection.workspaceType, branch: null })
  103. } catch (err) {
  104. toast.show({
  105. title: "Failed to create workspace",
  106. message: errorMessage(err),
  107. variant: "error",
  108. })
  109. return
  110. }
  111. const workspace = result?.data
  112. if (!workspace) {
  113. toast.show({
  114. title: "Failed to create workspace",
  115. message: errorMessage(result?.error ?? "no response"),
  116. variant: "error",
  117. })
  118. return
  119. }
  120. await project.workspace.sync()
  121. return workspace.id
  122. })()
  123. if (workspaceID === undefined) return
  124. await warpWorkspaceSession({
  125. dialog,
  126. sdk,
  127. sync,
  128. project,
  129. toast,
  130. sourceWorkspaceID: session.workspaceID,
  131. workspaceID,
  132. sessionID: session.id,
  133. copyChanges: false,
  134. done: list,
  135. })
  136. }
  137. dialog.replace(() => (
  138. <DialogSessionDeleteFailed
  139. session={session.title}
  140. workspace={workspace?.name ?? session.workspaceID!}
  141. onDone={list}
  142. onDelete={async () => {
  143. const current = currentSessionID()
  144. const info = current ? sync.data.session.find((item) => item.id === current) : undefined
  145. const result = await sdk.client.experimental.workspace.remove({ id: session.workspaceID! })
  146. if (result.error) {
  147. toast.show({
  148. variant: "error",
  149. title: "Failed to delete workspace",
  150. message: errorMessage(result.error),
  151. })
  152. return false
  153. }
  154. await project.workspace.sync()
  155. await sync.session.refresh()
  156. await refetchBrowse()
  157. if (search()) await refetch()
  158. if (info?.workspaceID === session.workspaceID) {
  159. route.navigate({ type: "home" })
  160. }
  161. return true
  162. }}
  163. onRestore={() => {
  164. void openWorkspaceSelect({
  165. dialog,
  166. sdk,
  167. sync,
  168. project,
  169. toast,
  170. onSelect: (selection) => {
  171. void warp(selection)
  172. },
  173. })
  174. return false
  175. }}
  176. />
  177. ))
  178. }
  179. function orderByRecency(sessionsList: NonNullable<ReturnType<typeof sessions>>) {
  180. return sessionsList
  181. .filter((x) => x.parentID === undefined)
  182. .toSorted((a, b) => b.time.updated - a.time.updated)
  183. .map((x) => x.id)
  184. }
  185. const browseOrder = createMemo(() => orderByRecency(browseResults() ?? sync.data.session))
  186. const quickSwitchHint = createMemo(() => {
  187. const first = quickSwitch1()
  188. const last = quickSwitch9()
  189. if (!first || !last) return undefined
  190. return quickSwitchRange(first, last)
  191. })
  192. const quickSwitchFooterHints = createMemo(() => {
  193. const hint = quickSwitchHint()
  194. return hint && local.session.slots().length > 0 ? [{ title: "switch", label: hint }] : []
  195. })
  196. const options = createMemo(() => {
  197. const today = new Date().toDateString()
  198. const sessionMap = new Map(
  199. sessions()
  200. .filter((x) => x.parentID === undefined)
  201. .map((x) => [x.id, x]),
  202. )
  203. const searchResult = searchResults()
  204. const order = searchResult ? orderByRecency(sessions()) : browseOrder()
  205. const current = currentSessionID()
  206. const displayOrder = current && sessionMap.has(current) && !order.includes(current) ? [...order, current] : order
  207. const pinned = local.session.pinned().filter((id) => sessionMap.has(id))
  208. const pinnedSet = new Set(pinned)
  209. const slotByID = new Map<string, number>(local.session.slots().map((id, i) => [id, i + 1]))
  210. function buildOption(id: string, category: string) {
  211. const x = sessionMap.get(id)
  212. if (!x) return undefined
  213. const directory = x.path
  214. ? x.directory.endsWith(x.path)
  215. ? x.directory.slice(0, -x.path.length).replace(/\/$/, "")
  216. : undefined
  217. : x.directory
  218. const footer =
  219. directory && directory !== project.data.project.mainDir ? Locale.truncate(path.basename(directory), 20) : ""
  220. const isDeleting = toDelete() === x.id
  221. const status = sync.data.session_status?.[x.id]
  222. const isWorking = status?.type === "busy" || status?.type === "retry"
  223. const slot = slotByID.get(x.id)
  224. const gutter = isWorking
  225. ? () => <Spinner />
  226. : slot !== undefined
  227. ? () => <text fg={theme.accent}>{slot}</text>
  228. : undefined
  229. return {
  230. title: isDeleting ? `Press ${deleteHint()} again to confirm` : x.title,
  231. bg: isDeleting ? theme.error : undefined,
  232. value: x.id,
  233. category,
  234. footer,
  235. gutter,
  236. }
  237. }
  238. const remaining = displayOrder
  239. .filter((id) => !pinnedSet.has(id))
  240. .map((id) => {
  241. const x = sessionMap.get(id)
  242. if (!x) return undefined
  243. const label = new Date(x.time.updated).toDateString()
  244. return buildOption(id, label === today ? "Today" : label)
  245. })
  246. .filter((x) => x !== undefined)
  247. return [...pinned.map((id) => buildOption(id, "Pinned")).filter((x) => x !== undefined), ...remaining]
  248. })
  249. onMount(() => {
  250. dialog.setSize("large")
  251. })
  252. return (
  253. <DialogSelect
  254. title="Sessions"
  255. options={options()}
  256. skipFilter={true}
  257. preserveSelection={true}
  258. current={currentSessionID()}
  259. onFilter={setSearch}
  260. onMove={() => {
  261. setToDelete(undefined)
  262. }}
  263. onSelect={(option) => {
  264. route.navigate({
  265. type: "session",
  266. sessionID: option.value,
  267. })
  268. dialog.clear()
  269. }}
  270. actions={[
  271. {
  272. command: "session.pin.toggle",
  273. title: "pin/unpin",
  274. onTrigger: (option: { value: string }) => {
  275. local.session.togglePin(option.value)
  276. },
  277. },
  278. {
  279. command: "session.delete",
  280. title: "delete",
  281. onTrigger: async (option) => {
  282. if (toDelete() === option.value) {
  283. const session = sessions().find((item) => item.id === option.value)
  284. const status = session?.workspaceID ? project.workspace.status(session.workspaceID) : undefined
  285. try {
  286. const result = await sdk.client.session.delete({
  287. sessionID: option.value,
  288. })
  289. if (result.error) {
  290. if (session?.workspaceID) {
  291. recover(session)
  292. } else {
  293. toast.show({
  294. variant: "error",
  295. title: "Failed to delete session",
  296. message: errorMessage(result.error),
  297. })
  298. }
  299. setToDelete(undefined)
  300. return
  301. }
  302. } catch (err) {
  303. if (session?.workspaceID) {
  304. recover(session)
  305. } else {
  306. toast.show({
  307. variant: "error",
  308. title: "Failed to delete session",
  309. message: errorMessage(err),
  310. })
  311. }
  312. setToDelete(undefined)
  313. return
  314. }
  315. if (status && status !== "connected") {
  316. await sync.session.refresh()
  317. }
  318. await refetchBrowse()
  319. if (search()) await refetch()
  320. setToDelete(undefined)
  321. return
  322. }
  323. setToDelete(option.value)
  324. },
  325. },
  326. {
  327. command: "session.rename",
  328. title: "rename",
  329. onTrigger: async (option) => {
  330. dialog.replace(() => <DialogSessionRename session={option.value} />)
  331. },
  332. },
  333. ]}
  334. footerHints={quickSwitchFooterHints()}
  335. />
  336. )
  337. }
  338. function quickSwitchRange(first: string, last: string) {
  339. const prefix = first.slice(0, -1)
  340. if (first.endsWith("1") && last === `${prefix}9`) return `${prefix}1-9`
  341. return `${first} through ${last}`
  342. }