layout.tsx 30 KB

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