| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460 |
- 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 { persisted } 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 [store, setStore, _, ready] = persisted(
- "layout.v6",
- createStore({
- sidebar: {
- opened: false,
- width: 280,
- },
- terminal: {
- opened: false,
- height: 280,
- },
- review: {
- opened: true,
- diffStyle: "split" as ReviewDiffStyle,
- },
- 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>()
- 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)
- 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 usedColors = new Set<AvatarColorKey>()
- function pickAvailableColor(): AvatarColorKey {
- const available = AVATAR_COLOR_KEYS.filter((c) => !usedColors.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)
- return [
- {
- ...(metadata ?? {}),
- ...project,
- icon: { url: metadata?.icon?.url, color: metadata?.icon?.color },
- },
- ]
- }
- function colorize(project: LocalProject) {
- if (project.icon?.color) return project
- const color = pickAvailableColor()
- usedColors.add(color)
- project.icon = { ...project.icon, color }
- if (project.id) {
- globalSdk.client.project.update({ projectID: project.id, icon: { color } })
- }
- return project
- }
- 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().flatMap(enrich))
- const list = createMemo(() => enriched().flatMap(colorize))
- 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)
- },
- },
- terminal: {
- opened: createMemo(() => store.terminal.opened),
- open() {
- setStore("terminal", "opened", true)
- },
- close() {
- setStore("terminal", "opened", false)
- },
- toggle() {
- setStore("terminal", "opened", (x) => !x)
- },
- height: createMemo(() => store.terminal.height),
- resize(height: number) {
- setStore("terminal", "height", height)
- },
- },
- review: {
- opened: createMemo(() => store.review?.opened ?? true),
- diffStyle: createMemo(() => store.review?.diffStyle ?? "split"),
- setDiffStyle(diffStyle: ReviewDiffStyle) {
- if (!store.review) {
- setStore("review", { opened: true, diffStyle })
- return
- }
- setStore("review", "diffStyle", diffStyle)
- },
- open() {
- setStore("review", "opened", true)
- },
- close() {
- setStore("review", "opened", false)
- },
- toggle() {
- setStore("review", "opened", (x) => !x)
- },
- },
- 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: {} })
- return {
- scroll(tab: string) {
- return scroll.scroll(sessionKey, tab)
- },
- setScroll(tab: string, pos: SessionScroll) {
- scroll.setScroll(sessionKey, tab, pos)
- },
- 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])
- }),
- )
- },
- }
- },
- }
- },
- })
|