| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937 |
- import { createStore, produce } from "solid-js/store"
- import { batch, createEffect, createMemo, 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 { usePlatform } from "./platform"
- import { Project } from "@opencode-ai/sdk/v2"
- import { Persist, persisted, removePersisted } from "@/utils/persist"
- import { decode64 } from "@/utils/base64"
- import { same } from "@/utils/same"
- import { createScrollPersistence, type SessionScroll } from "./layout-scroll"
- import { createPathHelpers } from "./file/path"
- const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
- const DEFAULT_PANEL_WIDTH = 344
- const DEFAULT_SESSION_WIDTH = 600
- const DEFAULT_TERMINAL_HEIGHT = 280
- 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[]
- pendingMessage?: string
- pendingMessageAt?: number
- }
- type TabHandoff = {
- dir: string
- id: string
- at: number
- }
- export type LocalProject = Partial<Project> & { worktree: string; expanded: boolean }
- export type ReviewDiffStyle = "unified" | "split"
- export function ensureSessionKey(key: string, touch: (key: string) => void, seed: (key: string) => void) {
- touch(key)
- seed(key)
- return key
- }
- export function createSessionKeyReader(sessionKey: string | Accessor<string>, ensure: (key: string) => void) {
- const key = typeof sessionKey === "function" ? sessionKey : () => sessionKey
- return () => {
- const value = key()
- ensure(value)
- return value
- }
- }
- export function pruneSessionKeys(input: {
- keep?: string
- max: number
- used: Map<string, number>
- view: string[]
- tabs: string[]
- }) {
- if (!input.keep) return []
- const keys = new Set<string>([...input.view, ...input.tabs])
- if (keys.size <= input.max) return []
- const score = (key: string) => {
- if (key === input.keep) return Number.MAX_SAFE_INTEGER
- return input.used.get(key) ?? 0
- }
- return Array.from(keys)
- .sort((a, b) => score(b) - score(a))
- .slice(input.max)
- }
- function nextSessionTabsForOpen(current: SessionTabs | undefined, tab: string): SessionTabs {
- const all = current?.all ?? []
- if (tab === "review") return { all: all.filter((x) => x !== "review"), active: tab }
- if (tab === "context") return { all: [tab, ...all.filter((x) => x !== tab)], active: tab }
- if (!all.includes(tab)) return { all: [...all, tab], active: tab }
- return { all, active: tab }
- }
- const sessionPath = (key: string) => {
- const dir = key.split("/")[0]
- if (!dir) return
- const root = decode64(dir)
- if (!root) return
- return createPathHelpers(() => root)
- }
- const normalizeSessionTab = (path: ReturnType<typeof createPathHelpers> | undefined, tab: string) => {
- if (!tab.startsWith("file://")) return tab
- if (!path) return tab
- return path.tab(tab)
- }
- const normalizeSessionTabList = (path: ReturnType<typeof createPathHelpers> | undefined, all: string[]) => {
- const seen = new Set<string>()
- return all.flatMap((tab) => {
- const value = normalizeSessionTab(path, tab)
- if (seen.has(value)) return []
- seen.add(value)
- return [value]
- })
- }
- const normalizeStoredSessionTabs = (key: string, tabs: SessionTabs) => {
- const path = sessionPath(key)
- return {
- all: normalizeSessionTabList(path, tabs.all),
- active: tabs.active ? normalizeSessionTab(path, tabs.active) : tabs.active,
- }
- }
- export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
- name: "Layout",
- init: () => {
- const globalSdk = useGlobalSDK()
- const globalSync = useGlobalSync()
- const server = useServer()
- const platform = usePlatform()
- 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
- const migratedSidebar = (() => {
- if (!isRecord(sidebar)) return sidebar
- if (typeof sidebar.workspaces !== "boolean") return sidebar
- return {
- ...sidebar,
- workspaces: {},
- workspacesDefault: sidebar.workspaces,
- }
- })()
- const review = value.review
- const fileTree = value.fileTree
- const migratedFileTree = (() => {
- if (!isRecord(fileTree)) return fileTree
- if (fileTree.tab === "changes" || fileTree.tab === "all") return fileTree
- const width = typeof fileTree.width === "number" ? fileTree.width : DEFAULT_PANEL_WIDTH
- return {
- ...fileTree,
- opened: true,
- width: width === 260 ? DEFAULT_PANEL_WIDTH : width,
- tab: "changes",
- }
- })()
- const migratedReview = (() => {
- if (!isRecord(review)) return review
- if (typeof review.panelOpened === "boolean") return review
- const opened = isRecord(fileTree) && typeof fileTree.opened === "boolean" ? fileTree.opened : true
- return {
- ...review,
- panelOpened: opened,
- }
- })()
- const sessionTabs = value.sessionTabs
- const migratedSessionTabs = (() => {
- if (!isRecord(sessionTabs)) return sessionTabs
- let changed = false
- const next = Object.fromEntries(
- Object.entries(sessionTabs).map(([key, tabs]) => {
- if (!isRecord(tabs) || !Array.isArray(tabs.all)) return [key, tabs]
- const current = {
- all: tabs.all.filter((tab): tab is string => typeof tab === "string"),
- active: typeof tabs.active === "string" ? tabs.active : undefined,
- }
- const normalized = normalizeStoredSessionTabs(key, current)
- if (current.all.length !== tabs.all.length) changed = true
- if (!same(current.all, normalized.all) || current.active !== normalized.active) changed = true
- if (tabs.active !== undefined && typeof tabs.active !== "string") changed = true
- return [key, normalized]
- }),
- )
- if (!changed) return sessionTabs
- return next
- })()
- if (
- migratedSidebar === sidebar &&
- migratedReview === review &&
- migratedFileTree === fileTree &&
- migratedSessionTabs === sessionTabs
- ) {
- return value
- }
- return {
- ...value,
- sidebar: migratedSidebar,
- review: migratedReview,
- fileTree: migratedFileTree,
- sessionTabs: migratedSessionTabs,
- }
- }
- const target = Persist.global("layout", ["layout.v6"])
- const [store, setStore, _, ready] = persisted(
- { ...target, migrate },
- createStore({
- sidebar: {
- opened: false,
- width: DEFAULT_PANEL_WIDTH,
- workspaces: {} as Record<string, boolean>,
- workspacesDefault: false,
- },
- terminal: {
- height: DEFAULT_TERMINAL_HEIGHT,
- opened: false,
- },
- review: {
- diffStyle: "split" as ReviewDiffStyle,
- panelOpened: true,
- },
- fileTree: {
- opened: true,
- width: DEFAULT_PANEL_WIDTH,
- tab: "changes" as "changes" | "all",
- },
- session: {
- width: DEFAULT_SESSION_WIDTH,
- },
- mobileSidebar: {
- opened: false,
- },
- sessionTabs: {} as Record<string, SessionTabs>,
- sessionView: {} as Record<string, SessionView>,
- handoff: {
- tabs: undefined as TabHandoff | undefined,
- },
- }),
- )
- const MAX_SESSION_KEYS = 50
- const PENDING_MESSAGE_TTL_MS = 2 * 60 * 1000
- const usage = {
- active: undefined as string | undefined,
- pruned: false,
- 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, platform)
- const legacyKey = `${dir}/${entry.legacy}${session ? "/" + session : ""}.${entry.version}`
- void removePersisted({ key: legacyKey }, platform)
- }
- }
- }
- function prune(keep?: string) {
- const drop = pruneSessionKeys({
- keep,
- max: MAX_SESSION_KEYS,
- used: usage.used,
- view: Object.keys(store.sessionView),
- tabs: Object.keys(store.sessionTabs),
- })
- 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) {
- usage.used.delete(key)
- }
- }
- function touch(sessionKey: string) {
- usage.active = sessionKey
- usage.used.set(sessionKey, Date.now())
- if (!ready()) return
- if (usage.pruned) return
- usage.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 = usage.active ?? sessionKey
- if (!current) {
- setStore("sessionView", sessionKey, { scroll: next })
- prune(keep)
- return
- }
- setStore("sessionView", sessionKey, "scroll", (prev) => ({ ...(prev ?? {}), ...next }))
- prune(keep)
- },
- })
- const ensureKey = (key: string) => ensureSessionKey(key, touch, (sessionKey) => scroll.seed(sessionKey))
- createEffect(() => {
- if (!ready()) return
- if (usage.pruned) return
- const active = usage.active
- if (!active) return
- usage.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>>({})
- const colorRequested = new Map<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, { bootstrap: false })
- 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<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
- })
- const rootFor = (directory: string) => {
- const map = roots()
- if (map.size === 0) return directory
- const visited = new Set<string>()
- 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<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 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,
- handoff: {
- tabs: createMemo(() => store.handoff?.tabs),
- setTabs(dir: string, id: string) {
- setStore("handoff", "tabs", { dir, id, at: Date.now() })
- },
- clearTabs() {
- if (!store.handoff?.tabs) return
- setStore("handoff", "tabs", undefined)
- },
- },
- 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, panelOpened: true })
- return
- }
- setStore("review", "diffStyle", diffStyle)
- },
- },
- fileTree: {
- opened: createMemo(() => store.fileTree?.opened ?? true),
- width: createMemo(() => store.fileTree?.width ?? DEFAULT_PANEL_WIDTH),
- tab: createMemo(() => store.fileTree?.tab ?? "changes"),
- setTab(tab: "changes" | "all") {
- if (!store.fileTree) {
- setStore("fileTree", { opened: true, width: DEFAULT_PANEL_WIDTH, tab })
- return
- }
- setStore("fileTree", "tab", tab)
- },
- open() {
- if (!store.fileTree) {
- setStore("fileTree", { opened: true, width: DEFAULT_PANEL_WIDTH, tab: "changes" })
- return
- }
- setStore("fileTree", "opened", true)
- },
- close() {
- if (!store.fileTree) {
- setStore("fileTree", { opened: false, width: DEFAULT_PANEL_WIDTH, tab: "changes" })
- return
- }
- setStore("fileTree", "opened", false)
- },
- toggle() {
- if (!store.fileTree) {
- setStore("fileTree", { opened: true, width: DEFAULT_PANEL_WIDTH, tab: "changes" })
- return
- }
- setStore("fileTree", "opened", (x) => !x)
- },
- resize(width: number) {
- if (!store.fileTree) {
- setStore("fileTree", { opened: true, width, tab: "changes" })
- return
- }
- setStore("fileTree", "width", width)
- },
- },
- session: {
- width: createMemo(() => store.session?.width ?? DEFAULT_SESSION_WIDTH),
- 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)
- },
- },
- pendingMessage: {
- set(sessionKey: string, messageID: string) {
- const at = Date.now()
- touch(sessionKey)
- const current = store.sessionView[sessionKey]
- if (!current) {
- setStore("sessionView", sessionKey, {
- scroll: {},
- pendingMessage: messageID,
- pendingMessageAt: at,
- })
- prune(usage.active ?? sessionKey)
- return
- }
- setStore(
- "sessionView",
- sessionKey,
- produce((draft) => {
- draft.pendingMessage = messageID
- draft.pendingMessageAt = at
- }),
- )
- },
- consume(sessionKey: string) {
- const current = store.sessionView[sessionKey]
- const message = current?.pendingMessage
- const at = current?.pendingMessageAt
- if (!message || !at) return
- setStore(
- "sessionView",
- sessionKey,
- produce((draft) => {
- delete draft.pendingMessage
- delete draft.pendingMessageAt
- }),
- )
- if (Date.now() - at > PENDING_MESSAGE_TTL_MS) return
- return message
- },
- },
- view(sessionKey: string | Accessor<string>) {
- const key = createSessionKeyReader(sessionKey, ensureKey)
- 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: DEFAULT_TERMINAL_HEIGHT, 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 next = Array.from(new Set(open))
- const current = store.sessionView[session]
- if (!current) {
- setStore("sessionView", session, {
- scroll: {},
- reviewOpen: next,
- })
- return
- }
- if (same(current.reviewOpen, next)) return
- setStore("sessionView", session, "reviewOpen", next)
- },
- openPath(path: string) {
- const session = key()
- const current = store.sessionView[session]
- if (!current) {
- setStore("sessionView", session, {
- scroll: {},
- reviewOpen: [path],
- })
- return
- }
- if (!current.reviewOpen) {
- setStore("sessionView", session, "reviewOpen", [path])
- return
- }
- if (current.reviewOpen.includes(path)) return
- setStore("sessionView", session, "reviewOpen", current.reviewOpen.length, path)
- },
- closePath(path: string) {
- const session = key()
- const current = store.sessionView[session]?.reviewOpen
- if (!current) return
- const index = current.indexOf(path)
- if (index === -1) return
- setStore(
- "sessionView",
- session,
- "reviewOpen",
- produce((draft) => {
- if (!draft) return
- draft.splice(index, 1)
- }),
- )
- },
- togglePath(path: string) {
- const session = key()
- const current = store.sessionView[session]?.reviewOpen
- if (!current || !current.includes(path)) {
- this.openPath(path)
- return
- }
- this.closePath(path)
- },
- },
- }
- },
- tabs(sessionKey: string | Accessor<string>) {
- const key = createSessionKeyReader(sessionKey, ensureKey)
- const path = createMemo(() => sessionPath(key()))
- const tabs = createMemo(() => store.sessionTabs[key()] ?? { all: [] })
- const normalize = (tab: string) => normalizeSessionTab(path(), tab)
- const normalizeAll = (all: string[]) => normalizeSessionTabList(path(), all)
- return {
- tabs,
- active: createMemo(() => tabs().active),
- all: createMemo(() => tabs().all.filter((tab) => tab !== "review")),
- setActive(tab: string | undefined) {
- const session = key()
- const next = tab ? normalize(tab) : tab
- if (!store.sessionTabs[session]) {
- setStore("sessionTabs", session, { all: [], active: next })
- } else {
- setStore("sessionTabs", session, "active", next)
- }
- },
- setAll(all: string[]) {
- const session = key()
- const next = normalizeAll(all).filter((tab) => tab !== "review")
- if (!store.sessionTabs[session]) {
- setStore("sessionTabs", session, { all: next, active: undefined })
- } else {
- setStore("sessionTabs", session, "all", next)
- }
- },
- async open(tab: string) {
- const session = key()
- const next = nextSessionTabsForOpen(store.sessionTabs[session], normalize(tab))
- setStore("sessionTabs", session, next)
- },
- close(tab: string) {
- const session = key()
- const current = store.sessionTabs[session]
- if (!current) return
- if (tab === "review") {
- if (current.active !== tab) return
- setStore("sessionTabs", session, "active", current.all[0])
- return
- }
- const all = current.all.filter((x) => x !== tab)
- if (current.active !== tab) {
- setStore("sessionTabs", session, "all", all)
- return
- }
- const index = current.all.findIndex((f) => f === tab)
- const next = current.all[index - 1] ?? current.all[index + 1] ?? all[0]
- batch(() => {
- setStore("sessionTabs", session, "all", all)
- 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])
- }),
- )
- },
- }
- },
- }
- },
- })
|