layout.tsx 31 KB

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