|
1 | | -import { createEffect, createMemo, onCleanup } from "solid-js" |
| 1 | +import { createEffect, createMemo, createRoot, onCleanup } from "solid-js" |
2 | 2 | import { createStore, produce } from "solid-js/store" |
3 | 3 | import { createSimpleContext } from "@opencode-ai/ui/context" |
4 | 4 | import type { FileContent } from "@opencode-ai/sdk/v2" |
@@ -82,8 +82,106 @@ function normalizeSelectedLines(range: SelectedLineRange): SelectedLineRange { |
82 | 82 | } |
83 | 83 | } |
84 | 84 |
|
| 85 | +const WORKSPACE_KEY = "__workspace__" |
| 86 | +const MAX_FILE_VIEW_SESSIONS = 20 |
| 87 | +const MAX_VIEW_FILES = 500 |
| 88 | + |
| 89 | +type ViewSession = ReturnType<typeof createViewSession> |
| 90 | + |
| 91 | +type ViewCacheEntry = { |
| 92 | + value: ViewSession |
| 93 | + dispose: VoidFunction |
| 94 | +} |
| 95 | + |
| 96 | +function createViewSession(dir: string, id: string | undefined) { |
| 97 | + const legacyViewKey = `${dir}/file${id ? "/" + id : ""}.v1` |
| 98 | + |
| 99 | + const [view, setView, _, ready] = persisted( |
| 100 | + Persist.scoped(dir, id, "file-view", [legacyViewKey]), |
| 101 | + createStore<{ |
| 102 | + file: Record<string, FileViewState> |
| 103 | + }>({ |
| 104 | + file: {}, |
| 105 | + }), |
| 106 | + ) |
| 107 | + |
| 108 | + const meta = { pruned: false } |
| 109 | + |
| 110 | + const pruneView = (keep?: string) => { |
| 111 | + const keys = Object.keys(view.file) |
| 112 | + if (keys.length <= MAX_VIEW_FILES) return |
| 113 | + |
| 114 | + const drop = keys.filter((key) => key !== keep).slice(0, keys.length - MAX_VIEW_FILES) |
| 115 | + if (drop.length === 0) return |
| 116 | + |
| 117 | + setView( |
| 118 | + produce((draft) => { |
| 119 | + for (const key of drop) { |
| 120 | + delete draft.file[key] |
| 121 | + } |
| 122 | + }), |
| 123 | + ) |
| 124 | + } |
| 125 | + |
| 126 | + createEffect(() => { |
| 127 | + if (!ready()) return |
| 128 | + if (meta.pruned) return |
| 129 | + meta.pruned = true |
| 130 | + pruneView() |
| 131 | + }) |
| 132 | + |
| 133 | + const scrollTop = (path: string) => view.file[path]?.scrollTop |
| 134 | + const scrollLeft = (path: string) => view.file[path]?.scrollLeft |
| 135 | + const selectedLines = (path: string) => view.file[path]?.selectedLines |
| 136 | + |
| 137 | + const setScrollTop = (path: string, top: number) => { |
| 138 | + setView("file", path, (current) => { |
| 139 | + if (current?.scrollTop === top) return current |
| 140 | + return { |
| 141 | + ...(current ?? {}), |
| 142 | + scrollTop: top, |
| 143 | + } |
| 144 | + }) |
| 145 | + pruneView(path) |
| 146 | + } |
| 147 | + |
| 148 | + const setScrollLeft = (path: string, left: number) => { |
| 149 | + setView("file", path, (current) => { |
| 150 | + if (current?.scrollLeft === left) return current |
| 151 | + return { |
| 152 | + ...(current ?? {}), |
| 153 | + scrollLeft: left, |
| 154 | + } |
| 155 | + }) |
| 156 | + pruneView(path) |
| 157 | + } |
| 158 | + |
| 159 | + const setSelectedLines = (path: string, range: SelectedLineRange | null) => { |
| 160 | + const next = range ? normalizeSelectedLines(range) : null |
| 161 | + setView("file", path, (current) => { |
| 162 | + if (current?.selectedLines === next) return current |
| 163 | + return { |
| 164 | + ...(current ?? {}), |
| 165 | + selectedLines: next, |
| 166 | + } |
| 167 | + }) |
| 168 | + pruneView(path) |
| 169 | + } |
| 170 | + |
| 171 | + return { |
| 172 | + ready, |
| 173 | + scrollTop, |
| 174 | + scrollLeft, |
| 175 | + selectedLines, |
| 176 | + setScrollTop, |
| 177 | + setScrollLeft, |
| 178 | + setSelectedLines, |
| 179 | + } |
| 180 | +} |
| 181 | + |
85 | 182 | export const { use: useFile, provider: FileProvider } = createSimpleContext({ |
86 | 183 | name: "File", |
| 184 | + gate: false, |
87 | 185 | init: () => { |
88 | 186 | const sdk = useSDK() |
89 | 187 | const sync = useSync() |
@@ -134,42 +232,45 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({ |
134 | 232 | file: {}, |
135 | 233 | }) |
136 | 234 |
|
137 | | - const legacyViewKey = createMemo(() => `${params.dir}/file${params.id ? "/" + params.id : ""}.v1`) |
| 235 | + const viewCache = new Map<string, ViewCacheEntry>() |
138 | 236 |
|
139 | | - const [view, setView, _, ready] = persisted( |
140 | | - Persist.scoped(params.dir!, params.id, "file-view", [legacyViewKey()]), |
141 | | - createStore<{ |
142 | | - file: Record<string, FileViewState> |
143 | | - }>({ |
144 | | - file: {}, |
145 | | - }), |
146 | | - ) |
| 237 | + const disposeViews = () => { |
| 238 | + for (const entry of viewCache.values()) { |
| 239 | + entry.dispose() |
| 240 | + } |
| 241 | + viewCache.clear() |
| 242 | + } |
147 | 243 |
|
148 | | - const MAX_VIEW_FILES = 500 |
149 | | - const viewMeta = { pruned: false } |
| 244 | + const pruneViews = () => { |
| 245 | + while (viewCache.size > MAX_FILE_VIEW_SESSIONS) { |
| 246 | + const first = viewCache.keys().next().value |
| 247 | + if (!first) return |
| 248 | + const entry = viewCache.get(first) |
| 249 | + entry?.dispose() |
| 250 | + viewCache.delete(first) |
| 251 | + } |
| 252 | + } |
150 | 253 |
|
151 | | - const pruneView = (keep?: string) => { |
152 | | - const keys = Object.keys(view.file) |
153 | | - if (keys.length <= MAX_VIEW_FILES) return |
| 254 | + const loadView = (dir: string, id: string | undefined) => { |
| 255 | + const key = `${dir}:${id ?? WORKSPACE_KEY}` |
| 256 | + const existing = viewCache.get(key) |
| 257 | + if (existing) { |
| 258 | + viewCache.delete(key) |
| 259 | + viewCache.set(key, existing) |
| 260 | + return existing.value |
| 261 | + } |
154 | 262 |
|
155 | | - const drop = keys.filter((key) => key !== keep).slice(0, keys.length - MAX_VIEW_FILES) |
156 | | - if (drop.length === 0) return |
| 263 | + const entry = createRoot((dispose) => ({ |
| 264 | + value: createViewSession(dir, id), |
| 265 | + dispose, |
| 266 | + })) |
157 | 267 |
|
158 | | - setView( |
159 | | - produce((draft) => { |
160 | | - for (const key of drop) { |
161 | | - delete draft.file[key] |
162 | | - } |
163 | | - }), |
164 | | - ) |
| 268 | + viewCache.set(key, entry) |
| 269 | + pruneViews() |
| 270 | + return entry.value |
165 | 271 | } |
166 | 272 |
|
167 | | - createEffect(() => { |
168 | | - if (!ready()) return |
169 | | - if (viewMeta.pruned) return |
170 | | - viewMeta.pruned = true |
171 | | - pruneView() |
172 | | - }) |
| 273 | + const view = createMemo(() => loadView(params.dir!, params.id)) |
173 | 274 |
|
174 | 275 | function ensure(path: string) { |
175 | 276 | if (!path) return |
@@ -246,51 +347,32 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({ |
246 | 347 |
|
247 | 348 | const get = (input: string) => store.file[normalize(input)] |
248 | 349 |
|
249 | | - const scrollTop = (input: string) => view.file[normalize(input)]?.scrollTop |
250 | | - const scrollLeft = (input: string) => view.file[normalize(input)]?.scrollLeft |
251 | | - const selectedLines = (input: string) => view.file[normalize(input)]?.selectedLines |
| 350 | + const scrollTop = (input: string) => view().scrollTop(normalize(input)) |
| 351 | + const scrollLeft = (input: string) => view().scrollLeft(normalize(input)) |
| 352 | + const selectedLines = (input: string) => view().selectedLines(normalize(input)) |
252 | 353 |
|
253 | 354 | const setScrollTop = (input: string, top: number) => { |
254 | 355 | const path = normalize(input) |
255 | | - setView("file", path, (current) => { |
256 | | - if (current?.scrollTop === top) return current |
257 | | - return { |
258 | | - ...(current ?? {}), |
259 | | - scrollTop: top, |
260 | | - } |
261 | | - }) |
262 | | - pruneView(path) |
| 356 | + view().setScrollTop(path, top) |
263 | 357 | } |
264 | 358 |
|
265 | 359 | const setScrollLeft = (input: string, left: number) => { |
266 | 360 | const path = normalize(input) |
267 | | - setView("file", path, (current) => { |
268 | | - if (current?.scrollLeft === left) return current |
269 | | - return { |
270 | | - ...(current ?? {}), |
271 | | - scrollLeft: left, |
272 | | - } |
273 | | - }) |
274 | | - pruneView(path) |
| 361 | + view().setScrollLeft(path, left) |
275 | 362 | } |
276 | 363 |
|
277 | 364 | const setSelectedLines = (input: string, range: SelectedLineRange | null) => { |
278 | 365 | const path = normalize(input) |
279 | | - const next = range ? normalizeSelectedLines(range) : null |
280 | | - setView("file", path, (current) => { |
281 | | - if (current?.selectedLines === next) return current |
282 | | - return { |
283 | | - ...(current ?? {}), |
284 | | - selectedLines: next, |
285 | | - } |
286 | | - }) |
287 | | - pruneView(path) |
| 366 | + view().setSelectedLines(path, range) |
288 | 367 | } |
289 | 368 |
|
290 | | - onCleanup(() => stop()) |
| 369 | + onCleanup(() => { |
| 370 | + stop() |
| 371 | + disposeViews() |
| 372 | + }) |
291 | 373 |
|
292 | 374 | return { |
293 | | - ready, |
| 375 | + ready: () => view().ready(), |
294 | 376 | normalize, |
295 | 377 | tab, |
296 | 378 | pathFromTab, |
|
0 commit comments