838 lines
26 KiB
TypeScript
838 lines
26 KiB
TypeScript
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 { 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[]
|
|
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)
|
|
}
|
|
|
|
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
|
|
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 : 344
|
|
return {
|
|
...fileTree,
|
|
opened: true,
|
|
width: width === 260 ? 344 : 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,
|
|
}
|
|
})()
|
|
|
|
if (migratedSidebar === sidebar && migratedReview === review && migratedFileTree === fileTree) return value
|
|
return {
|
|
...value,
|
|
sidebar: migratedSidebar,
|
|
review: migratedReview,
|
|
fileTree: migratedFileTree,
|
|
}
|
|
}
|
|
|
|
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,
|
|
},
|
|
fileTree: {
|
|
opened: true,
|
|
width: 344,
|
|
tab: "changes" as "changes" | "all",
|
|
},
|
|
session: {
|
|
width: 600,
|
|
},
|
|
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 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) {
|
|
const drop = pruneSessionKeys({
|
|
keep,
|
|
max: MAX_SESSION_KEYS,
|
|
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) {
|
|
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)
|
|
},
|
|
})
|
|
|
|
const ensureKey = (key: string) => ensureSessionKey(key, touch, (sessionKey) => scroll.seed(sessionKey))
|
|
|
|
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>>({})
|
|
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 ?? 344),
|
|
tab: createMemo(() => store.fileTree?.tab ?? "changes"),
|
|
setTab(tab: "changes" | "all") {
|
|
if (!store.fileTree) {
|
|
setStore("fileTree", { opened: true, width: 344, tab })
|
|
return
|
|
}
|
|
setStore("fileTree", "tab", tab)
|
|
},
|
|
open() {
|
|
if (!store.fileTree) {
|
|
setStore("fileTree", { opened: true, width: 344, tab: "changes" })
|
|
return
|
|
}
|
|
setStore("fileTree", "opened", true)
|
|
},
|
|
close() {
|
|
if (!store.fileTree) {
|
|
setStore("fileTree", { opened: false, width: 344, tab: "changes" })
|
|
return
|
|
}
|
|
setStore("fileTree", "opened", false)
|
|
},
|
|
toggle() {
|
|
if (!store.fileTree) {
|
|
setStore("fileTree", { opened: true, width: 344, 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 ?? 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)
|
|
},
|
|
},
|
|
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(meta.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: 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<string>) {
|
|
const key = createSessionKeyReader(sessionKey, ensureKey)
|
|
const tabs = createMemo(() => store.sessionTabs[key()] ?? { all: [] })
|
|
return {
|
|
tabs,
|
|
active: createMemo(() => tabs().active),
|
|
all: createMemo(() => tabs().all.filter((tab) => tab !== "review")),
|
|
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()
|
|
const next = 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 current = store.sessionTabs[session] ?? { all: [] }
|
|
|
|
if (tab === "review") {
|
|
if (!store.sessionTabs[session]) {
|
|
setStore("sessionTabs", session, { all: current.all.filter((x) => x !== "review"), 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
|
|
|
|
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])
|
|
}),
|
|
)
|
|
},
|
|
}
|
|
},
|
|
}
|
|
},
|
|
})
|