Download packages/app/src/context/layout.tsx from SaylorTwift/opencode: direct link, hf CLI and curl.
- Browser
- Download file 36.6 kB
-
https://huggingface.co/SaylorTwift/opencode/resolve/main/packages/app/src/context/layout.tsx
- Command line
-
hf download hf://SaylorTwift/opencode/packages/app/src/context/layout.tsx
-
curl -L -o layout.tsx https://huggingface.co/SaylorTwift/opencode/resolve/main/packages/app/src/context/layout.tsx
36.6 kB
| import { createStore, produce, reconcile } from "solid-js/store" | |
| import { batch, createEffect, createMemo, onCleanup, onMount, type Accessor } from "solid-js" | |
| import { useLocation } from "@solidjs/router" | |
| import { createSimpleContext } from "@opencode-ai/ui/context" | |
| import { makeEventListener } from "@solid-primitives/event-listener" | |
| import { useServerSync } from "./server-sync" | |
| import { useServerSDK } from "./server-sdk" | |
| import { RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServer } from "./server" | |
| import { usePlatform } from "./platform" | |
| import { Project } from "@opencode-ai/sdk/v2" | |
| import { normalizeProjectInfo } from "./global-sync/utils" | |
| import { Persist, persisted, removePersisted } from "@/utils/persist" | |
| import { pathKey } from "@/utils/path-key" | |
| import { decode64 } from "@/utils/base64" | |
| import { same } from "@/utils/same" | |
| import { createScrollPersistence, type SessionScroll } from "./layout-scroll" | |
| import { createPathHelpers } from "./file/path" | |
| import type { ProjectAvatarVariant } from "@opencode-ai/ui/v2/project-avatar-v2" | |
| import { migrateLegacySessionStateKeys, ServerScope, SessionStateKey } from "@/utils/server-scope" | |
| import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./layout-helpers" | |
| import { requireServerKey } from "@/utils/session-route" | |
| import { type DraftTab, useTabs } from "./tabs" | |
| import { closeSessionTab, openSessionTab, previewSessionTab, type SessionTabs } from "./layout-tabs" | |
| export { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } | |
| export type { ProjectAvatarVariant } | |
| const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const | |
| const DEFAULT_SIDEBAR_WIDTH = 344 | |
| const DEFAULT_FILE_TREE_WIDTH = 200 | |
| const DEFAULT_SESSION_WIDTH = 600 | |
| const DEFAULT_TERMINAL_HEIGHT = 280 | |
| const DEFAULT_REVIEW_PANEL_OPENED = false | |
| 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)", | |
| } | |
| } | |
| export function getProjectAvatarVariant(key?: string): ProjectAvatarVariant { | |
| if (key === "mint") return "cyan" | |
| if (key === "lime") return "green" | |
| if ( | |
| key === "orange" || | |
| key === "yellow" || | |
| key === "cyan" || | |
| key === "green" || | |
| key === "red" || | |
| key === "pink" || | |
| key === "blue" || | |
| key === "purple" || | |
| key === "gray" | |
| ) | |
| return key | |
| return "gray" | |
| } | |
| type SessionView = { | |
| scroll: Record<string, SessionScroll> | |
| reviewOpen?: string[] | |
| reviewMode?: ReviewChangeMode | |
| reviewFile?: string | |
| pendingMessage?: string | |
| pendingMessageAt?: number | |
| todoCollapsed?: boolean | |
| } | |
| type TabHandoff = { | |
| scope: ServerScope | |
| dir: string | |
| id: string | |
| at: number | |
| } | |
| export type LocalProject = Partial<Project> & { worktree: string; expanded: boolean } | |
| export type HomeProjectSelection = { server: ServerConnection.Key; directory?: string } | |
| export type ReviewDiffStyle = "unified" | "split" | |
| export type ReviewChangeMode = "git" | "branch" | "turn" | |
| export type ReviewPanelSource = "context-button" | "other" | |
| export type LayoutRoute = | |
| | { type: "home" } | |
| | { type: "draft"; draftID: string; server?: ServerConnection.Key } | |
| | { type: "dir-new-sesssion"; dir: string; dirBase64: string; server?: ServerConnection.Key } | |
| | { type: "session"; sessionId: string; server?: ServerConnection.Key } | |
| const sessionPath = (key: string) => { | |
| const dir = SessionStateKey.route(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 currentRoute = (pathname: string, search: string): LayoutRoute => { | |
| const parts = pathname.split("/").filter(Boolean) | |
| if (parts.length === 0) return { type: "home" } | |
| if (parts[0] === "new-session") { | |
| const draftID = new URLSearchParams(search).get("draftId") | |
| if (!draftID) return { type: "home" } | |
| return { type: "draft", draftID } | |
| } | |
| if (parts[0] === "server" && parts[2] === "session" && parts[3]) { | |
| return { | |
| type: "session", | |
| sessionId: parts[3], | |
| server: requireServerKey(parts[1]), | |
| } | |
| } | |
| const dirBase64 = parts[0] | |
| const dir = decode64(dirBase64) | |
| if (!dir) return { type: "home" } | |
| if (parts[1] !== "session") return { type: "home" } | |
| const id = parts[2] | |
| if (id) return { type: "session", sessionId: id } | |
| return { type: "dir-new-sesssion", dir, dirBase64 } | |
| } | |
| export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({ | |
| name: "Layout", | |
| gate: false, | |
| init: () => { | |
| const serverSdk = useServerSDK() | |
| const serverSync = useServerSync() | |
| const server = useServer() | |
| const tabs = useTabs() | |
| const platform = usePlatform() | |
| const location = useLocation() | |
| const route = createMemo(() => { | |
| const value = currentRoute(location.pathname, location.search) | |
| if (value.type === "home") return value | |
| if (value.server) return value | |
| if (value.type === "draft") { | |
| const draft = tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === value.draftID) | |
| if (draft) return { ...value, server: draft.server } | |
| } | |
| return { ...value, server: server.key } | |
| }) | |
| 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_FILE_TREE_WIDTH | |
| return { | |
| ...fileTree, | |
| opened: true, | |
| width: width === 260 ? DEFAULT_FILE_TREE_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 : DEFAULT_REVIEW_PANEL_OPENED | |
| return { | |
| ...review, | |
| panelOpened: opened, | |
| } | |
| })() | |
| const sessionTabs = migrateLegacySessionStateKeys(value.sessionTabs) | |
| const sessionView = migrateLegacySessionStateKeys(value.sessionView) | |
| 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 === value.sessionTabs && | |
| sessionView === value.sessionView | |
| ) { | |
| return value | |
| } | |
| return { | |
| ...value, | |
| sidebar: migratedSidebar, | |
| review: migratedReview, | |
| fileTree: migratedFileTree, | |
| sessionTabs: migratedSessionTabs, | |
| sessionView, | |
| } | |
| } | |
| const target = Persist.serverGlobal(serverSdk().scope, "layout", ["layout.v6"]) | |
| const [store, setStore, _, ready] = persisted( | |
| { ...target, migrate }, | |
| createStore({ | |
| sidebar: { | |
| opened: false, | |
| width: DEFAULT_SIDEBAR_WIDTH, | |
| workspaces: {} as Record<string, boolean>, | |
| workspacesDefault: false, | |
| }, | |
| terminal: { | |
| height: DEFAULT_TERMINAL_HEIGHT, | |
| opened: false, | |
| }, | |
| review: { | |
| diffStyle: "split" as ReviewDiffStyle, | |
| panelOpened: DEFAULT_REVIEW_PANEL_OPENED, | |
| }, | |
| fileTree: { | |
| opened: false, | |
| width: DEFAULT_FILE_TREE_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, | |
| }, | |
| home: { | |
| selection: { server: server.key } as HomeProjectSelection, | |
| }, | |
| }), | |
| ) | |
| const [ephemeral, setEphemeral] = createStore({ | |
| reviewPanelSource: "other" as ReviewPanelSource, | |
| sessionTabPreview: {} as Record<string, string | 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 scope = SessionStateKey.scope(key) | |
| const parts = SessionStateKey.route(key).split("/") | |
| const dir = parts[0] | |
| const session = parts[1] | |
| if (!dir) continue | |
| for (const entry of SESSION_STATE_KEYS) { | |
| const target = session | |
| ? Persist.serverSession(scope, dir, session, entry.key) | |
| : Persist.serverWorkspace(scope, dir, entry.key) | |
| void removePersisted(target, platform) | |
| if (scope !== ServerScope.local) continue | |
| 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) | |
| setEphemeral( | |
| "sessionTabPreview", | |
| produce((draft) => { | |
| for (const key of drop) delete draft[key] | |
| }), | |
| ) | |
| 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() | |
| } | |
| makeEventListener(window, "pagehide", flush) | |
| makeEventListener(document, "visibilitychange", handleVisibility) | |
| onCleanup(() => { | |
| 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] = serverSync().child(project.worktree, { bootstrap: false }) | |
| const projectID = childStore.project | |
| const metadata = projectID | |
| ? serverSync().data.project.find((x) => x.id === projectID) | |
| : serverSync().data.project.find((x) => x.worktree === project.worktree) | |
| // Preserve local icon override from per-workspace localStorage cache (childStore.icon). | |
| // Without this, different subdirectories of the same git repo would share the same | |
| // icon from the database instead of using their individual overrides. | |
| const base = { ...metadata, ...project } | |
| if (childStore.icon) { | |
| return { ...base, icon: { ...base.icon, override: childStore.icon } } | |
| } | |
| return base | |
| } | |
| const roots = createMemo(() => { | |
| const map = new Map<string, string>() | |
| for (const project of serverSync().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.remove(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 (!serverSync().ready) return | |
| for (const project of projects) { | |
| if (!project.id) continue | |
| if (project.id === "global") continue | |
| serverSync().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 || project.icon?.override || project.icon?.url) 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") { | |
| serverSync().project.meta(worktree, { icon: { color } }) | |
| continue | |
| } | |
| const projectID = project.id | |
| void (async () => { | |
| const sdk = serverSdk() | |
| if ((await sdk.protocol) !== "v1") return | |
| return sdk.client.project | |
| .update({ projectID, directory: worktree, icon: { color } }) | |
| .then((response) => response.data) | |
| .then((result) => { | |
| if (!result) return | |
| serverSync().set("project", (items) => | |
| items.map((item) => (item.id === result.id ? normalizeProjectInfo(result) : item)), | |
| ) | |
| }) | |
| })().catch(() => { | |
| if (colorRequested.get(worktree) === color) colorRequested.delete(worktree) | |
| }) | |
| } | |
| }) | |
| let sessionFrame: number | undefined | |
| let sessionTimer: number | undefined | |
| onMount(() => { | |
| sessionFrame = requestAnimationFrame(() => { | |
| sessionFrame = undefined | |
| sessionTimer = window.setTimeout(() => { | |
| sessionTimer = undefined | |
| void Promise.all( | |
| server.projects.list().map((project) => { | |
| return serverSync().project.loadSessions(project.worktree) | |
| }), | |
| ) | |
| }, 0) | |
| }) | |
| }) | |
| onCleanup(() => { | |
| if (sessionFrame !== undefined) cancelAnimationFrame(sessionFrame) | |
| if (sessionTimer !== undefined) window.clearTimeout(sessionTimer) | |
| }) | |
| return { | |
| route, | |
| ready, | |
| home: { | |
| selection: createMemo(() => store.home.selection), | |
| setSelection(selection: HomeProjectSelection) { | |
| setStore("home", "selection", reconcile(selection)) | |
| }, | |
| }, | |
| handoff: { | |
| tabs: createMemo(() => store.handoff?.tabs), | |
| setTabs(dir: string, id: string) { | |
| setStore("handoff", "tabs", { scope: serverSdk().scope, dir, id, at: Date.now() }) | |
| }, | |
| clearTabs() { | |
| if (!store.handoff?.tabs) return | |
| setStore("handoff", "tabs", undefined) | |
| }, | |
| }, | |
| projects: { | |
| list, | |
| recentlyClosed: createMemo(() => { | |
| const known = new Set(serverSync().data.project.map((project) => pathKey(project.worktree))) | |
| return server.projects | |
| .recentlyClosed() | |
| .filter((worktree) => known.has(pathKey(worktree))) | |
| .slice(0, RECENTLY_CLOSED_DISPLAY_LIMIT) | |
| .map((worktree) => enrich({ worktree, expanded: false })) | |
| }), | |
| open(directory: string) { | |
| const root = rootFor(directory) | |
| if (server.projects.list().find((x) => x.worktree === root)) return | |
| void serverSync().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: DEFAULT_REVIEW_PANEL_OPENED }) | |
| return | |
| } | |
| setStore("review", "diffStyle", diffStyle) | |
| }, | |
| }, | |
| fileTree: { | |
| opened: createMemo(() => store.fileTree?.opened ?? true), | |
| width: createMemo(() => store.fileTree?.width ?? DEFAULT_FILE_TREE_WIDTH), | |
| tab: createMemo(() => store.fileTree?.tab ?? "changes"), | |
| setTab(tab: "changes" | "all") { | |
| if (!store.fileTree) { | |
| setStore("fileTree", { opened: true, width: DEFAULT_FILE_TREE_WIDTH, tab }) | |
| return | |
| } | |
| setStore("fileTree", "tab", tab) | |
| }, | |
| open() { | |
| if (!store.fileTree) { | |
| setStore("fileTree", { opened: true, width: DEFAULT_FILE_TREE_WIDTH, tab: "changes" }) | |
| return | |
| } | |
| setStore("fileTree", "opened", true) | |
| }, | |
| close() { | |
| if (!store.fileTree) { | |
| setStore("fileTree", { opened: false, width: DEFAULT_FILE_TREE_WIDTH, tab: "changes" }) | |
| return | |
| } | |
| setStore("fileTree", "opened", false) | |
| }, | |
| toggle() { | |
| if (!store.fileTree) { | |
| setStore("fileTree", { opened: true, width: DEFAULT_FILE_TREE_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 reviewMode = createMemo(() => { | |
| const mode = s().reviewMode | |
| if (mode === "git" || mode === "branch" || mode === "turn") return mode | |
| }) | |
| const reviewFile = createMemo(() => { | |
| const file = s().reviewFile | |
| if (typeof file === "string") return file | |
| }) | |
| const terminalOpened = createMemo(() => store.terminal?.opened ?? false) | |
| const reviewPanelOpened = createMemo(() => store.review?.panelOpened ?? DEFAULT_REVIEW_PANEL_OPENED) | |
| const reviewPanelSource = createMemo(() => (reviewPanelOpened() ? ephemeral.reviewPanelSource : "other")) | |
| 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, source: ReviewPanelSource) { | |
| const nextSource = next ? source : "other" | |
| const current = store.review | |
| if (!current) { | |
| batch(() => { | |
| setStore("review", { diffStyle: "split" as ReviewDiffStyle, panelOpened: next }) | |
| setEphemeral("reviewPanelSource", nextSource) | |
| }) | |
| return | |
| } | |
| const value = current.panelOpened ?? DEFAULT_REVIEW_PANEL_OPENED | |
| if (value === next) { | |
| if (ephemeral.reviewPanelSource !== nextSource) setEphemeral("reviewPanelSource", nextSource) | |
| return | |
| } | |
| batch(() => { | |
| setStore("review", "panelOpened", next) | |
| setEphemeral("reviewPanelSource", nextSource) | |
| }) | |
| } | |
| return { | |
| scroll(tab: string) { | |
| return scroll.scroll(key(), tab) | |
| }, | |
| setScroll(tab: string, pos: SessionScroll) { | |
| scroll.setScroll(key(), tab, pos) | |
| }, | |
| todoCollapsed: { | |
| get: () => s().todoCollapsed ?? false, | |
| set(collapsed: boolean) { | |
| const session = key() | |
| const current = store.sessionView[session] | |
| if (!current) { | |
| setStore("sessionView", session, { scroll: {}, todoCollapsed: collapsed }) | |
| } else { | |
| setStore("sessionView", session, "todoCollapsed", collapsed) | |
| } | |
| }, | |
| }, | |
| terminal: { | |
| opened: terminalOpened, | |
| open() { | |
| setTerminalOpened(true) | |
| }, | |
| close() { | |
| setTerminalOpened(false) | |
| }, | |
| toggle() { | |
| setTerminalOpened(!terminalOpened()) | |
| }, | |
| }, | |
| reviewPanel: { | |
| opened: reviewPanelOpened, | |
| source: reviewPanelSource, | |
| open(source: ReviewPanelSource = "other") { | |
| setReviewPanelOpened(true, source) | |
| }, | |
| close() { | |
| setReviewPanelOpened(false, "other") | |
| }, | |
| toggle() { | |
| setReviewPanelOpened(!reviewPanelOpened(), "other") | |
| }, | |
| }, | |
| review: { | |
| mode: reviewMode, | |
| setMode(mode: ReviewChangeMode) { | |
| const session = key() | |
| const current = store.sessionView[session] | |
| if (!current) { | |
| setStore("sessionView", session, { scroll: {}, reviewMode: mode }) | |
| prune(session) | |
| return | |
| } | |
| if (current.reviewMode === mode) return | |
| setStore("sessionView", session, "reviewMode", mode) | |
| prune(session) | |
| }, | |
| file: reviewFile, | |
| setFile(file: string) { | |
| const session = key() | |
| const current = store.sessionView[session] | |
| if (!current) { | |
| setStore("sessionView", session, { scroll: {}, reviewFile: file }) | |
| prune(session) | |
| return | |
| } | |
| if (current.reviewFile === file) return | |
| setStore("sessionView", session, "reviewFile", file) | |
| prune(session) | |
| }, | |
| 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) | |
| const apply = (session: string, next: ReturnType<typeof openSessionTab>) => { | |
| batch(() => { | |
| setStore("sessionTabs", session, next.tabs) | |
| setEphemeral("sessionTabPreview", session, next.preview) | |
| }) | |
| } | |
| return { | |
| tabs, | |
| active: createMemo(() => tabs().active), | |
| all: createMemo(() => tabs().all.filter((tab) => tab !== "review")), | |
| preview: createMemo(() => ephemeral.sessionTabPreview[key()]), | |
| 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") | |
| batch(() => { | |
| if (!store.sessionTabs[session]) { | |
| setStore("sessionTabs", session, { all: next, active: undefined }) | |
| } else { | |
| setStore("sessionTabs", session, "all", next) | |
| } | |
| const preview = ephemeral.sessionTabPreview[session] | |
| if (preview && !next.includes(preview)) setEphemeral("sessionTabPreview", session, undefined) | |
| }) | |
| }, | |
| async open(tab: string) { | |
| const session = key() | |
| apply( | |
| session, | |
| openSessionTab( | |
| { tabs: store.sessionTabs[session] ?? { all: [] }, preview: ephemeral.sessionTabPreview[session] }, | |
| normalize(tab), | |
| ), | |
| ) | |
| }, | |
| previewTab(tab: string) { | |
| const session = key() | |
| apply( | |
| session, | |
| previewSessionTab( | |
| { tabs: store.sessionTabs[session] ?? { all: [] }, preview: ephemeral.sessionTabPreview[session] }, | |
| normalize(tab), | |
| ), | |
| ) | |
| }, | |
| close(tab: string) { | |
| const session = key() | |
| const current = store.sessionTabs[session] | |
| if (!current) return | |
| apply( | |
| session, | |
| closeSessionTab({ tabs: current, preview: ephemeral.sessionTabPreview[session] }, normalize(tab)), | |
| ) | |
| }, | |
| 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]) | |
| }), | |
| ) | |
| }, | |
| } | |
| }, | |
| } | |
| }, | |
| }) | |