| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599 |
- import { createStore, produce } from "solid-js/store"
- import { batch, createEffect, createMemo, onCleanup, onMount } 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<string, SessionScroll>
- reviewOpen?: string[]
- }
- export type LocalProject = Partial<Project> & { 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<string, unknown> =>
- 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<string, boolean>,
- workspacesDefault: false,
- },
- terminal: {
- height: 280,
- opened: false,
- },
- review: {
- diffStyle: "split" as ReviewDiffStyle,
- panelOpened: true,
- },
- session: {
- width: 600,
- },
- mobileSidebar: {
- opened: false,
- },
- sessionTabs: {} as Record<string, SessionTabs>,
- sessionView: {} as Record<string, SessionView>,
- }),
- )
- const MAX_SESSION_KEYS = 50
- const meta = { active: undefined as string | undefined, pruned: false }
- const used = new Map<string, number>()
- 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<string>()
- 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<Record<string, AvatarColorKey>>({})
- function pickAvailableColor(used: Set<string>): 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,
- 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<string, string>()
- for (const project of globalSync.data.project) {
- const sandboxes = project.sandboxes ?? []
- for (const sandbox of sandboxes) {
- map.set(sandbox, project.worktree)
- }
- }
- return map
- })
- createEffect(() => {
- const map = roots()
- if (map.size === 0) return
- const projects = server.projects.list()
- const seen = new Set(projects.map((project) => project.worktree))
- batch(() => {
- for (const project of projects) {
- const root = map.get(project.worktree)
- if (!root) 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
- const used = new Set<string>()
- 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 existing = colors[project.worktree]
- const color = existing ?? pickAvailableColor(used)
- if (!existing) {
- used.add(color)
- setColors(project.worktree, color)
- }
- if (!project.id) continue
- if (project.id === "global") {
- globalSync.project.meta(project.worktree, { icon: { color } })
- continue
- }
- void globalSdk.client.project.update({ projectID: project.id, directory: project.worktree, icon: { color } })
- }
- })
- onMount(() => {
- Promise.all(
- server.projects.list().map((project) => {
- return globalSync.project.loadSessions(project.worktree)
- }),
- )
- })
- return {
- ready,
- projects: {
- list,
- open(directory: string) {
- const root = roots().get(directory) ?? 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 createMemo(() => 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) {
- touch(sessionKey)
- scroll.seed(sessionKey)
- const s = createMemo(() => store.sessionView[sessionKey] ?? { 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(sessionKey, tab)
- },
- setScroll(tab: string, pos: SessionScroll) {
- scroll.setScroll(sessionKey, 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 current = store.sessionView[sessionKey]
- if (!current) {
- setStore("sessionView", sessionKey, {
- scroll: {},
- reviewOpen: open,
- })
- return
- }
- if (same(current.reviewOpen, open)) return
- setStore("sessionView", sessionKey, "reviewOpen", open)
- },
- },
- }
- },
- tabs(sessionKey: string) {
- touch(sessionKey)
- const tabs = createMemo(() => store.sessionTabs[sessionKey] ?? { all: [] })
- return {
- tabs,
- active: createMemo(() => tabs().active),
- all: createMemo(() => tabs().all),
- setActive(tab: string | undefined) {
- if (!store.sessionTabs[sessionKey]) {
- setStore("sessionTabs", sessionKey, { all: [], active: tab })
- } else {
- setStore("sessionTabs", sessionKey, "active", tab)
- }
- },
- setAll(all: string[]) {
- if (!store.sessionTabs[sessionKey]) {
- setStore("sessionTabs", sessionKey, { all, active: undefined })
- } else {
- setStore("sessionTabs", sessionKey, "all", all)
- }
- },
- async open(tab: string) {
- const current = store.sessionTabs[sessionKey] ?? { all: [] }
- if (tab === "review") {
- if (!store.sessionTabs[sessionKey]) {
- setStore("sessionTabs", sessionKey, { all: [], active: tab })
- return
- }
- setStore("sessionTabs", sessionKey, "active", tab)
- return
- }
- if (tab === "context") {
- const all = [tab, ...current.all.filter((x) => x !== tab)]
- if (!store.sessionTabs[sessionKey]) {
- setStore("sessionTabs", sessionKey, { all, active: tab })
- return
- }
- setStore("sessionTabs", sessionKey, "all", all)
- setStore("sessionTabs", sessionKey, "active", tab)
- return
- }
- if (!current.all.includes(tab)) {
- if (!store.sessionTabs[sessionKey]) {
- setStore("sessionTabs", sessionKey, { all: [tab], active: tab })
- return
- }
- setStore("sessionTabs", sessionKey, "all", [...current.all, tab])
- setStore("sessionTabs", sessionKey, "active", tab)
- return
- }
- if (!store.sessionTabs[sessionKey]) {
- setStore("sessionTabs", sessionKey, { all: current.all, active: tab })
- return
- }
- setStore("sessionTabs", sessionKey, "active", tab)
- },
- close(tab: string) {
- const current = store.sessionTabs[sessionKey]
- if (!current) return
- const all = current.all.filter((x) => x !== tab)
- batch(() => {
- setStore("sessionTabs", sessionKey, "all", all)
- if (current.active !== tab) return
- const index = current.all.findIndex((f) => f === tab)
- const next = all[index - 1] ?? all[0]
- setStore("sessionTabs", sessionKey, "active", next)
- })
- },
- move(tab: string, to: number) {
- const current = store.sessionTabs[sessionKey]
- if (!current) return
- const index = current.all.findIndex((f) => f === tab)
- if (index === -1) return
- setStore(
- "sessionTabs",
- sessionKey,
- "all",
- produce((opened) => {
- opened.splice(to, 0, opened.splice(index, 1)[0])
- }),
- )
- },
- }
- },
- }
- },
- })
|