layout.tsx 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226
  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 Dialog = "provider" | "model" | "connect"
  23. export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
  24. name: "Layout",
  25. init: () => {
  26. const globalSdk = useGlobalSDK()
  27. const globalSync = useGlobalSync()
  28. const [store, setStore] = makePersisted(
  29. createStore({
  30. projects: [] as { worktree: string; expanded: boolean }[],
  31. sidebar: {
  32. opened: false,
  33. width: 280,
  34. },
  35. terminal: {
  36. opened: false,
  37. height: 280,
  38. },
  39. review: {
  40. state: "pane" as "pane" | "tab",
  41. },
  42. }),
  43. {
  44. name: "layout.v1",
  45. },
  46. )
  47. const [ephemeral, setEphemeral] = createStore<{
  48. connect: {
  49. provider?: string
  50. state?: "pending" | "complete" | "error"
  51. error?: string
  52. }
  53. dialog: {
  54. open?: Dialog
  55. }
  56. }>({
  57. connect: {},
  58. dialog: {},
  59. })
  60. const usedColors = new Set<AvatarColorKey>()
  61. function pickAvailableColor(): AvatarColorKey {
  62. const available = AVATAR_COLOR_KEYS.filter((c) => !usedColors.has(c))
  63. if (available.length === 0) return AVATAR_COLOR_KEYS[Math.floor(Math.random() * AVATAR_COLOR_KEYS.length)]
  64. return available[Math.floor(Math.random() * available.length)]
  65. }
  66. function enrich(project: { worktree: string; expanded: boolean }) {
  67. const metadata = globalSync.data.project.find((x) => x.worktree === project.worktree)
  68. if (!metadata) return []
  69. return [
  70. {
  71. ...project,
  72. ...metadata,
  73. },
  74. ]
  75. }
  76. function colorize(project: Project & { expanded: boolean }) {
  77. if (project.icon?.color) return project
  78. const color = pickAvailableColor()
  79. usedColors.add(color)
  80. project.icon = { ...project.icon, color }
  81. globalSdk.client.project.update({ projectID: project.id, icon: { color } })
  82. return project
  83. }
  84. const enriched = createMemo(() => store.projects.flatMap(enrich))
  85. const list = createMemo(() => enriched().flatMap(colorize))
  86. onMount(() => {
  87. Promise.all(
  88. store.projects.map((project) => {
  89. return globalSync.project.loadSessions(project.worktree)
  90. }),
  91. )
  92. })
  93. return {
  94. projects: {
  95. list,
  96. open(directory: string) {
  97. if (store.projects.find((x) => x.worktree === directory)) return
  98. globalSync.project.loadSessions(directory)
  99. setStore("projects", (x) => [{ worktree: directory, expanded: true }, ...x])
  100. },
  101. close(directory: string) {
  102. setStore("projects", (x) => x.filter((x) => x.worktree !== directory))
  103. },
  104. expand(directory: string) {
  105. setStore("projects", (x) => x.map((x) => (x.worktree === directory ? { ...x, expanded: true } : x)))
  106. },
  107. collapse(directory: string) {
  108. setStore("projects", (x) => x.map((x) => (x.worktree === directory ? { ...x, expanded: false } : x)))
  109. },
  110. move(directory: string, toIndex: number) {
  111. setStore("projects", (projects) => {
  112. const fromIndex = projects.findIndex((x) => x.worktree === directory)
  113. if (fromIndex === -1 || fromIndex === toIndex) return projects
  114. const result = [...projects]
  115. const [item] = result.splice(fromIndex, 1)
  116. result.splice(toIndex, 0, item)
  117. return result
  118. })
  119. },
  120. },
  121. sidebar: {
  122. opened: createMemo(() => store.sidebar.opened),
  123. open() {
  124. setStore("sidebar", "opened", true)
  125. },
  126. close() {
  127. setStore("sidebar", "opened", false)
  128. },
  129. toggle() {
  130. setStore("sidebar", "opened", (x) => !x)
  131. },
  132. width: createMemo(() => store.sidebar.width),
  133. resize(width: number) {
  134. setStore("sidebar", "width", width)
  135. },
  136. },
  137. terminal: {
  138. opened: createMemo(() => store.terminal.opened),
  139. open() {
  140. setStore("terminal", "opened", true)
  141. },
  142. close() {
  143. setStore("terminal", "opened", false)
  144. },
  145. toggle() {
  146. setStore("terminal", "opened", (x) => !x)
  147. },
  148. height: createMemo(() => store.terminal.height),
  149. resize(height: number) {
  150. setStore("terminal", "height", height)
  151. },
  152. },
  153. review: {
  154. state: createMemo(() => store.review?.state ?? "closed"),
  155. pane() {
  156. setStore("review", "state", "pane")
  157. },
  158. tab() {
  159. setStore("review", "state", "tab")
  160. },
  161. },
  162. dialog: {
  163. opened: createMemo(() => ephemeral.dialog?.open),
  164. open(dialog: Dialog) {
  165. batch(() => {
  166. // if (dialog !== "connect") {
  167. // setEphemeral("connect", {})
  168. // }
  169. setEphemeral("dialog", "open", dialog)
  170. })
  171. },
  172. close(dialog: Dialog) {
  173. if (ephemeral.dialog.open === dialog) {
  174. setEphemeral(
  175. produce((state) => {
  176. state.dialog.open = undefined
  177. state.connect = {}
  178. }),
  179. )
  180. }
  181. },
  182. connect(provider: string) {
  183. setEphemeral(
  184. produce((state) => {
  185. state.dialog.open = "connect"
  186. state.connect = { provider, state: "pending" }
  187. }),
  188. )
  189. },
  190. },
  191. connect: {
  192. provider: createMemo(() => ephemeral.connect.provider),
  193. state: createMemo(() => ephemeral.connect.state),
  194. complete() {
  195. setEphemeral(
  196. produce((state) => {
  197. state.dialog.open = "model"
  198. state.connect.state = "complete"
  199. }),
  200. )
  201. },
  202. error(message: string) {
  203. setEphemeral(
  204. produce((state) => {
  205. state.connect.state = "error"
  206. state.connect.error = message
  207. }),
  208. )
  209. },
  210. clear() {
  211. setEphemeral("connect", {})
  212. },
  213. },
  214. }
  215. },
  216. })