layout.tsx 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599
  1. import { createStore, produce } from "solid-js/store"
  2. import { batch, createEffect, createMemo, onCleanup, onMount } 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 { Project } from "@opencode-ai/sdk/v2"
  8. import { Persist, persisted, removePersisted } from "@/utils/persist"
  9. import { same } from "@/utils/same"
  10. import { createScrollPersistence, type SessionScroll } from "./layout-scroll"
  11. const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
  12. export type AvatarColorKey = (typeof AVATAR_COLOR_KEYS)[number]
  13. export function getAvatarColors(key?: string) {
  14. if (key && AVATAR_COLOR_KEYS.includes(key as AvatarColorKey)) {
  15. return {
  16. background: `var(--avatar-background-${key})`,
  17. foreground: `var(--avatar-text-${key})`,
  18. }
  19. }
  20. return {
  21. background: "var(--surface-info-base)",
  22. foreground: "var(--text-base)",
  23. }
  24. }
  25. type SessionTabs = {
  26. active?: string
  27. all: string[]
  28. }
  29. type SessionView = {
  30. scroll: Record<string, SessionScroll>
  31. reviewOpen?: string[]
  32. }
  33. export type LocalProject = Partial<Project> & { worktree: string; expanded: boolean }
  34. export type ReviewDiffStyle = "unified" | "split"
  35. export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
  36. name: "Layout",
  37. init: () => {
  38. const globalSdk = useGlobalSDK()
  39. const globalSync = useGlobalSync()
  40. const server = useServer()
  41. const isRecord = (value: unknown): value is Record<string, unknown> =>
  42. typeof value === "object" && value !== null && !Array.isArray(value)
  43. const migrate = (value: unknown) => {
  44. if (!isRecord(value)) return value
  45. const sidebar = value.sidebar
  46. if (!isRecord(sidebar)) return value
  47. if (typeof sidebar.workspaces !== "boolean") return value
  48. return {
  49. ...value,
  50. sidebar: {
  51. ...sidebar,
  52. workspaces: {},
  53. workspacesDefault: sidebar.workspaces,
  54. },
  55. }
  56. }
  57. const target = Persist.global("layout", ["layout.v6"])
  58. const [store, setStore, _, ready] = persisted(
  59. { ...target, migrate },
  60. createStore({
  61. sidebar: {
  62. opened: false,
  63. width: 344,
  64. workspaces: {} as Record<string, boolean>,
  65. workspacesDefault: false,
  66. },
  67. terminal: {
  68. height: 280,
  69. opened: false,
  70. },
  71. review: {
  72. diffStyle: "split" as ReviewDiffStyle,
  73. panelOpened: true,
  74. },
  75. session: {
  76. width: 600,
  77. },
  78. mobileSidebar: {
  79. opened: false,
  80. },
  81. sessionTabs: {} as Record<string, SessionTabs>,
  82. sessionView: {} as Record<string, SessionView>,
  83. }),
  84. )
  85. const MAX_SESSION_KEYS = 50
  86. const meta = { active: undefined as string | undefined, pruned: false }
  87. const used = new Map<string, number>()
  88. const SESSION_STATE_KEYS = [
  89. { key: "prompt", legacy: "prompt", version: "v2" },
  90. { key: "terminal", legacy: "terminal", version: "v1" },
  91. { key: "file-view", legacy: "file", version: "v1" },
  92. ] as const
  93. const dropSessionState = (keys: string[]) => {
  94. for (const key of keys) {
  95. const parts = key.split("/")
  96. const dir = parts[0]
  97. const session = parts[1]
  98. if (!dir) continue
  99. for (const entry of SESSION_STATE_KEYS) {
  100. const target = session ? Persist.session(dir, session, entry.key) : Persist.workspace(dir, entry.key)
  101. void removePersisted(target)
  102. const legacyKey = `${dir}/${entry.legacy}${session ? "/" + session : ""}.${entry.version}`
  103. void removePersisted({ key: legacyKey })
  104. }
  105. }
  106. }
  107. function prune(keep?: string) {
  108. if (!keep) return
  109. const keys = new Set<string>()
  110. for (const key of Object.keys(store.sessionView)) keys.add(key)
  111. for (const key of Object.keys(store.sessionTabs)) keys.add(key)
  112. if (keys.size <= MAX_SESSION_KEYS) return
  113. const score = (key: string) => {
  114. if (key === keep) return Number.MAX_SAFE_INTEGER
  115. return used.get(key) ?? 0
  116. }
  117. const ordered = Array.from(keys).sort((a, b) => score(b) - score(a))
  118. const drop = ordered.slice(MAX_SESSION_KEYS)
  119. if (drop.length === 0) return
  120. setStore(
  121. produce((draft) => {
  122. for (const key of drop) {
  123. delete draft.sessionView[key]
  124. delete draft.sessionTabs[key]
  125. }
  126. }),
  127. )
  128. scroll.drop(drop)
  129. dropSessionState(drop)
  130. for (const key of drop) {
  131. used.delete(key)
  132. }
  133. }
  134. function touch(sessionKey: string) {
  135. meta.active = sessionKey
  136. used.set(sessionKey, Date.now())
  137. if (!ready()) return
  138. if (meta.pruned) return
  139. meta.pruned = true
  140. prune(sessionKey)
  141. }
  142. const scroll = createScrollPersistence({
  143. debounceMs: 250,
  144. getSnapshot: (sessionKey) => store.sessionView[sessionKey]?.scroll,
  145. onFlush: (sessionKey, next) => {
  146. const current = store.sessionView[sessionKey]
  147. const keep = meta.active ?? sessionKey
  148. if (!current) {
  149. setStore("sessionView", sessionKey, { scroll: next })
  150. prune(keep)
  151. return
  152. }
  153. setStore("sessionView", sessionKey, "scroll", (prev) => ({ ...(prev ?? {}), ...next }))
  154. prune(keep)
  155. },
  156. })
  157. createEffect(() => {
  158. if (!ready()) return
  159. if (meta.pruned) return
  160. const active = meta.active
  161. if (!active) return
  162. meta.pruned = true
  163. prune(active)
  164. })
  165. onMount(() => {
  166. const flush = () => batch(() => scroll.flushAll())
  167. const handleVisibility = () => {
  168. if (document.visibilityState !== "hidden") return
  169. flush()
  170. }
  171. window.addEventListener("pagehide", flush)
  172. document.addEventListener("visibilitychange", handleVisibility)
  173. onCleanup(() => {
  174. window.removeEventListener("pagehide", flush)
  175. document.removeEventListener("visibilitychange", handleVisibility)
  176. scroll.dispose()
  177. })
  178. })
  179. const [colors, setColors] = createStore<Record<string, AvatarColorKey>>({})
  180. function pickAvailableColor(used: Set<string>): AvatarColorKey {
  181. const available = AVATAR_COLOR_KEYS.filter((c) => !used.has(c))
  182. if (available.length === 0) return AVATAR_COLOR_KEYS[Math.floor(Math.random() * AVATAR_COLOR_KEYS.length)]
  183. return available[Math.floor(Math.random() * available.length)]
  184. }
  185. function enrich(project: { worktree: string; expanded: boolean }) {
  186. const [childStore] = globalSync.child(project.worktree)
  187. const projectID = childStore.project
  188. const metadata = projectID
  189. ? globalSync.data.project.find((x) => x.id === projectID)
  190. : globalSync.data.project.find((x) => x.worktree === project.worktree)
  191. const local = childStore.projectMeta
  192. const localOverride =
  193. local?.name !== undefined ||
  194. local?.commands?.start !== undefined ||
  195. local?.icon?.override !== undefined ||
  196. local?.icon?.color !== undefined
  197. const base = {
  198. ...(metadata ?? {}),
  199. ...project,
  200. icon: {
  201. url: metadata?.icon?.url,
  202. override: metadata?.icon?.override,
  203. color: metadata?.icon?.color,
  204. },
  205. }
  206. const isGlobal = projectID === "global" || (metadata?.id === undefined && localOverride)
  207. if (!isGlobal) return base
  208. return {
  209. ...base,
  210. id: base.id ?? "global",
  211. name: local?.name,
  212. commands: local?.commands,
  213. icon: {
  214. url: base.icon?.url,
  215. override: local?.icon?.override,
  216. color: local?.icon?.color,
  217. },
  218. }
  219. }
  220. const roots = createMemo(() => {
  221. const map = new Map<string, string>()
  222. for (const project of globalSync.data.project) {
  223. const sandboxes = project.sandboxes ?? []
  224. for (const sandbox of sandboxes) {
  225. map.set(sandbox, project.worktree)
  226. }
  227. }
  228. return map
  229. })
  230. createEffect(() => {
  231. const map = roots()
  232. if (map.size === 0) return
  233. const projects = server.projects.list()
  234. const seen = new Set(projects.map((project) => project.worktree))
  235. batch(() => {
  236. for (const project of projects) {
  237. const root = map.get(project.worktree)
  238. if (!root) continue
  239. server.projects.close(project.worktree)
  240. if (!seen.has(root)) {
  241. server.projects.open(root)
  242. seen.add(root)
  243. }
  244. if (project.expanded) server.projects.expand(root)
  245. }
  246. })
  247. })
  248. const enriched = createMemo(() => server.projects.list().map(enrich))
  249. const list = createMemo(() => {
  250. const projects = enriched()
  251. return projects.map((project) => {
  252. const color = project.icon?.color ?? colors[project.worktree]
  253. if (!color) return project
  254. const icon = project.icon ? { ...project.icon, color } : { color }
  255. return { ...project, icon }
  256. })
  257. })
  258. createEffect(() => {
  259. const projects = enriched()
  260. if (projects.length === 0) return
  261. const used = new Set<string>()
  262. for (const project of projects) {
  263. const color = project.icon?.color ?? colors[project.worktree]
  264. if (color) used.add(color)
  265. }
  266. for (const project of projects) {
  267. if (project.icon?.color) continue
  268. const existing = colors[project.worktree]
  269. const color = existing ?? pickAvailableColor(used)
  270. if (!existing) {
  271. used.add(color)
  272. setColors(project.worktree, color)
  273. }
  274. if (!project.id) continue
  275. if (project.id === "global") {
  276. globalSync.project.meta(project.worktree, { icon: { color } })
  277. continue
  278. }
  279. void globalSdk.client.project.update({ projectID: project.id, directory: project.worktree, icon: { color } })
  280. }
  281. })
  282. onMount(() => {
  283. Promise.all(
  284. server.projects.list().map((project) => {
  285. return globalSync.project.loadSessions(project.worktree)
  286. }),
  287. )
  288. })
  289. return {
  290. ready,
  291. projects: {
  292. list,
  293. open(directory: string) {
  294. const root = roots().get(directory) ?? directory
  295. if (server.projects.list().find((x) => x.worktree === root)) return
  296. globalSync.project.loadSessions(root)
  297. server.projects.open(root)
  298. },
  299. close(directory: string) {
  300. server.projects.close(directory)
  301. },
  302. expand(directory: string) {
  303. server.projects.expand(directory)
  304. },
  305. collapse(directory: string) {
  306. server.projects.collapse(directory)
  307. },
  308. move(directory: string, toIndex: number) {
  309. server.projects.move(directory, toIndex)
  310. },
  311. },
  312. sidebar: {
  313. opened: createMemo(() => store.sidebar.opened),
  314. open() {
  315. setStore("sidebar", "opened", true)
  316. },
  317. close() {
  318. setStore("sidebar", "opened", false)
  319. },
  320. toggle() {
  321. setStore("sidebar", "opened", (x) => !x)
  322. },
  323. width: createMemo(() => store.sidebar.width),
  324. resize(width: number) {
  325. setStore("sidebar", "width", width)
  326. },
  327. workspaces(directory: string) {
  328. return createMemo(() => store.sidebar.workspaces[directory] ?? store.sidebar.workspacesDefault ?? false)
  329. },
  330. setWorkspaces(directory: string, value: boolean) {
  331. setStore("sidebar", "workspaces", directory, value)
  332. },
  333. toggleWorkspaces(directory: string) {
  334. const current = store.sidebar.workspaces[directory] ?? store.sidebar.workspacesDefault ?? false
  335. setStore("sidebar", "workspaces", directory, !current)
  336. },
  337. },
  338. terminal: {
  339. height: createMemo(() => store.terminal.height),
  340. resize(height: number) {
  341. setStore("terminal", "height", height)
  342. },
  343. },
  344. review: {
  345. diffStyle: createMemo(() => store.review?.diffStyle ?? "split"),
  346. setDiffStyle(diffStyle: ReviewDiffStyle) {
  347. if (!store.review) {
  348. setStore("review", { diffStyle })
  349. return
  350. }
  351. setStore("review", "diffStyle", diffStyle)
  352. },
  353. },
  354. session: {
  355. width: createMemo(() => store.session?.width ?? 600),
  356. resize(width: number) {
  357. if (!store.session) {
  358. setStore("session", { width })
  359. return
  360. }
  361. setStore("session", "width", width)
  362. },
  363. },
  364. mobileSidebar: {
  365. opened: createMemo(() => store.mobileSidebar?.opened ?? false),
  366. show() {
  367. setStore("mobileSidebar", "opened", true)
  368. },
  369. hide() {
  370. setStore("mobileSidebar", "opened", false)
  371. },
  372. toggle() {
  373. setStore("mobileSidebar", "opened", (x) => !x)
  374. },
  375. },
  376. view(sessionKey: string) {
  377. touch(sessionKey)
  378. scroll.seed(sessionKey)
  379. const s = createMemo(() => store.sessionView[sessionKey] ?? { scroll: {} })
  380. const terminalOpened = createMemo(() => store.terminal?.opened ?? false)
  381. const reviewPanelOpened = createMemo(() => store.review?.panelOpened ?? true)
  382. function setTerminalOpened(next: boolean) {
  383. const current = store.terminal
  384. if (!current) {
  385. setStore("terminal", { height: 280, opened: next })
  386. return
  387. }
  388. const value = current.opened ?? false
  389. if (value === next) return
  390. setStore("terminal", "opened", next)
  391. }
  392. function setReviewPanelOpened(next: boolean) {
  393. const current = store.review
  394. if (!current) {
  395. setStore("review", { diffStyle: "split" as ReviewDiffStyle, panelOpened: next })
  396. return
  397. }
  398. const value = current.panelOpened ?? true
  399. if (value === next) return
  400. setStore("review", "panelOpened", next)
  401. }
  402. return {
  403. scroll(tab: string) {
  404. return scroll.scroll(sessionKey, tab)
  405. },
  406. setScroll(tab: string, pos: SessionScroll) {
  407. scroll.setScroll(sessionKey, tab, pos)
  408. },
  409. terminal: {
  410. opened: terminalOpened,
  411. open() {
  412. setTerminalOpened(true)
  413. },
  414. close() {
  415. setTerminalOpened(false)
  416. },
  417. toggle() {
  418. setTerminalOpened(!terminalOpened())
  419. },
  420. },
  421. reviewPanel: {
  422. opened: reviewPanelOpened,
  423. open() {
  424. setReviewPanelOpened(true)
  425. },
  426. close() {
  427. setReviewPanelOpened(false)
  428. },
  429. toggle() {
  430. setReviewPanelOpened(!reviewPanelOpened())
  431. },
  432. },
  433. review: {
  434. open: createMemo(() => s().reviewOpen),
  435. setOpen(open: string[]) {
  436. const current = store.sessionView[sessionKey]
  437. if (!current) {
  438. setStore("sessionView", sessionKey, {
  439. scroll: {},
  440. reviewOpen: open,
  441. })
  442. return
  443. }
  444. if (same(current.reviewOpen, open)) return
  445. setStore("sessionView", sessionKey, "reviewOpen", open)
  446. },
  447. },
  448. }
  449. },
  450. tabs(sessionKey: string) {
  451. touch(sessionKey)
  452. const tabs = createMemo(() => store.sessionTabs[sessionKey] ?? { all: [] })
  453. return {
  454. tabs,
  455. active: createMemo(() => tabs().active),
  456. all: createMemo(() => tabs().all),
  457. setActive(tab: string | undefined) {
  458. if (!store.sessionTabs[sessionKey]) {
  459. setStore("sessionTabs", sessionKey, { all: [], active: tab })
  460. } else {
  461. setStore("sessionTabs", sessionKey, "active", tab)
  462. }
  463. },
  464. setAll(all: string[]) {
  465. if (!store.sessionTabs[sessionKey]) {
  466. setStore("sessionTabs", sessionKey, { all, active: undefined })
  467. } else {
  468. setStore("sessionTabs", sessionKey, "all", all)
  469. }
  470. },
  471. async open(tab: string) {
  472. const current = store.sessionTabs[sessionKey] ?? { all: [] }
  473. if (tab === "review") {
  474. if (!store.sessionTabs[sessionKey]) {
  475. setStore("sessionTabs", sessionKey, { all: [], active: tab })
  476. return
  477. }
  478. setStore("sessionTabs", sessionKey, "active", tab)
  479. return
  480. }
  481. if (tab === "context") {
  482. const all = [tab, ...current.all.filter((x) => x !== tab)]
  483. if (!store.sessionTabs[sessionKey]) {
  484. setStore("sessionTabs", sessionKey, { all, active: tab })
  485. return
  486. }
  487. setStore("sessionTabs", sessionKey, "all", all)
  488. setStore("sessionTabs", sessionKey, "active", tab)
  489. return
  490. }
  491. if (!current.all.includes(tab)) {
  492. if (!store.sessionTabs[sessionKey]) {
  493. setStore("sessionTabs", sessionKey, { all: [tab], active: tab })
  494. return
  495. }
  496. setStore("sessionTabs", sessionKey, "all", [...current.all, tab])
  497. setStore("sessionTabs", sessionKey, "active", tab)
  498. return
  499. }
  500. if (!store.sessionTabs[sessionKey]) {
  501. setStore("sessionTabs", sessionKey, { all: current.all, active: tab })
  502. return
  503. }
  504. setStore("sessionTabs", sessionKey, "active", tab)
  505. },
  506. close(tab: string) {
  507. const current = store.sessionTabs[sessionKey]
  508. if (!current) return
  509. const all = current.all.filter((x) => x !== tab)
  510. batch(() => {
  511. setStore("sessionTabs", sessionKey, "all", all)
  512. if (current.active !== tab) return
  513. const index = current.all.findIndex((f) => f === tab)
  514. const next = all[index - 1] ?? all[0]
  515. setStore("sessionTabs", sessionKey, "active", next)
  516. })
  517. },
  518. move(tab: string, to: number) {
  519. const current = store.sessionTabs[sessionKey]
  520. if (!current) return
  521. const index = current.all.findIndex((f) => f === tab)
  522. if (index === -1) return
  523. setStore(
  524. "sessionTabs",
  525. sessionKey,
  526. "all",
  527. produce((opened) => {
  528. opened.splice(to, 0, opened.splice(index, 1)[0])
  529. }),
  530. )
  531. },
  532. }
  533. },
  534. }
  535. },
  536. })