summaryrefslogtreecommitdiffhomepage
path: root/packages/app/src/context
diff options
context:
space:
mode:
authoradamelmore <[email protected]>2026-01-27 14:51:34 -0600
committeradamelmore <[email protected]>2026-01-27 15:25:07 -0600
commit842f17d6d97c52d1efac66a8dca298f6ca692a56 (patch)
treefd45e06f014dc5a7e72e509bb46f4c3ec1fb444c /packages/app/src/context
parent1ebf63c70c552c95794325f40bbd278ba3e0c725 (diff)
downloadopencode-842f17d6d97c52d1efac66a8dca298f6ca692a56.tar.gz
opencode-842f17d6d97c52d1efac66a8dca298f6ca692a56.zip
perf(app): better memory management
Diffstat (limited to 'packages/app/src/context')
-rw-r--r--packages/app/src/context/file.tsx72
-rw-r--r--packages/app/src/context/global-sync.tsx47
-rw-r--r--packages/app/src/context/permission.tsx24
3 files changed, 130 insertions, 13 deletions
diff --git a/packages/app/src/context/file.tsx b/packages/app/src/context/file.tsx
index 16deacfe8..7509334ed 100644
--- a/packages/app/src/context/file.tsx
+++ b/packages/app/src/context/file.tsx
@@ -151,6 +151,28 @@ const WORKSPACE_KEY = "__workspace__"
const MAX_FILE_VIEW_SESSIONS = 20
const MAX_VIEW_FILES = 500
+const MAX_FILE_CONTENT_ENTRIES = 40
+const MAX_FILE_CONTENT_BYTES = 20 * 1024 * 1024
+
+const contentLru = new Map<string, number>()
+
+function approxBytes(content: FileContent) {
+ const patchBytes =
+ content.patch?.hunks.reduce((total, hunk) => {
+ return total + hunk.lines.reduce((sum, line) => sum + line.length, 0)
+ }, 0) ?? 0
+
+ return (content.content.length + (content.diff?.length ?? 0) + patchBytes) * 2
+}
+
+function touchContent(path: string, bytes?: number) {
+ const prev = contentLru.get(path)
+ if (prev === undefined && bytes === undefined) return
+ const value = bytes ?? prev ?? 0
+ contentLru.delete(path)
+ contentLru.set(path, value)
+}
+
type ViewSession = ReturnType<typeof createViewSession>
type ViewCacheEntry = {
@@ -315,10 +337,40 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
dir: { "": { expanded: true } },
})
+ const evictContent = (keep?: Set<string>) => {
+ const protectedSet = keep ?? new Set<string>()
+ const total = () => {
+ return Array.from(contentLru.values()).reduce((sum, bytes) => sum + bytes, 0)
+ }
+
+ while (contentLru.size > MAX_FILE_CONTENT_ENTRIES || total() > MAX_FILE_CONTENT_BYTES) {
+ const path = contentLru.keys().next().value
+ if (!path) return
+
+ if (protectedSet.has(path)) {
+ touchContent(path)
+ if (contentLru.size <= protectedSet.size) return
+ continue
+ }
+
+ contentLru.delete(path)
+ if (!store.file[path]) continue
+ setStore(
+ "file",
+ path,
+ produce((draft) => {
+ draft.content = undefined
+ draft.loaded = false
+ }),
+ )
+ }
+ }
+
createEffect(() => {
scope()
inflight.clear()
treeInflight.clear()
+ contentLru.clear()
setStore("file", {})
setTree("node", {})
setTree("dir", { "": { expanded: true } })
@@ -399,15 +451,20 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
.read({ path })
.then((x) => {
if (scope() !== directory) return
+ const content = x.data
setStore(
"file",
path,
produce((draft) => {
draft.loaded = true
draft.loading = false
- draft.content = x.data
+ draft.content = content
}),
)
+
+ if (!content) return
+ touchContent(path, approxBytes(content))
+ evictContent(new Set([path]))
})
.catch((e) => {
if (scope() !== directory) return
@@ -597,7 +654,18 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
listDir(parent, { force: true })
})
- const get = (input: string) => store.file[normalize(input)]
+ const get = (input: string) => {
+ const path = normalize(input)
+ const file = store.file[path]
+ const content = file?.content
+ if (!content) return file
+ if (contentLru.has(path)) {
+ touchContent(path)
+ return file
+ }
+ touchContent(path, approxBytes(content))
+ return file
+ }
const scrollTop = (input: string) => view().scrollTop(normalize(input))
const scrollLeft = (input: string) => view().scrollLeft(normalize(input))
diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx
index 4efbf62aa..fb67193ab 100644
--- a/packages/app/src/context/global-sync.tsx
+++ b/packages/app/src/context/global-sync.tsx
@@ -546,6 +546,37 @@ function createGlobalSync() {
return promise
}
+ function purgeMessageParts(setStore: SetStoreFunction<State>, messageID: string | undefined) {
+ if (!messageID) return
+ setStore(
+ produce((draft) => {
+ delete draft.part[messageID]
+ }),
+ )
+ }
+
+ function purgeSessionData(store: Store<State>, setStore: SetStoreFunction<State>, sessionID: string | undefined) {
+ if (!sessionID) return
+
+ const messages = store.message[sessionID]
+ const messageIDs = (messages ?? []).map((m) => m.id).filter((id): id is string => !!id)
+
+ setStore(
+ produce((draft) => {
+ delete draft.message[sessionID]
+ delete draft.session_diff[sessionID]
+ delete draft.todo[sessionID]
+ delete draft.permission[sessionID]
+ delete draft.question[sessionID]
+ delete draft.session_status[sessionID]
+
+ for (const messageID of messageIDs) {
+ delete draft.part[messageID]
+ }
+ }),
+ )
+ }
+
const unsub = globalSDK.event.listen((e) => {
const directory = e.name
const event = e.details
@@ -651,9 +682,7 @@ function createGlobalSync() {
}),
)
}
-
cleanupSessionCaches(info.id)
-
if (info.parentID) break
setStore("sessionTotal", (value) => Math.max(0, value - 1))
break
@@ -679,9 +708,7 @@ function createGlobalSync() {
}),
)
}
-
cleanupSessionCaches(sessionID)
-
if (event.properties.info.parentID) break
setStore("sessionTotal", (value) => Math.max(0, value - 1))
break
@@ -757,15 +784,19 @@ function createGlobalSync() {
break
}
case "message.part.removed": {
- const parts = store.part[event.properties.messageID]
+ const messageID = event.properties.messageID
+ const parts = store.part[messageID]
if (!parts) break
const result = Binary.search(parts, event.properties.partID, (p) => p.id)
if (result.found) {
setStore(
- "part",
- event.properties.messageID,
produce((draft) => {
- draft.splice(result.index, 1)
+ const list = draft.part[messageID]
+ if (!list) return
+ const next = Binary.search(list, event.properties.partID, (p) => p.id)
+ if (!next.found) return
+ list.splice(next.index, 1)
+ if (list.length === 0) delete draft.part[messageID]
}),
)
}
diff --git a/packages/app/src/context/permission.tsx b/packages/app/src/context/permission.tsx
index d85f2ef24..a701dbd1f 100644
--- a/packages/app/src/context/permission.tsx
+++ b/packages/app/src/context/permission.tsx
@@ -67,7 +67,21 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
}),
)
- const responded = new Set<string>()
+ const MAX_RESPONDED = 1000
+ const RESPONDED_TTL_MS = 60 * 60 * 1000
+ const responded = new Map<string, number>()
+
+ function pruneResponded(now: number) {
+ for (const [id, ts] of responded) {
+ if (now - ts < RESPONDED_TTL_MS) break
+ responded.delete(id)
+ }
+
+ for (const id of responded.keys()) {
+ if (responded.size <= MAX_RESPONDED) break
+ responded.delete(id)
+ }
+ }
const respond: PermissionRespondFn = (input) => {
globalSDK.client.permission.respond(input).catch(() => {
@@ -76,8 +90,12 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
}
function respondOnce(permission: PermissionRequest, directory?: string) {
- if (responded.has(permission.id)) return
- responded.add(permission.id)
+ const now = Date.now()
+ const hit = responded.has(permission.id)
+ responded.delete(permission.id)
+ responded.set(permission.id, now)
+ pruneResponded(now)
+ if (hit) return
respond({
sessionID: permission.sessionID,
permissionID: permission.id,