summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--packages/app/src/context/layout-scroll.test.ts73
-rw-r--r--packages/app/src/context/layout-scroll.ts118
-rw-r--r--packages/app/src/context/layout.tsx114
3 files changed, 289 insertions, 16 deletions
diff --git a/packages/app/src/context/layout-scroll.test.ts b/packages/app/src/context/layout-scroll.test.ts
new file mode 100644
index 000000000..b7962c411
--- /dev/null
+++ b/packages/app/src/context/layout-scroll.test.ts
@@ -0,0 +1,73 @@
+import { describe, expect, test } from "bun:test"
+import { createRoot } from "solid-js"
+import { createStore } from "solid-js/store"
+import { makePersisted, type SyncStorage } from "@solid-primitives/storage"
+import { createScrollPersistence } from "./layout-scroll"
+
+describe("createScrollPersistence", () => {
+ test("debounces persisted scroll writes", async () => {
+ const key = "layout-scroll.test"
+ const data = new Map<string, string>()
+ const writes: string[] = []
+ const stats = { flushes: 0 }
+
+ const storage = {
+ getItem: (k: string) => data.get(k) ?? null,
+ setItem: (k: string, v: string) => {
+ data.set(k, v)
+ if (k === key) writes.push(v)
+ },
+ removeItem: (k: string) => {
+ data.delete(k)
+ },
+ } as SyncStorage
+
+ await new Promise<void>((resolve, reject) => {
+ createRoot((dispose) => {
+ const [raw, setRaw] = createStore({
+ sessionView: {} as Record<string, { scroll: Record<string, { x: number; y: number }> }>,
+ })
+
+ const [store, setStore] = makePersisted([raw, setRaw], { name: key, storage })
+
+ const scroll = createScrollPersistence({
+ debounceMs: 30,
+ getSnapshot: (sessionKey) => store.sessionView[sessionKey]?.scroll,
+ onFlush: (sessionKey, next) => {
+ stats.flushes += 1
+
+ const current = store.sessionView[sessionKey]
+ if (!current) {
+ setStore("sessionView", sessionKey, { scroll: next })
+ return
+ }
+ setStore("sessionView", sessionKey, "scroll", (prev) => ({ ...(prev ?? {}), ...next }))
+ },
+ })
+
+ const run = async () => {
+ await new Promise((r) => setTimeout(r, 0))
+ writes.length = 0
+
+ for (const i of Array.from({ length: 100 }, (_, n) => n)) {
+ scroll.setScroll("session", "review", { x: 0, y: i })
+ }
+
+ await new Promise((r) => setTimeout(r, 120))
+
+ expect(stats.flushes).toBeGreaterThanOrEqual(1)
+ expect(writes.length).toBeGreaterThanOrEqual(1)
+ expect(writes.length).toBeLessThanOrEqual(2)
+ }
+
+ void run()
+ .then(resolve)
+ .catch(reject)
+ .finally(() => {
+ scroll.dispose()
+ dispose()
+ })
+ })
+ })
+ })
+})
diff --git a/packages/app/src/context/layout-scroll.ts b/packages/app/src/context/layout-scroll.ts
new file mode 100644
index 000000000..30b0f6904
--- /dev/null
+++ b/packages/app/src/context/layout-scroll.ts
@@ -0,0 +1,118 @@
+import { createStore, produce } from "solid-js/store"
+
+export type SessionScroll = {
+ x: number
+ y: number
+}
+
+type ScrollMap = Record<string, SessionScroll>
+
+type Options = {
+ debounceMs?: number
+ getSnapshot: (sessionKey: string) => ScrollMap | undefined
+ onFlush: (sessionKey: string, scroll: ScrollMap) => void
+}
+
+export function createScrollPersistence(opts: Options) {
+ const wait = opts.debounceMs ?? 200
+ const [cache, setCache] = createStore<Record<string, ScrollMap>>({})
+ const dirty = new Set<string>()
+ const timers = new Map<string, ReturnType<typeof setTimeout>>()
+
+ function clone(input?: ScrollMap) {
+ const out: ScrollMap = {}
+ if (!input) return out
+
+ for (const key of Object.keys(input)) {
+ const pos = input[key]
+ if (!pos) continue
+ out[key] = { x: pos.x, y: pos.y }
+ }
+
+ return out
+ }
+
+ function seed(sessionKey: string) {
+ if (cache[sessionKey]) return
+ setCache(sessionKey, clone(opts.getSnapshot(sessionKey)))
+ }
+
+ function scroll(sessionKey: string, tab: string) {
+ seed(sessionKey)
+ return cache[sessionKey]?.[tab] ?? opts.getSnapshot(sessionKey)?.[tab]
+ }
+
+ function schedule(sessionKey: string) {
+ const prev = timers.get(sessionKey)
+ if (prev) clearTimeout(prev)
+ timers.set(
+ sessionKey,
+ setTimeout(() => flush(sessionKey), wait),
+ )
+ }
+
+ function setScroll(sessionKey: string, tab: string, pos: SessionScroll) {
+ seed(sessionKey)
+
+ const prev = cache[sessionKey]?.[tab]
+ if (prev?.x === pos.x && prev?.y === pos.y) return
+
+ setCache(sessionKey, tab, { x: pos.x, y: pos.y })
+ dirty.add(sessionKey)
+ schedule(sessionKey)
+ }
+
+ function flush(sessionKey: string) {
+ const timer = timers.get(sessionKey)
+ if (timer) clearTimeout(timer)
+ timers.delete(sessionKey)
+
+ if (!dirty.has(sessionKey)) return
+ dirty.delete(sessionKey)
+
+ opts.onFlush(sessionKey, clone(cache[sessionKey]))
+ }
+
+ function flushAll() {
+ const keys = Array.from(dirty)
+ if (keys.length === 0) return
+
+ for (const key of keys) {
+ flush(key)
+ }
+ }
+
+ function drop(keys: string[]) {
+ if (keys.length === 0) return
+
+ for (const key of keys) {
+ const timer = timers.get(key)
+ if (timer) clearTimeout(timer)
+ timers.delete(key)
+ dirty.delete(key)
+ }
+
+ setCache(
+ produce((draft) => {
+ for (const key of keys) {
+ delete draft[key]
+ }
+ }),
+ )
+ }
+
+ function dispose() {
+ drop(Array.from(timers.keys()))
+ }
+
+ return {
+ cache,
+ drop,
+ flush,
+ flushAll,
+ scroll,
+ seed,
+ setScroll,
+ dispose,
+ }
+}
diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx
index e454f6cfa..def933c39 100644
--- a/packages/app/src/context/layout.tsx
+++ b/packages/app/src/context/layout.tsx
@@ -1,5 +1,5 @@
import { createStore, produce } from "solid-js/store"
-import { batch, createEffect, createMemo, onMount } from "solid-js"
+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"
@@ -7,6 +7,7 @@ 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]
@@ -29,11 +30,6 @@ type SessionTabs = {
all: string[]
}
-type SessionScroll = {
- x: number
- y: number
-}
-
type SessionView = {
scroll: Record<string, SessionScroll>
reviewOpen?: string[]
@@ -75,6 +71,97 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}),
)
+ 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 {
@@ -253,21 +340,15 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
},
},
view(sessionKey: string) {
+ touch(sessionKey)
+ scroll.seed(sessionKey)
const s = createMemo(() => store.sessionView[sessionKey] ?? { scroll: {} })
return {
scroll(tab: string) {
- return s().scroll?.[tab]
+ return scroll.scroll(sessionKey, tab)
},
setScroll(tab: string, pos: SessionScroll) {
- const current = store.sessionView[sessionKey]
- if (!current) {
- setStore("sessionView", sessionKey, { scroll: { [tab]: pos } })
- return
- }
-
- const prev = current.scroll?.[tab]
- if (prev?.x === pos.x && prev?.y === pos.y) return
- setStore("sessionView", sessionKey, "scroll", tab, pos)
+ scroll.setScroll(sessionKey, tab, pos)
},
review: {
open: createMemo(() => s().reviewOpen),
@@ -285,6 +366,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}
},
tabs(sessionKey: string) {
+ touch(sessionKey)
const tabs = createMemo(() => store.sessionTabs[sessionKey] ?? { all: [] })
return {
tabs,