layout.tsx 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261
  1. import { createStore, produce } from "solid-js/store"
  2. import { batch, createMemo, onMount } from "solid-js"
  3. import { createSimpleContext } from "@opencode-ai/ui/context"
  4. import { makePersisted } from "@solid-primitives/storage"
  5. import { useGlobalSync } from "./global-sync"
  6. import { useGlobalSDK } from "./global-sdk"
  7. import { Project } from "@opencode-ai/sdk/v2"
  8. const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
  9. export type AvatarColorKey = (typeof AVATAR_COLOR_KEYS)[number]
  10. export function getAvatarColors(key?: string) {
  11. if (key && AVATAR_COLOR_KEYS.includes(key as AvatarColorKey)) {
  12. return {
  13. background: `var(--avatar-background-${key})`,
  14. foreground: `var(--avatar-text-${key})`,
  15. }
  16. }
  17. return {
  18. background: "var(--surface-info-base)",
  19. foreground: "var(--text-base)",
  20. }
  21. }
  22. type SessionTabs = {
  23. active?: string
  24. all: string[]
  25. }
  26. export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
  27. name: "Layout",
  28. init: () => {
  29. const globalSdk = useGlobalSDK()
  30. const globalSync = useGlobalSync()
  31. const [store, setStore] = makePersisted(
  32. createStore({
  33. projects: [] as { worktree: string; expanded: boolean }[],
  34. sidebar: {
  35. opened: false,
  36. width: 280,
  37. },
  38. terminal: {
  39. opened: false,
  40. height: 280,
  41. },
  42. review: {
  43. state: "pane" as "pane" | "tab",
  44. },
  45. steps: {
  46. expanded: false,
  47. },
  48. sessionTabs: {} as Record<string, SessionTabs>,
  49. }),
  50. {
  51. name: "layout.v3",
  52. },
  53. )
  54. const usedColors = new Set<AvatarColorKey>()
  55. function pickAvailableColor(): AvatarColorKey {
  56. const available = AVATAR_COLOR_KEYS.filter((c) => !usedColors.has(c))
  57. if (available.length === 0) return AVATAR_COLOR_KEYS[Math.floor(Math.random() * AVATAR_COLOR_KEYS.length)]
  58. return available[Math.floor(Math.random() * available.length)]
  59. }
  60. function enrich(project: { worktree: string; expanded: boolean }) {
  61. const metadata = globalSync.data.project.find((x) => x.worktree === project.worktree)
  62. if (!metadata) return []
  63. return [
  64. {
  65. ...project,
  66. ...metadata,
  67. },
  68. ]
  69. }
  70. function colorize(project: Project & { expanded: boolean }) {
  71. if (project.icon?.color) return project
  72. const color = pickAvailableColor()
  73. usedColors.add(color)
  74. project.icon = { ...project.icon, color }
  75. globalSdk.client.project.update({ projectID: project.id, icon: { color } })
  76. return project
  77. }
  78. const enriched = createMemo(() => store.projects.flatMap(enrich))
  79. const list = createMemo(() => enriched().flatMap(colorize))
  80. onMount(() => {
  81. Promise.all(
  82. store.projects.map((project) => {
  83. return globalSync.project.loadSessions(project.worktree)
  84. }),
  85. )
  86. })
  87. return {
  88. projects: {
  89. list,
  90. open(directory: string) {
  91. if (store.projects.find((x) => x.worktree === directory)) return
  92. globalSync.project.loadSessions(directory)
  93. setStore("projects", (x) => [{ worktree: directory, expanded: true }, ...x])
  94. },
  95. close(directory: string) {
  96. setStore("projects", (x) => x.filter((x) => x.worktree !== directory))
  97. },
  98. expand(directory: string) {
  99. setStore("projects", (x) => x.map((x) => (x.worktree === directory ? { ...x, expanded: true } : x)))
  100. },
  101. collapse(directory: string) {
  102. setStore("projects", (x) => x.map((x) => (x.worktree === directory ? { ...x, expanded: false } : x)))
  103. },
  104. move(directory: string, toIndex: number) {
  105. setStore("projects", (projects) => {
  106. const fromIndex = projects.findIndex((x) => x.worktree === directory)
  107. if (fromIndex === -1 || fromIndex === toIndex) return projects
  108. const result = [...projects]
  109. const [item] = result.splice(fromIndex, 1)
  110. result.splice(toIndex, 0, item)
  111. return result
  112. })
  113. },
  114. },
  115. sidebar: {
  116. opened: createMemo(() => store.sidebar.opened),
  117. open() {
  118. setStore("sidebar", "opened", true)
  119. },
  120. close() {
  121. setStore("sidebar", "opened", false)
  122. },
  123. toggle() {
  124. setStore("sidebar", "opened", (x) => !x)
  125. },
  126. width: createMemo(() => store.sidebar.width),
  127. resize(width: number) {
  128. setStore("sidebar", "width", width)
  129. },
  130. },
  131. terminal: {
  132. opened: createMemo(() => store.terminal.opened),
  133. open() {
  134. setStore("terminal", "opened", true)
  135. },
  136. close() {
  137. setStore("terminal", "opened", false)
  138. },
  139. toggle() {
  140. setStore("terminal", "opened", (x) => !x)
  141. },
  142. height: createMemo(() => store.terminal.height),
  143. resize(height: number) {
  144. setStore("terminal", "height", height)
  145. },
  146. },
  147. review: {
  148. state: createMemo(() => store.review?.state ?? "closed"),
  149. pane() {
  150. setStore("review", "state", "pane")
  151. },
  152. tab() {
  153. setStore("review", "state", "tab")
  154. },
  155. },
  156. steps: {
  157. expanded: createMemo(() => store.steps?.expanded ?? false),
  158. toggle() {
  159. setStore("steps", "expanded", (x) => !x)
  160. },
  161. expand() {
  162. setStore("steps", "expanded", true)
  163. },
  164. collapse() {
  165. setStore("steps", "expanded", false)
  166. },
  167. },
  168. tabs(sessionKey: string) {
  169. const tabs = createMemo(() => store.sessionTabs[sessionKey] ?? { all: [] })
  170. return {
  171. tabs,
  172. active: createMemo(() => tabs().active),
  173. all: createMemo(() => tabs().all),
  174. setActive(tab: string | undefined) {
  175. if (!store.sessionTabs[sessionKey]) {
  176. setStore("sessionTabs", sessionKey, { all: [], active: tab })
  177. } else {
  178. setStore("sessionTabs", sessionKey, "active", tab)
  179. }
  180. },
  181. setAll(all: string[]) {
  182. if (!store.sessionTabs[sessionKey]) {
  183. setStore("sessionTabs", sessionKey, { all, active: undefined })
  184. } else {
  185. setStore("sessionTabs", sessionKey, "all", all)
  186. }
  187. },
  188. async open(tab: string) {
  189. if (tab === "chat") {
  190. if (!store.sessionTabs[sessionKey]) {
  191. setStore("sessionTabs", sessionKey, { all: [], active: undefined })
  192. } else {
  193. setStore("sessionTabs", sessionKey, "active", undefined)
  194. }
  195. return
  196. }
  197. const current = store.sessionTabs[sessionKey] ?? { all: [] }
  198. if (tab !== "review") {
  199. if (!current.all.includes(tab)) {
  200. if (!store.sessionTabs[sessionKey]) {
  201. setStore("sessionTabs", sessionKey, { all: [tab], active: tab })
  202. } else {
  203. setStore("sessionTabs", sessionKey, "all", [...current.all, tab])
  204. setStore("sessionTabs", sessionKey, "active", tab)
  205. }
  206. return
  207. }
  208. }
  209. if (!store.sessionTabs[sessionKey]) {
  210. setStore("sessionTabs", sessionKey, { all: [], active: tab })
  211. } else {
  212. setStore("sessionTabs", sessionKey, "active", tab)
  213. }
  214. },
  215. close(tab: string) {
  216. const current = store.sessionTabs[sessionKey]
  217. if (!current) return
  218. batch(() => {
  219. setStore(
  220. "sessionTabs",
  221. sessionKey,
  222. "all",
  223. current.all.filter((x) => x !== tab),
  224. )
  225. if (current.active === tab) {
  226. const index = current.all.findIndex((f) => f === tab)
  227. const previous = current.all[Math.max(0, index - 1)]
  228. setStore("sessionTabs", sessionKey, "active", previous)
  229. }
  230. })
  231. },
  232. move(tab: string, to: number) {
  233. const current = store.sessionTabs[sessionKey]
  234. if (!current) return
  235. const index = current.all.findIndex((f) => f === tab)
  236. if (index === -1) return
  237. setStore(
  238. "sessionTabs",
  239. sessionKey,
  240. "all",
  241. produce((opened) => {
  242. opened.splice(to, 0, opened.splice(index, 1)[0])
  243. }),
  244. )
  245. },
  246. }
  247. },
  248. }
  249. },
  250. })