layout.tsx 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937
  1. import { createStore, produce } from "solid-js/store"
  2. import { batch, createEffect, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
  3. import { createSimpleContext } from "@opencode-ai/ui/context"
  4. import { useGlobalSync } from "./global-sync"
  5. import { useGlobalSDK } from "./global-sdk"
  6. import { useServer } from "./server"
  7. import { usePlatform } from "./platform"
  8. import { Project } from "@opencode-ai/sdk/v2"
  9. import { Persist, persisted, removePersisted } from "@/utils/persist"
  10. import { decode64 } from "@/utils/base64"
  11. import { same } from "@/utils/same"
  12. import { createScrollPersistence, type SessionScroll } from "./layout-scroll"
  13. import { createPathHelpers } from "./file/path"
  14. const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
  15. const DEFAULT_PANEL_WIDTH = 344
  16. const DEFAULT_SESSION_WIDTH = 600
  17. const DEFAULT_TERMINAL_HEIGHT = 280
  18. export type AvatarColorKey = (typeof AVATAR_COLOR_KEYS)[number]
  19. export function getAvatarColors(key?: string) {
  20. if (key && AVATAR_COLOR_KEYS.includes(key as AvatarColorKey)) {
  21. return {
  22. background: `var(--avatar-background-${key})`,
  23. foreground: `var(--avatar-text-${key})`,
  24. }
  25. }
  26. return {
  27. background: "var(--surface-info-base)",
  28. foreground: "var(--text-base)",
  29. }
  30. }
  31. type SessionTabs = {
  32. active?: string
  33. all: string[]
  34. }
  35. type SessionView = {
  36. scroll: Record<string, SessionScroll>
  37. reviewOpen?: string[]
  38. pendingMessage?: string
  39. pendingMessageAt?: number
  40. }
  41. type TabHandoff = {
  42. dir: string
  43. id: string
  44. at: number
  45. }
  46. export type LocalProject = Partial<Project> & { worktree: string; expanded: boolean }
  47. export type ReviewDiffStyle = "unified" | "split"
  48. export function ensureSessionKey(key: string, touch: (key: string) => void, seed: (key: string) => void) {
  49. touch(key)
  50. seed(key)
  51. return key
  52. }
  53. export function createSessionKeyReader(sessionKey: string | Accessor<string>, ensure: (key: string) => void) {
  54. const key = typeof sessionKey === "function" ? sessionKey : () => sessionKey
  55. return () => {
  56. const value = key()
  57. ensure(value)
  58. return value
  59. }
  60. }
  61. export function pruneSessionKeys(input: {
  62. keep?: string
  63. max: number
  64. used: Map<string, number>
  65. view: string[]
  66. tabs: string[]
  67. }) {
  68. if (!input.keep) return []
  69. const keys = new Set<string>([...input.view, ...input.tabs])
  70. if (keys.size <= input.max) return []
  71. const score = (key: string) => {
  72. if (key === input.keep) return Number.MAX_SAFE_INTEGER
  73. return input.used.get(key) ?? 0
  74. }
  75. return Array.from(keys)
  76. .sort((a, b) => score(b) - score(a))
  77. .slice(input.max)
  78. }
  79. function nextSessionTabsForOpen(current: SessionTabs | undefined, tab: string): SessionTabs {
  80. const all = current?.all ?? []
  81. if (tab === "review") return { all: all.filter((x) => x !== "review"), active: tab }
  82. if (tab === "context") return { all: [tab, ...all.filter((x) => x !== tab)], active: tab }
  83. if (!all.includes(tab)) return { all: [...all, tab], active: tab }
  84. return { all, active: tab }
  85. }
  86. const sessionPath = (key: string) => {
  87. const dir = key.split("/")[0]
  88. if (!dir) return
  89. const root = decode64(dir)
  90. if (!root) return
  91. return createPathHelpers(() => root)
  92. }
  93. const normalizeSessionTab = (path: ReturnType<typeof createPathHelpers> | undefined, tab: string) => {
  94. if (!tab.startsWith("file://")) return tab
  95. if (!path) return tab
  96. return path.tab(tab)
  97. }
  98. const normalizeSessionTabList = (path: ReturnType<typeof createPathHelpers> | undefined, all: string[]) => {
  99. const seen = new Set<string>()
  100. return all.flatMap((tab) => {
  101. const value = normalizeSessionTab(path, tab)
  102. if (seen.has(value)) return []
  103. seen.add(value)
  104. return [value]
  105. })
  106. }
  107. const normalizeStoredSessionTabs = (key: string, tabs: SessionTabs) => {
  108. const path = sessionPath(key)
  109. return {
  110. all: normalizeSessionTabList(path, tabs.all),
  111. active: tabs.active ? normalizeSessionTab(path, tabs.active) : tabs.active,
  112. }
  113. }
  114. export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
  115. name: "Layout",
  116. init: () => {
  117. const globalSdk = useGlobalSDK()
  118. const globalSync = useGlobalSync()
  119. const server = useServer()
  120. const platform = usePlatform()
  121. const isRecord = (value: unknown): value is Record<string, unknown> =>
  122. typeof value === "object" && value !== null && !Array.isArray(value)
  123. const migrate = (value: unknown) => {
  124. if (!isRecord(value)) return value
  125. const sidebar = value.sidebar
  126. const migratedSidebar = (() => {
  127. if (!isRecord(sidebar)) return sidebar
  128. if (typeof sidebar.workspaces !== "boolean") return sidebar
  129. return {
  130. ...sidebar,
  131. workspaces: {},
  132. workspacesDefault: sidebar.workspaces,
  133. }
  134. })()
  135. const review = value.review
  136. const fileTree = value.fileTree
  137. const migratedFileTree = (() => {
  138. if (!isRecord(fileTree)) return fileTree
  139. if (fileTree.tab === "changes" || fileTree.tab === "all") return fileTree
  140. const width = typeof fileTree.width === "number" ? fileTree.width : DEFAULT_PANEL_WIDTH
  141. return {
  142. ...fileTree,
  143. opened: true,
  144. width: width === 260 ? DEFAULT_PANEL_WIDTH : width,
  145. tab: "changes",
  146. }
  147. })()
  148. const migratedReview = (() => {
  149. if (!isRecord(review)) return review
  150. if (typeof review.panelOpened === "boolean") return review
  151. const opened = isRecord(fileTree) && typeof fileTree.opened === "boolean" ? fileTree.opened : true
  152. return {
  153. ...review,
  154. panelOpened: opened,
  155. }
  156. })()
  157. const sessionTabs = value.sessionTabs
  158. const migratedSessionTabs = (() => {
  159. if (!isRecord(sessionTabs)) return sessionTabs
  160. let changed = false
  161. const next = Object.fromEntries(
  162. Object.entries(sessionTabs).map(([key, tabs]) => {
  163. if (!isRecord(tabs) || !Array.isArray(tabs.all)) return [key, tabs]
  164. const current = {
  165. all: tabs.all.filter((tab): tab is string => typeof tab === "string"),
  166. active: typeof tabs.active === "string" ? tabs.active : undefined,
  167. }
  168. const normalized = normalizeStoredSessionTabs(key, current)
  169. if (current.all.length !== tabs.all.length) changed = true
  170. if (!same(current.all, normalized.all) || current.active !== normalized.active) changed = true
  171. if (tabs.active !== undefined && typeof tabs.active !== "string") changed = true
  172. return [key, normalized]
  173. }),
  174. )
  175. if (!changed) return sessionTabs
  176. return next
  177. })()
  178. if (
  179. migratedSidebar === sidebar &&
  180. migratedReview === review &&
  181. migratedFileTree === fileTree &&
  182. migratedSessionTabs === sessionTabs
  183. ) {
  184. return value
  185. }
  186. return {
  187. ...value,
  188. sidebar: migratedSidebar,
  189. review: migratedReview,
  190. fileTree: migratedFileTree,
  191. sessionTabs: migratedSessionTabs,
  192. }
  193. }
  194. const target = Persist.global("layout", ["layout.v6"])
  195. const [store, setStore, _, ready] = persisted(
  196. { ...target, migrate },
  197. createStore({
  198. sidebar: {
  199. opened: false,
  200. width: DEFAULT_PANEL_WIDTH,
  201. workspaces: {} as Record<string, boolean>,
  202. workspacesDefault: false,
  203. },
  204. terminal: {
  205. height: DEFAULT_TERMINAL_HEIGHT,
  206. opened: false,
  207. },
  208. review: {
  209. diffStyle: "split" as ReviewDiffStyle,
  210. panelOpened: true,
  211. },
  212. fileTree: {
  213. opened: true,
  214. width: DEFAULT_PANEL_WIDTH,
  215. tab: "changes" as "changes" | "all",
  216. },
  217. session: {
  218. width: DEFAULT_SESSION_WIDTH,
  219. },
  220. mobileSidebar: {
  221. opened: false,
  222. },
  223. sessionTabs: {} as Record<string, SessionTabs>,
  224. sessionView: {} as Record<string, SessionView>,
  225. handoff: {
  226. tabs: undefined as TabHandoff | undefined,
  227. },
  228. }),
  229. )
  230. const MAX_SESSION_KEYS = 50
  231. const PENDING_MESSAGE_TTL_MS = 2 * 60 * 1000
  232. const usage = {
  233. active: undefined as string | undefined,
  234. pruned: false,
  235. used: new Map<string, number>(),
  236. }
  237. const SESSION_STATE_KEYS = [
  238. { key: "prompt", legacy: "prompt", version: "v2" },
  239. { key: "terminal", legacy: "terminal", version: "v1" },
  240. { key: "file-view", legacy: "file", version: "v1" },
  241. ] as const
  242. const dropSessionState = (keys: string[]) => {
  243. for (const key of keys) {
  244. const parts = key.split("/")
  245. const dir = parts[0]
  246. const session = parts[1]
  247. if (!dir) continue
  248. for (const entry of SESSION_STATE_KEYS) {
  249. const target = session ? Persist.session(dir, session, entry.key) : Persist.workspace(dir, entry.key)
  250. void removePersisted(target, platform)
  251. const legacyKey = `${dir}/${entry.legacy}${session ? "/" + session : ""}.${entry.version}`
  252. void removePersisted({ key: legacyKey }, platform)
  253. }
  254. }
  255. }
  256. function prune(keep?: string) {
  257. const drop = pruneSessionKeys({
  258. keep,
  259. max: MAX_SESSION_KEYS,
  260. used: usage.used,
  261. view: Object.keys(store.sessionView),
  262. tabs: Object.keys(store.sessionTabs),
  263. })
  264. if (drop.length === 0) return
  265. setStore(
  266. produce((draft) => {
  267. for (const key of drop) {
  268. delete draft.sessionView[key]
  269. delete draft.sessionTabs[key]
  270. }
  271. }),
  272. )
  273. scroll.drop(drop)
  274. dropSessionState(drop)
  275. for (const key of drop) {
  276. usage.used.delete(key)
  277. }
  278. }
  279. function touch(sessionKey: string) {
  280. usage.active = sessionKey
  281. usage.used.set(sessionKey, Date.now())
  282. if (!ready()) return
  283. if (usage.pruned) return
  284. usage.pruned = true
  285. prune(sessionKey)
  286. }
  287. const scroll = createScrollPersistence({
  288. debounceMs: 250,
  289. getSnapshot: (sessionKey) => store.sessionView[sessionKey]?.scroll,
  290. onFlush: (sessionKey, next) => {
  291. const current = store.sessionView[sessionKey]
  292. const keep = usage.active ?? sessionKey
  293. if (!current) {
  294. setStore("sessionView", sessionKey, { scroll: next })
  295. prune(keep)
  296. return
  297. }
  298. setStore("sessionView", sessionKey, "scroll", (prev) => ({ ...(prev ?? {}), ...next }))
  299. prune(keep)
  300. },
  301. })
  302. const ensureKey = (key: string) => ensureSessionKey(key, touch, (sessionKey) => scroll.seed(sessionKey))
  303. createEffect(() => {
  304. if (!ready()) return
  305. if (usage.pruned) return
  306. const active = usage.active
  307. if (!active) return
  308. usage.pruned = true
  309. prune(active)
  310. })
  311. onMount(() => {
  312. const flush = () => batch(() => scroll.flushAll())
  313. const handleVisibility = () => {
  314. if (document.visibilityState !== "hidden") return
  315. flush()
  316. }
  317. window.addEventListener("pagehide", flush)
  318. document.addEventListener("visibilitychange", handleVisibility)
  319. onCleanup(() => {
  320. window.removeEventListener("pagehide", flush)
  321. document.removeEventListener("visibilitychange", handleVisibility)
  322. scroll.dispose()
  323. })
  324. })
  325. const [colors, setColors] = createStore<Record<string, AvatarColorKey>>({})
  326. const colorRequested = new Map<string, AvatarColorKey>()
  327. function pickAvailableColor(used: Set<string>): AvatarColorKey {
  328. const available = AVATAR_COLOR_KEYS.filter((c) => !used.has(c))
  329. if (available.length === 0) return AVATAR_COLOR_KEYS[Math.floor(Math.random() * AVATAR_COLOR_KEYS.length)]
  330. return available[Math.floor(Math.random() * available.length)]
  331. }
  332. function enrich(project: { worktree: string; expanded: boolean }) {
  333. const [childStore] = globalSync.child(project.worktree, { bootstrap: false })
  334. const projectID = childStore.project
  335. const metadata = projectID
  336. ? globalSync.data.project.find((x) => x.id === projectID)
  337. : globalSync.data.project.find((x) => x.worktree === project.worktree)
  338. const local = childStore.projectMeta
  339. const localOverride =
  340. local?.name !== undefined ||
  341. local?.commands?.start !== undefined ||
  342. local?.icon?.override !== undefined ||
  343. local?.icon?.color !== undefined
  344. const base = {
  345. ...(metadata ?? {}),
  346. ...project,
  347. icon: {
  348. url: metadata?.icon?.url,
  349. override: metadata?.icon?.override ?? childStore.icon,
  350. color: metadata?.icon?.color,
  351. },
  352. }
  353. const isGlobal = projectID === "global" || (metadata?.id === undefined && localOverride)
  354. if (!isGlobal) return base
  355. return {
  356. ...base,
  357. id: base.id ?? "global",
  358. name: local?.name,
  359. commands: local?.commands,
  360. icon: {
  361. url: base.icon?.url,
  362. override: local?.icon?.override,
  363. color: local?.icon?.color,
  364. },
  365. }
  366. }
  367. const roots = createMemo(() => {
  368. const map = new Map<string, string>()
  369. for (const project of globalSync.data.project) {
  370. const sandboxes = project.sandboxes ?? []
  371. for (const sandbox of sandboxes) {
  372. map.set(sandbox, project.worktree)
  373. }
  374. }
  375. return map
  376. })
  377. const rootFor = (directory: string) => {
  378. const map = roots()
  379. if (map.size === 0) return directory
  380. const visited = new Set<string>()
  381. const chain = [directory]
  382. while (chain.length) {
  383. const current = chain[chain.length - 1]
  384. if (!current) return directory
  385. const next = map.get(current)
  386. if (!next) return current
  387. if (visited.has(next)) return directory
  388. visited.add(next)
  389. chain.push(next)
  390. }
  391. return directory
  392. }
  393. createEffect(() => {
  394. const projects = server.projects.list()
  395. const seen = new Set(projects.map((project) => project.worktree))
  396. batch(() => {
  397. for (const project of projects) {
  398. const root = rootFor(project.worktree)
  399. if (root === project.worktree) continue
  400. server.projects.close(project.worktree)
  401. if (!seen.has(root)) {
  402. server.projects.open(root)
  403. seen.add(root)
  404. }
  405. if (project.expanded) server.projects.expand(root)
  406. }
  407. })
  408. })
  409. const enriched = createMemo(() => server.projects.list().map(enrich))
  410. const list = createMemo(() => {
  411. const projects = enriched()
  412. return projects.map((project) => {
  413. const color = project.icon?.color ?? colors[project.worktree]
  414. if (!color) return project
  415. const icon = project.icon ? { ...project.icon, color } : { color }
  416. return { ...project, icon }
  417. })
  418. })
  419. createEffect(() => {
  420. const projects = enriched()
  421. if (projects.length === 0) return
  422. if (!globalSync.ready) return
  423. for (const project of projects) {
  424. if (!project.id) continue
  425. if (project.id === "global") continue
  426. globalSync.project.icon(project.worktree, project.icon?.override)
  427. }
  428. })
  429. createEffect(() => {
  430. const projects = enriched()
  431. if (projects.length === 0) return
  432. for (const project of projects) {
  433. if (project.icon?.color) colorRequested.delete(project.worktree)
  434. }
  435. const used = new Set<string>()
  436. for (const project of projects) {
  437. const color = project.icon?.color ?? colors[project.worktree]
  438. if (color) used.add(color)
  439. }
  440. for (const project of projects) {
  441. if (project.icon?.color) continue
  442. const worktree = project.worktree
  443. const existing = colors[worktree]
  444. const color = existing ?? pickAvailableColor(used)
  445. if (!existing) {
  446. used.add(color)
  447. setColors(worktree, color)
  448. }
  449. if (!project.id) continue
  450. const requested = colorRequested.get(worktree)
  451. if (requested === color) continue
  452. colorRequested.set(worktree, color)
  453. if (project.id === "global") {
  454. globalSync.project.meta(worktree, { icon: { color } })
  455. continue
  456. }
  457. void globalSdk.client.project
  458. .update({ projectID: project.id, directory: worktree, icon: { color } })
  459. .catch(() => {
  460. if (colorRequested.get(worktree) === color) colorRequested.delete(worktree)
  461. })
  462. }
  463. })
  464. onMount(() => {
  465. Promise.all(
  466. server.projects.list().map((project) => {
  467. return globalSync.project.loadSessions(project.worktree)
  468. }),
  469. )
  470. })
  471. return {
  472. ready,
  473. handoff: {
  474. tabs: createMemo(() => store.handoff?.tabs),
  475. setTabs(dir: string, id: string) {
  476. setStore("handoff", "tabs", { dir, id, at: Date.now() })
  477. },
  478. clearTabs() {
  479. if (!store.handoff?.tabs) return
  480. setStore("handoff", "tabs", undefined)
  481. },
  482. },
  483. projects: {
  484. list,
  485. open(directory: string) {
  486. const root = rootFor(directory)
  487. if (server.projects.list().find((x) => x.worktree === root)) return
  488. globalSync.project.loadSessions(root)
  489. server.projects.open(root)
  490. },
  491. close(directory: string) {
  492. server.projects.close(directory)
  493. },
  494. expand(directory: string) {
  495. server.projects.expand(directory)
  496. },
  497. collapse(directory: string) {
  498. server.projects.collapse(directory)
  499. },
  500. move(directory: string, toIndex: number) {
  501. server.projects.move(directory, toIndex)
  502. },
  503. },
  504. sidebar: {
  505. opened: createMemo(() => store.sidebar.opened),
  506. open() {
  507. setStore("sidebar", "opened", true)
  508. },
  509. close() {
  510. setStore("sidebar", "opened", false)
  511. },
  512. toggle() {
  513. setStore("sidebar", "opened", (x) => !x)
  514. },
  515. width: createMemo(() => store.sidebar.width),
  516. resize(width: number) {
  517. setStore("sidebar", "width", width)
  518. },
  519. workspaces(directory: string) {
  520. return () => store.sidebar.workspaces[directory] ?? store.sidebar.workspacesDefault ?? false
  521. },
  522. setWorkspaces(directory: string, value: boolean) {
  523. setStore("sidebar", "workspaces", directory, value)
  524. },
  525. toggleWorkspaces(directory: string) {
  526. const current = store.sidebar.workspaces[directory] ?? store.sidebar.workspacesDefault ?? false
  527. setStore("sidebar", "workspaces", directory, !current)
  528. },
  529. },
  530. terminal: {
  531. height: createMemo(() => store.terminal.height),
  532. resize(height: number) {
  533. setStore("terminal", "height", height)
  534. },
  535. },
  536. review: {
  537. diffStyle: createMemo(() => store.review?.diffStyle ?? "split"),
  538. setDiffStyle(diffStyle: ReviewDiffStyle) {
  539. if (!store.review) {
  540. setStore("review", { diffStyle, panelOpened: true })
  541. return
  542. }
  543. setStore("review", "diffStyle", diffStyle)
  544. },
  545. },
  546. fileTree: {
  547. opened: createMemo(() => store.fileTree?.opened ?? true),
  548. width: createMemo(() => store.fileTree?.width ?? DEFAULT_PANEL_WIDTH),
  549. tab: createMemo(() => store.fileTree?.tab ?? "changes"),
  550. setTab(tab: "changes" | "all") {
  551. if (!store.fileTree) {
  552. setStore("fileTree", { opened: true, width: DEFAULT_PANEL_WIDTH, tab })
  553. return
  554. }
  555. setStore("fileTree", "tab", tab)
  556. },
  557. open() {
  558. if (!store.fileTree) {
  559. setStore("fileTree", { opened: true, width: DEFAULT_PANEL_WIDTH, tab: "changes" })
  560. return
  561. }
  562. setStore("fileTree", "opened", true)
  563. },
  564. close() {
  565. if (!store.fileTree) {
  566. setStore("fileTree", { opened: false, width: DEFAULT_PANEL_WIDTH, tab: "changes" })
  567. return
  568. }
  569. setStore("fileTree", "opened", false)
  570. },
  571. toggle() {
  572. if (!store.fileTree) {
  573. setStore("fileTree", { opened: true, width: DEFAULT_PANEL_WIDTH, tab: "changes" })
  574. return
  575. }
  576. setStore("fileTree", "opened", (x) => !x)
  577. },
  578. resize(width: number) {
  579. if (!store.fileTree) {
  580. setStore("fileTree", { opened: true, width, tab: "changes" })
  581. return
  582. }
  583. setStore("fileTree", "width", width)
  584. },
  585. },
  586. session: {
  587. width: createMemo(() => store.session?.width ?? DEFAULT_SESSION_WIDTH),
  588. resize(width: number) {
  589. if (!store.session) {
  590. setStore("session", { width })
  591. return
  592. }
  593. setStore("session", "width", width)
  594. },
  595. },
  596. mobileSidebar: {
  597. opened: createMemo(() => store.mobileSidebar?.opened ?? false),
  598. show() {
  599. setStore("mobileSidebar", "opened", true)
  600. },
  601. hide() {
  602. setStore("mobileSidebar", "opened", false)
  603. },
  604. toggle() {
  605. setStore("mobileSidebar", "opened", (x) => !x)
  606. },
  607. },
  608. pendingMessage: {
  609. set(sessionKey: string, messageID: string) {
  610. const at = Date.now()
  611. touch(sessionKey)
  612. const current = store.sessionView[sessionKey]
  613. if (!current) {
  614. setStore("sessionView", sessionKey, {
  615. scroll: {},
  616. pendingMessage: messageID,
  617. pendingMessageAt: at,
  618. })
  619. prune(usage.active ?? sessionKey)
  620. return
  621. }
  622. setStore(
  623. "sessionView",
  624. sessionKey,
  625. produce((draft) => {
  626. draft.pendingMessage = messageID
  627. draft.pendingMessageAt = at
  628. }),
  629. )
  630. },
  631. consume(sessionKey: string) {
  632. const current = store.sessionView[sessionKey]
  633. const message = current?.pendingMessage
  634. const at = current?.pendingMessageAt
  635. if (!message || !at) return
  636. setStore(
  637. "sessionView",
  638. sessionKey,
  639. produce((draft) => {
  640. delete draft.pendingMessage
  641. delete draft.pendingMessageAt
  642. }),
  643. )
  644. if (Date.now() - at > PENDING_MESSAGE_TTL_MS) return
  645. return message
  646. },
  647. },
  648. view(sessionKey: string | Accessor<string>) {
  649. const key = createSessionKeyReader(sessionKey, ensureKey)
  650. const s = createMemo(() => store.sessionView[key()] ?? { scroll: {} })
  651. const terminalOpened = createMemo(() => store.terminal?.opened ?? false)
  652. const reviewPanelOpened = createMemo(() => store.review?.panelOpened ?? true)
  653. function setTerminalOpened(next: boolean) {
  654. const current = store.terminal
  655. if (!current) {
  656. setStore("terminal", { height: DEFAULT_TERMINAL_HEIGHT, opened: next })
  657. return
  658. }
  659. const value = current.opened ?? false
  660. if (value === next) return
  661. setStore("terminal", "opened", next)
  662. }
  663. function setReviewPanelOpened(next: boolean) {
  664. const current = store.review
  665. if (!current) {
  666. setStore("review", { diffStyle: "split" as ReviewDiffStyle, panelOpened: next })
  667. return
  668. }
  669. const value = current.panelOpened ?? true
  670. if (value === next) return
  671. setStore("review", "panelOpened", next)
  672. }
  673. return {
  674. scroll(tab: string) {
  675. return scroll.scroll(key(), tab)
  676. },
  677. setScroll(tab: string, pos: SessionScroll) {
  678. scroll.setScroll(key(), tab, pos)
  679. },
  680. terminal: {
  681. opened: terminalOpened,
  682. open() {
  683. setTerminalOpened(true)
  684. },
  685. close() {
  686. setTerminalOpened(false)
  687. },
  688. toggle() {
  689. setTerminalOpened(!terminalOpened())
  690. },
  691. },
  692. reviewPanel: {
  693. opened: reviewPanelOpened,
  694. open() {
  695. setReviewPanelOpened(true)
  696. },
  697. close() {
  698. setReviewPanelOpened(false)
  699. },
  700. toggle() {
  701. setReviewPanelOpened(!reviewPanelOpened())
  702. },
  703. },
  704. review: {
  705. open: createMemo(() => s().reviewOpen ?? []),
  706. setOpen(open: string[]) {
  707. const session = key()
  708. const next = Array.from(new Set(open))
  709. const current = store.sessionView[session]
  710. if (!current) {
  711. setStore("sessionView", session, {
  712. scroll: {},
  713. reviewOpen: next,
  714. })
  715. return
  716. }
  717. if (same(current.reviewOpen, next)) return
  718. setStore("sessionView", session, "reviewOpen", next)
  719. },
  720. openPath(path: string) {
  721. const session = key()
  722. const current = store.sessionView[session]
  723. if (!current) {
  724. setStore("sessionView", session, {
  725. scroll: {},
  726. reviewOpen: [path],
  727. })
  728. return
  729. }
  730. if (!current.reviewOpen) {
  731. setStore("sessionView", session, "reviewOpen", [path])
  732. return
  733. }
  734. if (current.reviewOpen.includes(path)) return
  735. setStore("sessionView", session, "reviewOpen", current.reviewOpen.length, path)
  736. },
  737. closePath(path: string) {
  738. const session = key()
  739. const current = store.sessionView[session]?.reviewOpen
  740. if (!current) return
  741. const index = current.indexOf(path)
  742. if (index === -1) return
  743. setStore(
  744. "sessionView",
  745. session,
  746. "reviewOpen",
  747. produce((draft) => {
  748. if (!draft) return
  749. draft.splice(index, 1)
  750. }),
  751. )
  752. },
  753. togglePath(path: string) {
  754. const session = key()
  755. const current = store.sessionView[session]?.reviewOpen
  756. if (!current || !current.includes(path)) {
  757. this.openPath(path)
  758. return
  759. }
  760. this.closePath(path)
  761. },
  762. },
  763. }
  764. },
  765. tabs(sessionKey: string | Accessor<string>) {
  766. const key = createSessionKeyReader(sessionKey, ensureKey)
  767. const path = createMemo(() => sessionPath(key()))
  768. const tabs = createMemo(() => store.sessionTabs[key()] ?? { all: [] })
  769. const normalize = (tab: string) => normalizeSessionTab(path(), tab)
  770. const normalizeAll = (all: string[]) => normalizeSessionTabList(path(), all)
  771. return {
  772. tabs,
  773. active: createMemo(() => tabs().active),
  774. all: createMemo(() => tabs().all.filter((tab) => tab !== "review")),
  775. setActive(tab: string | undefined) {
  776. const session = key()
  777. const next = tab ? normalize(tab) : tab
  778. if (!store.sessionTabs[session]) {
  779. setStore("sessionTabs", session, { all: [], active: next })
  780. } else {
  781. setStore("sessionTabs", session, "active", next)
  782. }
  783. },
  784. setAll(all: string[]) {
  785. const session = key()
  786. const next = normalizeAll(all).filter((tab) => tab !== "review")
  787. if (!store.sessionTabs[session]) {
  788. setStore("sessionTabs", session, { all: next, active: undefined })
  789. } else {
  790. setStore("sessionTabs", session, "all", next)
  791. }
  792. },
  793. async open(tab: string) {
  794. const session = key()
  795. const next = nextSessionTabsForOpen(store.sessionTabs[session], normalize(tab))
  796. setStore("sessionTabs", session, next)
  797. },
  798. close(tab: string) {
  799. const session = key()
  800. const current = store.sessionTabs[session]
  801. if (!current) return
  802. if (tab === "review") {
  803. if (current.active !== tab) return
  804. setStore("sessionTabs", session, "active", current.all[0])
  805. return
  806. }
  807. const all = current.all.filter((x) => x !== tab)
  808. if (current.active !== tab) {
  809. setStore("sessionTabs", session, "all", all)
  810. return
  811. }
  812. const index = current.all.findIndex((f) => f === tab)
  813. const next = current.all[index - 1] ?? current.all[index + 1] ?? all[0]
  814. batch(() => {
  815. setStore("sessionTabs", session, "all", all)
  816. setStore("sessionTabs", session, "active", next)
  817. })
  818. },
  819. move(tab: string, to: number) {
  820. const session = key()
  821. const current = store.sessionTabs[session]
  822. if (!current) return
  823. const index = current.all.findIndex((f) => f === tab)
  824. if (index === -1) return
  825. setStore(
  826. "sessionTabs",
  827. session,
  828. "all",
  829. produce((opened) => {
  830. opened.splice(to, 0, opened.splice(index, 1)[0])
  831. }),
  832. )
  833. },
  834. }
  835. },
  836. }
  837. },
  838. })