layout.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460
  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 { persisted } 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 [store, setStore, _, ready] = persisted(
  42. "layout.v6",
  43. createStore({
  44. sidebar: {
  45. opened: false,
  46. width: 280,
  47. },
  48. terminal: {
  49. opened: false,
  50. height: 280,
  51. },
  52. review: {
  53. opened: true,
  54. diffStyle: "split" as ReviewDiffStyle,
  55. },
  56. session: {
  57. width: 600,
  58. },
  59. mobileSidebar: {
  60. opened: false,
  61. },
  62. sessionTabs: {} as Record<string, SessionTabs>,
  63. sessionView: {} as Record<string, SessionView>,
  64. }),
  65. )
  66. const MAX_SESSION_KEYS = 50
  67. const meta = { active: undefined as string | undefined, pruned: false }
  68. const used = new Map<string, number>()
  69. function prune(keep?: string) {
  70. if (!keep) return
  71. const keys = new Set<string>()
  72. for (const key of Object.keys(store.sessionView)) keys.add(key)
  73. for (const key of Object.keys(store.sessionTabs)) keys.add(key)
  74. if (keys.size <= MAX_SESSION_KEYS) return
  75. const score = (key: string) => {
  76. if (key === keep) return Number.MAX_SAFE_INTEGER
  77. return used.get(key) ?? 0
  78. }
  79. const ordered = Array.from(keys).sort((a, b) => score(b) - score(a))
  80. const drop = ordered.slice(MAX_SESSION_KEYS)
  81. if (drop.length === 0) return
  82. setStore(
  83. produce((draft) => {
  84. for (const key of drop) {
  85. delete draft.sessionView[key]
  86. delete draft.sessionTabs[key]
  87. }
  88. }),
  89. )
  90. scroll.drop(drop)
  91. for (const key of drop) {
  92. used.delete(key)
  93. }
  94. }
  95. function touch(sessionKey: string) {
  96. meta.active = sessionKey
  97. used.set(sessionKey, Date.now())
  98. if (!ready()) return
  99. if (meta.pruned) return
  100. meta.pruned = true
  101. prune(sessionKey)
  102. }
  103. const scroll = createScrollPersistence({
  104. debounceMs: 250,
  105. getSnapshot: (sessionKey) => store.sessionView[sessionKey]?.scroll,
  106. onFlush: (sessionKey, next) => {
  107. const current = store.sessionView[sessionKey]
  108. const keep = meta.active ?? sessionKey
  109. if (!current) {
  110. setStore("sessionView", sessionKey, { scroll: next })
  111. prune(keep)
  112. return
  113. }
  114. setStore("sessionView", sessionKey, "scroll", (prev) => ({ ...(prev ?? {}), ...next }))
  115. prune(keep)
  116. },
  117. })
  118. createEffect(() => {
  119. if (!ready()) return
  120. if (meta.pruned) return
  121. const active = meta.active
  122. if (!active) return
  123. meta.pruned = true
  124. prune(active)
  125. })
  126. onMount(() => {
  127. const flush = () => batch(() => scroll.flushAll())
  128. const handleVisibility = () => {
  129. if (document.visibilityState !== "hidden") return
  130. flush()
  131. }
  132. window.addEventListener("pagehide", flush)
  133. document.addEventListener("visibilitychange", handleVisibility)
  134. onCleanup(() => {
  135. window.removeEventListener("pagehide", flush)
  136. document.removeEventListener("visibilitychange", handleVisibility)
  137. scroll.dispose()
  138. })
  139. })
  140. const usedColors = new Set<AvatarColorKey>()
  141. function pickAvailableColor(): AvatarColorKey {
  142. const available = AVATAR_COLOR_KEYS.filter((c) => !usedColors.has(c))
  143. if (available.length === 0) return AVATAR_COLOR_KEYS[Math.floor(Math.random() * AVATAR_COLOR_KEYS.length)]
  144. return available[Math.floor(Math.random() * available.length)]
  145. }
  146. function enrich(project: { worktree: string; expanded: boolean }) {
  147. const [childStore] = globalSync.child(project.worktree)
  148. const projectID = childStore.project
  149. const metadata = projectID
  150. ? globalSync.data.project.find((x) => x.id === projectID)
  151. : globalSync.data.project.find((x) => x.worktree === project.worktree)
  152. return [
  153. {
  154. ...(metadata ?? {}),
  155. ...project,
  156. icon: { url: metadata?.icon?.url, color: metadata?.icon?.color },
  157. },
  158. ]
  159. }
  160. function colorize(project: LocalProject) {
  161. if (project.icon?.color) return project
  162. const color = pickAvailableColor()
  163. usedColors.add(color)
  164. project.icon = { ...project.icon, color }
  165. if (project.id) {
  166. globalSdk.client.project.update({ projectID: project.id, icon: { color } })
  167. }
  168. return project
  169. }
  170. const roots = createMemo(() => {
  171. const map = new Map<string, string>()
  172. for (const project of globalSync.data.project) {
  173. const sandboxes = project.sandboxes ?? []
  174. for (const sandbox of sandboxes) {
  175. map.set(sandbox, project.worktree)
  176. }
  177. }
  178. return map
  179. })
  180. createEffect(() => {
  181. const map = roots()
  182. if (map.size === 0) return
  183. const projects = server.projects.list()
  184. const seen = new Set(projects.map((project) => project.worktree))
  185. batch(() => {
  186. for (const project of projects) {
  187. const root = map.get(project.worktree)
  188. if (!root) continue
  189. server.projects.close(project.worktree)
  190. if (!seen.has(root)) {
  191. server.projects.open(root)
  192. seen.add(root)
  193. }
  194. if (project.expanded) server.projects.expand(root)
  195. }
  196. })
  197. })
  198. const enriched = createMemo(() => server.projects.list().flatMap(enrich))
  199. const list = createMemo(() => enriched().flatMap(colorize))
  200. onMount(() => {
  201. Promise.all(
  202. server.projects.list().map((project) => {
  203. return globalSync.project.loadSessions(project.worktree)
  204. }),
  205. )
  206. })
  207. return {
  208. ready,
  209. projects: {
  210. list,
  211. open(directory: string) {
  212. const root = roots().get(directory) ?? directory
  213. if (server.projects.list().find((x) => x.worktree === root)) return
  214. globalSync.project.loadSessions(root)
  215. server.projects.open(root)
  216. },
  217. close(directory: string) {
  218. server.projects.close(directory)
  219. },
  220. expand(directory: string) {
  221. server.projects.expand(directory)
  222. },
  223. collapse(directory: string) {
  224. server.projects.collapse(directory)
  225. },
  226. move(directory: string, toIndex: number) {
  227. server.projects.move(directory, toIndex)
  228. },
  229. },
  230. sidebar: {
  231. opened: createMemo(() => store.sidebar.opened),
  232. open() {
  233. setStore("sidebar", "opened", true)
  234. },
  235. close() {
  236. setStore("sidebar", "opened", false)
  237. },
  238. toggle() {
  239. setStore("sidebar", "opened", (x) => !x)
  240. },
  241. width: createMemo(() => store.sidebar.width),
  242. resize(width: number) {
  243. setStore("sidebar", "width", width)
  244. },
  245. },
  246. terminal: {
  247. opened: createMemo(() => store.terminal.opened),
  248. open() {
  249. setStore("terminal", "opened", true)
  250. },
  251. close() {
  252. setStore("terminal", "opened", false)
  253. },
  254. toggle() {
  255. setStore("terminal", "opened", (x) => !x)
  256. },
  257. height: createMemo(() => store.terminal.height),
  258. resize(height: number) {
  259. setStore("terminal", "height", height)
  260. },
  261. },
  262. review: {
  263. opened: createMemo(() => store.review?.opened ?? true),
  264. diffStyle: createMemo(() => store.review?.diffStyle ?? "split"),
  265. setDiffStyle(diffStyle: ReviewDiffStyle) {
  266. if (!store.review) {
  267. setStore("review", { opened: true, diffStyle })
  268. return
  269. }
  270. setStore("review", "diffStyle", diffStyle)
  271. },
  272. open() {
  273. setStore("review", "opened", true)
  274. },
  275. close() {
  276. setStore("review", "opened", false)
  277. },
  278. toggle() {
  279. setStore("review", "opened", (x) => !x)
  280. },
  281. },
  282. session: {
  283. width: createMemo(() => store.session?.width ?? 600),
  284. resize(width: number) {
  285. if (!store.session) {
  286. setStore("session", { width })
  287. return
  288. }
  289. setStore("session", "width", width)
  290. },
  291. },
  292. mobileSidebar: {
  293. opened: createMemo(() => store.mobileSidebar?.opened ?? false),
  294. show() {
  295. setStore("mobileSidebar", "opened", true)
  296. },
  297. hide() {
  298. setStore("mobileSidebar", "opened", false)
  299. },
  300. toggle() {
  301. setStore("mobileSidebar", "opened", (x) => !x)
  302. },
  303. },
  304. view(sessionKey: string) {
  305. touch(sessionKey)
  306. scroll.seed(sessionKey)
  307. const s = createMemo(() => store.sessionView[sessionKey] ?? { scroll: {} })
  308. return {
  309. scroll(tab: string) {
  310. return scroll.scroll(sessionKey, tab)
  311. },
  312. setScroll(tab: string, pos: SessionScroll) {
  313. scroll.setScroll(sessionKey, tab, pos)
  314. },
  315. review: {
  316. open: createMemo(() => s().reviewOpen),
  317. setOpen(open: string[]) {
  318. const current = store.sessionView[sessionKey]
  319. if (!current) {
  320. setStore("sessionView", sessionKey, { scroll: {}, reviewOpen: open })
  321. return
  322. }
  323. if (same(current.reviewOpen, open)) return
  324. setStore("sessionView", sessionKey, "reviewOpen", open)
  325. },
  326. },
  327. }
  328. },
  329. tabs(sessionKey: string) {
  330. touch(sessionKey)
  331. const tabs = createMemo(() => store.sessionTabs[sessionKey] ?? { all: [] })
  332. return {
  333. tabs,
  334. active: createMemo(() => tabs().active),
  335. all: createMemo(() => tabs().all),
  336. setActive(tab: string | undefined) {
  337. if (!store.sessionTabs[sessionKey]) {
  338. setStore("sessionTabs", sessionKey, { all: [], active: tab })
  339. } else {
  340. setStore("sessionTabs", sessionKey, "active", tab)
  341. }
  342. },
  343. setAll(all: string[]) {
  344. if (!store.sessionTabs[sessionKey]) {
  345. setStore("sessionTabs", sessionKey, { all, active: undefined })
  346. } else {
  347. setStore("sessionTabs", sessionKey, "all", all)
  348. }
  349. },
  350. async open(tab: string) {
  351. const current = store.sessionTabs[sessionKey] ?? { all: [] }
  352. if (tab === "review") {
  353. if (!store.sessionTabs[sessionKey]) {
  354. setStore("sessionTabs", sessionKey, { all: [], active: tab })
  355. return
  356. }
  357. setStore("sessionTabs", sessionKey, "active", tab)
  358. return
  359. }
  360. if (tab === "context") {
  361. const all = [tab, ...current.all.filter((x) => x !== tab)]
  362. if (!store.sessionTabs[sessionKey]) {
  363. setStore("sessionTabs", sessionKey, { all, active: tab })
  364. return
  365. }
  366. setStore("sessionTabs", sessionKey, "all", all)
  367. setStore("sessionTabs", sessionKey, "active", tab)
  368. return
  369. }
  370. if (!current.all.includes(tab)) {
  371. if (!store.sessionTabs[sessionKey]) {
  372. setStore("sessionTabs", sessionKey, { all: [tab], active: tab })
  373. return
  374. }
  375. setStore("sessionTabs", sessionKey, "all", [...current.all, tab])
  376. setStore("sessionTabs", sessionKey, "active", tab)
  377. return
  378. }
  379. if (!store.sessionTabs[sessionKey]) {
  380. setStore("sessionTabs", sessionKey, { all: current.all, active: tab })
  381. return
  382. }
  383. setStore("sessionTabs", sessionKey, "active", tab)
  384. },
  385. close(tab: string) {
  386. const current = store.sessionTabs[sessionKey]
  387. if (!current) return
  388. const all = current.all.filter((x) => x !== tab)
  389. batch(() => {
  390. setStore("sessionTabs", sessionKey, "all", all)
  391. if (current.active !== tab) return
  392. const index = current.all.findIndex((f) => f === tab)
  393. const next = all[index - 1] ?? all[0]
  394. setStore("sessionTabs", sessionKey, "active", next)
  395. })
  396. },
  397. move(tab: string, to: number) {
  398. const current = store.sessionTabs[sessionKey]
  399. if (!current) return
  400. const index = current.all.findIndex((f) => f === tab)
  401. if (index === -1) return
  402. setStore(
  403. "sessionTabs",
  404. sessionKey,
  405. "all",
  406. produce((opened) => {
  407. opened.splice(to, 0, opened.splice(index, 1)[0])
  408. }),
  409. )
  410. },
  411. }
  412. },
  413. }
  414. },
  415. })