import { createStore, produce } from "solid-js/store" import { batch, createEffect, createMemo, on, onCleanup, onMount, type Accessor } from "solid-js" import { createSimpleContext } from "@opencode-ai/ui/context" import { useGlobalSync } from "./global-sync" import { useGlobalSDK } from "./global-sdk" import { useServer } from "./server" import { Project } from "@opencode-ai/sdk/v2" import { Persist, persisted, removePersisted } from "@/utils/persist" import { same } from "@/utils/same" import { createScrollPersistence, type SessionScroll } from "./layout-scroll" const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const export type AvatarColorKey = (typeof AVATAR_COLOR_KEYS)[number] export function getAvatarColors(key?: string) { if (key && AVATAR_COLOR_KEYS.includes(key as AvatarColorKey)) { return { background: `var(--avatar-background-${key})`, foreground: `var(--avatar-text-${key})`, } } return { background: "var(--surface-info-base)", foreground: "var(--text-base)", } } type SessionTabs = { active?: string all: string[] } type SessionView = { scroll: Record reviewOpen?: string[] } export type LocalProject = Partial & { worktree: string; expanded: boolean } export type ReviewDiffStyle = "unified" | "split" export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({ name: "Layout", init: () => { const globalSdk = useGlobalSDK() const globalSync = useGlobalSync() const server = useServer() const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value) const migrate = (value: unknown) => { if (!isRecord(value)) return value const sidebar = value.sidebar if (!isRecord(sidebar)) return value if (typeof sidebar.workspaces !== "boolean") return value return { ...value, sidebar: { ...sidebar, workspaces: {}, workspacesDefault: sidebar.workspaces, }, } } const target = Persist.global("layout", ["layout.v6"]) const [store, setStore, _, ready] = persisted( { ...target, migrate }, createStore({ sidebar: { opened: false, width: 344, workspaces: {} as Record, workspacesDefault: false, }, terminal: { height: 280, opened: false, }, review: { diffStyle: "split" as ReviewDiffStyle, panelOpened: true, }, session: { width: 600, }, mobileSidebar: { opened: false, }, sessionTabs: {} as Record, sessionView: {} as Record, }), ) const MAX_SESSION_KEYS = 50 const meta = { active: undefined as string | undefined, pruned: false } const used = new Map() const SESSION_STATE_KEYS = [ { key: "prompt", legacy: "prompt", version: "v2" }, { key: "terminal", legacy: "terminal", version: "v1" }, { key: "file-view", legacy: "file", version: "v1" }, ] as const const dropSessionState = (keys: string[]) => { for (const key of keys) { const parts = key.split("/") const dir = parts[0] const session = parts[1] if (!dir) continue for (const entry of SESSION_STATE_KEYS) { const target = session ? Persist.session(dir, session, entry.key) : Persist.workspace(dir, entry.key) void removePersisted(target) const legacyKey = `${dir}/${entry.legacy}${session ? "/" + session : ""}.${entry.version}` void removePersisted({ key: legacyKey }) } } } function prune(keep?: string) { if (!keep) return const keys = new Set() for (const key of Object.keys(store.sessionView)) keys.add(key) for (const key of Object.keys(store.sessionTabs)) keys.add(key) if (keys.size <= MAX_SESSION_KEYS) return const score = (key: string) => { if (key === keep) return Number.MAX_SAFE_INTEGER return used.get(key) ?? 0 } const ordered = Array.from(keys).sort((a, b) => score(b) - score(a)) const drop = ordered.slice(MAX_SESSION_KEYS) if (drop.length === 0) return setStore( produce((draft) => { for (const key of drop) { delete draft.sessionView[key] delete draft.sessionTabs[key] } }), ) scroll.drop(drop) dropSessionState(drop) for (const key of drop) { used.delete(key) } } function touch(sessionKey: string) { meta.active = sessionKey used.set(sessionKey, Date.now()) if (!ready()) return if (meta.pruned) return meta.pruned = true prune(sessionKey) } const scroll = createScrollPersistence({ debounceMs: 250, getSnapshot: (sessionKey) => store.sessionView[sessionKey]?.scroll, onFlush: (sessionKey, next) => { const current = store.sessionView[sessionKey] const keep = meta.active ?? sessionKey if (!current) { setStore("sessionView", sessionKey, { scroll: next }) prune(keep) return } setStore("sessionView", sessionKey, "scroll", (prev) => ({ ...(prev ?? {}), ...next })) prune(keep) }, }) createEffect(() => { if (!ready()) return if (meta.pruned) return const active = meta.active if (!active) return meta.pruned = true prune(active) }) onMount(() => { const flush = () => batch(() => scroll.flushAll()) const handleVisibility = () => { if (document.visibilityState !== "hidden") return flush() } window.addEventListener("pagehide", flush) document.addEventListener("visibilitychange", handleVisibility) onCleanup(() => { window.removeEventListener("pagehide", flush) document.removeEventListener("visibilitychange", handleVisibility) scroll.dispose() }) }) const [colors, setColors] = createStore>({}) const colorRequested = new Map() function pickAvailableColor(used: Set): AvatarColorKey { const available = AVATAR_COLOR_KEYS.filter((c) => !used.has(c)) if (available.length === 0) return AVATAR_COLOR_KEYS[Math.floor(Math.random() * AVATAR_COLOR_KEYS.length)] return available[Math.floor(Math.random() * available.length)] } function enrich(project: { worktree: string; expanded: boolean }) { const [childStore] = globalSync.child(project.worktree) const projectID = childStore.project const metadata = projectID ? globalSync.data.project.find((x) => x.id === projectID) : globalSync.data.project.find((x) => x.worktree === project.worktree) const local = childStore.projectMeta const localOverride = local?.name !== undefined || local?.commands?.start !== undefined || local?.icon?.override !== undefined || local?.icon?.color !== undefined const base = { ...(metadata ?? {}), ...project, icon: { url: metadata?.icon?.url, override: metadata?.icon?.override ?? childStore.icon, color: metadata?.icon?.color, }, } const isGlobal = projectID === "global" || (metadata?.id === undefined && localOverride) if (!isGlobal) return base return { ...base, id: base.id ?? "global", name: local?.name, commands: local?.commands, icon: { url: base.icon?.url, override: local?.icon?.override, color: local?.icon?.color, }, } } const roots = createMemo(() => { const map = new Map() for (const project of globalSync.data.project) { const sandboxes = project.sandboxes ?? [] for (const sandbox of sandboxes) { map.set(sandbox, project.worktree) } } return map }) const rootFor = (directory: string) => { const map = roots() if (map.size === 0) return directory const visited = new Set() const chain = [directory] while (chain.length) { const current = chain[chain.length - 1] if (!current) return directory const next = map.get(current) if (!next) return current if (visited.has(next)) return directory visited.add(next) chain.push(next) } return directory } createEffect(() => { const projects = server.projects.list() const seen = new Set(projects.map((project) => project.worktree)) batch(() => { for (const project of projects) { const root = rootFor(project.worktree) if (root === project.worktree) continue server.projects.close(project.worktree) if (!seen.has(root)) { server.projects.open(root) seen.add(root) } if (project.expanded) server.projects.expand(root) } }) }) const enriched = createMemo(() => server.projects.list().map(enrich)) const list = createMemo(() => { const projects = enriched() return projects.map((project) => { const color = project.icon?.color ?? colors[project.worktree] if (!color) return project const icon = project.icon ? { ...project.icon, color } : { color } return { ...project, icon } }) }) createEffect(() => { const projects = enriched() if (projects.length === 0) return if (!globalSync.ready) return for (const project of projects) { if (!project.id) continue if (project.id === "global") continue globalSync.project.icon(project.worktree, project.icon?.override) } }) createEffect(() => { const projects = enriched() if (projects.length === 0) return for (const project of projects) { if (project.icon?.color) colorRequested.delete(project.worktree) } const used = new Set() for (const project of projects) { const color = project.icon?.color ?? colors[project.worktree] if (color) used.add(color) } for (const project of projects) { if (project.icon?.color) continue const worktree = project.worktree const existing = colors[worktree] const color = existing ?? pickAvailableColor(used) if (!existing) { used.add(color) setColors(worktree, color) } if (!project.id) continue const requested = colorRequested.get(worktree) if (requested === color) continue colorRequested.set(worktree, color) if (project.id === "global") { globalSync.project.meta(worktree, { icon: { color } }) continue } void globalSdk.client.project .update({ projectID: project.id, directory: worktree, icon: { color } }) .catch(() => { if (colorRequested.get(worktree) === color) colorRequested.delete(worktree) }) } }) onMount(() => { Promise.all( server.projects.list().map((project) => { return globalSync.project.loadSessions(project.worktree) }), ) }) return { ready, projects: { list, open(directory: string) { const root = rootFor(directory) if (server.projects.list().find((x) => x.worktree === root)) return globalSync.project.loadSessions(root) server.projects.open(root) }, close(directory: string) { server.projects.close(directory) }, expand(directory: string) { server.projects.expand(directory) }, collapse(directory: string) { server.projects.collapse(directory) }, move(directory: string, toIndex: number) { server.projects.move(directory, toIndex) }, }, sidebar: { opened: createMemo(() => store.sidebar.opened), open() { setStore("sidebar", "opened", true) }, close() { setStore("sidebar", "opened", false) }, toggle() { setStore("sidebar", "opened", (x) => !x) }, width: createMemo(() => store.sidebar.width), resize(width: number) { setStore("sidebar", "width", width) }, workspaces(directory: string) { return () => store.sidebar.workspaces[directory] ?? store.sidebar.workspacesDefault ?? false }, setWorkspaces(directory: string, value: boolean) { setStore("sidebar", "workspaces", directory, value) }, toggleWorkspaces(directory: string) { const current = store.sidebar.workspaces[directory] ?? store.sidebar.workspacesDefault ?? false setStore("sidebar", "workspaces", directory, !current) }, }, terminal: { height: createMemo(() => store.terminal.height), resize(height: number) { setStore("terminal", "height", height) }, }, review: { diffStyle: createMemo(() => store.review?.diffStyle ?? "split"), setDiffStyle(diffStyle: ReviewDiffStyle) { if (!store.review) { setStore("review", { diffStyle }) return } setStore("review", "diffStyle", diffStyle) }, }, session: { width: createMemo(() => store.session?.width ?? 600), resize(width: number) { if (!store.session) { setStore("session", { width }) return } setStore("session", "width", width) }, }, mobileSidebar: { opened: createMemo(() => store.mobileSidebar?.opened ?? false), show() { setStore("mobileSidebar", "opened", true) }, hide() { setStore("mobileSidebar", "opened", false) }, toggle() { setStore("mobileSidebar", "opened", (x) => !x) }, }, view(sessionKey: string | Accessor) { const key = typeof sessionKey === "function" ? sessionKey : () => sessionKey touch(key()) scroll.seed(key()) createEffect( on( key, (value) => { touch(value) scroll.seed(value) }, { defer: true }, ), ) const s = createMemo(() => store.sessionView[key()] ?? { scroll: {} }) const terminalOpened = createMemo(() => store.terminal?.opened ?? false) const reviewPanelOpened = createMemo(() => store.review?.panelOpened ?? true) function setTerminalOpened(next: boolean) { const current = store.terminal if (!current) { setStore("terminal", { height: 280, opened: next }) return } const value = current.opened ?? false if (value === next) return setStore("terminal", "opened", next) } function setReviewPanelOpened(next: boolean) { const current = store.review if (!current) { setStore("review", { diffStyle: "split" as ReviewDiffStyle, panelOpened: next }) return } const value = current.panelOpened ?? true if (value === next) return setStore("review", "panelOpened", next) } return { scroll(tab: string) { return scroll.scroll(key(), tab) }, setScroll(tab: string, pos: SessionScroll) { scroll.setScroll(key(), tab, pos) }, terminal: { opened: terminalOpened, open() { setTerminalOpened(true) }, close() { setTerminalOpened(false) }, toggle() { setTerminalOpened(!terminalOpened()) }, }, reviewPanel: { opened: reviewPanelOpened, open() { setReviewPanelOpened(true) }, close() { setReviewPanelOpened(false) }, toggle() { setReviewPanelOpened(!reviewPanelOpened()) }, }, review: { open: createMemo(() => s().reviewOpen), setOpen(open: string[]) { const session = key() const current = store.sessionView[session] if (!current) { setStore("sessionView", session, { scroll: {}, reviewOpen: open, }) return } if (same(current.reviewOpen, open)) return setStore("sessionView", session, "reviewOpen", open) }, }, } }, tabs(sessionKey: string | Accessor) { const key = typeof sessionKey === "function" ? sessionKey : () => sessionKey touch(key()) createEffect( on( key, (value) => { touch(value) }, { defer: true }, ), ) const tabs = createMemo(() => store.sessionTabs[key()] ?? { all: [] }) return { tabs, active: createMemo(() => tabs().active), all: createMemo(() => tabs().all), setActive(tab: string | undefined) { const session = key() if (!store.sessionTabs[session]) { setStore("sessionTabs", session, { all: [], active: tab }) } else { setStore("sessionTabs", session, "active", tab) } }, setAll(all: string[]) { const session = key() if (!store.sessionTabs[session]) { setStore("sessionTabs", session, { all, active: undefined }) } else { setStore("sessionTabs", session, "all", all) } }, async open(tab: string) { const session = key() const current = store.sessionTabs[session] ?? { all: [] } if (tab === "review") { if (!store.sessionTabs[session]) { setStore("sessionTabs", session, { all: [], active: tab }) return } setStore("sessionTabs", session, "active", tab) return } if (tab === "context") { const all = [tab, ...current.all.filter((x) => x !== tab)] if (!store.sessionTabs[session]) { setStore("sessionTabs", session, { all, active: tab }) return } setStore("sessionTabs", session, "all", all) setStore("sessionTabs", session, "active", tab) return } if (!current.all.includes(tab)) { if (!store.sessionTabs[session]) { setStore("sessionTabs", session, { all: [tab], active: tab }) return } setStore("sessionTabs", session, "all", [...current.all, tab]) setStore("sessionTabs", session, "active", tab) return } if (!store.sessionTabs[session]) { setStore("sessionTabs", session, { all: current.all, active: tab }) return } setStore("sessionTabs", session, "active", tab) }, close(tab: string) { const session = key() const current = store.sessionTabs[session] if (!current) return const all = current.all.filter((x) => x !== tab) batch(() => { setStore("sessionTabs", session, "all", all) if (current.active !== tab) return const index = current.all.findIndex((f) => f === tab) const next = all[index - 1] ?? all[0] setStore("sessionTabs", session, "active", next) }) }, move(tab: string, to: number) { const session = key() const current = store.sessionTabs[session] if (!current) return const index = current.all.findIndex((f) => f === tab) if (index === -1) return setStore( "sessionTabs", session, "all", produce((opened) => { opened.splice(to, 0, opened.splice(index, 1)[0]) }), ) }, } }, } }, })