summaryrefslogtreecommitdiffhomepage
path: root/packages/app/src/context
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-02-12 11:26:19 -0600
committerGitHub <[email protected]>2026-02-12 11:26:19 -0600
commitda952135cabba2926698298797cd301e7adaf48c (patch)
tree78635fe4f7d656266ad3cc1c353b04b56969515c /packages/app/src/context
parent789705ea96ae28af7e30801fd6039ce89b6ac48e (diff)
downloadopencode-da952135cabba2926698298797cd301e7adaf48c.tar.gz
opencode-da952135cabba2926698298797cd301e7adaf48c.zip
chore(app): refactor for better solidjs hygiene (#13344)
Diffstat (limited to 'packages/app/src/context')
-rw-r--r--packages/app/src/context/file/view-cache.ts52
-rw-r--r--packages/app/src/context/global-sync/event-reducer.ts1
-rw-r--r--packages/app/src/context/notification.tsx185
-rw-r--r--packages/app/src/context/terminal.tsx49
4 files changed, 230 insertions, 57 deletions
diff --git a/packages/app/src/context/file/view-cache.ts b/packages/app/src/context/file/view-cache.ts
index 2614b2fb5..6e8ddf62d 100644
--- a/packages/app/src/context/file/view-cache.ts
+++ b/packages/app/src/context/file/view-cache.ts
@@ -23,6 +23,16 @@ function normalizeSelectedLines(range: SelectedLineRange): SelectedLineRange {
}
}
+function equalSelectedLines(a: SelectedLineRange | null | undefined, b: SelectedLineRange | null | undefined) {
+ if (!a && !b) return true
+ if (!a || !b) return false
+ const left = normalizeSelectedLines(a)
+ const right = normalizeSelectedLines(b)
+ return (
+ left.start === right.start && left.end === right.end && left.side === right.side && left.endSide === right.endSide
+ )
+}
+
function createViewSession(dir: string, id: string | undefined) {
const legacyViewKey = `${dir}/file${id ? "/" + id : ""}.v1`
@@ -65,36 +75,36 @@ function createViewSession(dir: string, id: string | undefined) {
const selectedLines = (path: string) => view.file[path]?.selectedLines
const setScrollTop = (path: string, top: number) => {
- setView("file", path, (current) => {
- if (current?.scrollTop === top) return current
- return {
- ...(current ?? {}),
- scrollTop: top,
- }
- })
+ setView(
+ produce((draft) => {
+ const file = draft.file[path] ?? (draft.file[path] = {})
+ if (file.scrollTop === top) return
+ file.scrollTop = top
+ }),
+ )
pruneView(path)
}
const setScrollLeft = (path: string, left: number) => {
- setView("file", path, (current) => {
- if (current?.scrollLeft === left) return current
- return {
- ...(current ?? {}),
- scrollLeft: left,
- }
- })
+ setView(
+ produce((draft) => {
+ const file = draft.file[path] ?? (draft.file[path] = {})
+ if (file.scrollLeft === left) return
+ file.scrollLeft = left
+ }),
+ )
pruneView(path)
}
const setSelectedLines = (path: string, range: SelectedLineRange | null) => {
const next = range ? normalizeSelectedLines(range) : null
- setView("file", path, (current) => {
- if (current?.selectedLines === next) return current
- return {
- ...(current ?? {}),
- selectedLines: next,
- }
- })
+ setView(
+ produce((draft) => {
+ const file = draft.file[path] ?? (draft.file[path] = {})
+ if (equalSelectedLines(file.selectedLines, next)) return
+ file.selectedLines = next
+ }),
+ )
pruneView(path)
}
diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts
index c658d82c8..fa1a43d47 100644
--- a/packages/app/src/context/global-sync/event-reducer.ts
+++ b/packages/app/src/context/global-sync/event-reducer.ts
@@ -233,6 +233,7 @@ export function applyDirectoryEvent(input: {
}
case "vcs.branch.updated": {
const props = event.properties as { branch: string }
+ if (input.store.vcs?.branch === props.branch) break
const next = { branch: props.branch }
input.setStore("vcs", next)
if (input.vcsCache) input.vcsCache.setStore("value", next)
diff --git a/packages/app/src/context/notification.tsx b/packages/app/src/context/notification.tsx
index e35e815f9..bf880d115 100644
--- a/packages/app/src/context/notification.tsx
+++ b/packages/app/src/context/notification.tsx
@@ -1,5 +1,5 @@
-import { createStore } from "solid-js/store"
-import { createEffect, createMemo, onCleanup } from "solid-js"
+import { createStore, reconcile } from "solid-js/store"
+import { batch, createEffect, createMemo, onCleanup } from "solid-js"
import { useParams } from "@solidjs/router"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useGlobalSDK } from "./global-sdk"
@@ -13,7 +13,6 @@ import { decode64 } from "@/utils/base64"
import { EventSessionError } from "@opencode-ai/sdk/v2"
import { Persist, persisted } from "@/utils/persist"
import { playSound, soundSrc } from "@/utils/sound"
-import { buildNotificationIndex } from "./notification-index"
type NotificationBase = {
directory?: string
@@ -34,6 +33,21 @@ type ErrorNotification = NotificationBase & {
export type Notification = TurnCompleteNotification | ErrorNotification
+type NotificationIndex = {
+ session: {
+ all: Record<string, Notification[]>
+ unseen: Record<string, Notification[]>
+ unseenCount: Record<string, number>
+ unseenHasError: Record<string, boolean>
+ }
+ project: {
+ all: Record<string, Notification[]>
+ unseen: Record<string, Notification[]>
+ unseenCount: Record<string, number>
+ unseenHasError: Record<string, boolean>
+ }
+}
+
const MAX_NOTIFICATIONS = 500
const NOTIFICATION_TTL_MS = 1000 * 60 * 60 * 24 * 30
@@ -44,6 +58,53 @@ function pruneNotifications(list: Notification[]) {
return pruned.slice(pruned.length - MAX_NOTIFICATIONS)
}
+function createNotificationIndex(): NotificationIndex {
+ return {
+ session: {
+ all: {},
+ unseen: {},
+ unseenCount: {},
+ unseenHasError: {},
+ },
+ project: {
+ all: {},
+ unseen: {},
+ unseenCount: {},
+ unseenHasError: {},
+ },
+ }
+}
+
+function buildNotificationIndex(list: Notification[]) {
+ const index = createNotificationIndex()
+
+ list.forEach((notification) => {
+ if (notification.session) {
+ const all = index.session.all[notification.session] ?? []
+ index.session.all[notification.session] = [...all, notification]
+ if (!notification.viewed) {
+ const unseen = index.session.unseen[notification.session] ?? []
+ index.session.unseen[notification.session] = [...unseen, notification]
+ index.session.unseenCount[notification.session] = unseen.length + 1
+ if (notification.type === "error") index.session.unseenHasError[notification.session] = true
+ }
+ }
+
+ if (notification.directory) {
+ const all = index.project.all[notification.directory] ?? []
+ index.project.all[notification.directory] = [...all, notification]
+ if (!notification.viewed) {
+ const unseen = index.project.unseen[notification.directory] ?? []
+ index.project.unseen[notification.directory] = [...unseen, notification]
+ index.project.unseenCount[notification.directory] = unseen.length + 1
+ if (notification.type === "error") index.project.unseenHasError[notification.directory] = true
+ }
+ }
+ })
+
+ return index
+}
+
export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({
name: "Notification",
init: () => {
@@ -68,21 +129,81 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
list: [] as Notification[],
}),
)
+ const [index, setIndex] = createStore<NotificationIndex>(buildNotificationIndex(store.list))
const meta = { pruned: false, disposed: false }
+ const updateUnseen = (scope: "session" | "project", key: string, unseen: Notification[]) => {
+ setIndex(scope, "unseen", key, unseen)
+ setIndex(scope, "unseenCount", key, unseen.length)
+ setIndex(
+ scope,
+ "unseenHasError",
+ key,
+ unseen.some((notification) => notification.type === "error"),
+ )
+ }
+
+ const appendToIndex = (notification: Notification) => {
+ if (notification.session) {
+ setIndex("session", "all", notification.session, (all = []) => [...all, notification])
+ if (!notification.viewed) {
+ setIndex("session", "unseen", notification.session, (unseen = []) => [...unseen, notification])
+ setIndex("session", "unseenCount", notification.session, (count = 0) => count + 1)
+ if (notification.type === "error") setIndex("session", "unseenHasError", notification.session, true)
+ }
+ }
+
+ if (notification.directory) {
+ setIndex("project", "all", notification.directory, (all = []) => [...all, notification])
+ if (!notification.viewed) {
+ setIndex("project", "unseen", notification.directory, (unseen = []) => [...unseen, notification])
+ setIndex("project", "unseenCount", notification.directory, (count = 0) => count + 1)
+ if (notification.type === "error") setIndex("project", "unseenHasError", notification.directory, true)
+ }
+ }
+ }
+
+ const removeFromIndex = (notification: Notification) => {
+ if (notification.session) {
+ setIndex("session", "all", notification.session, (all = []) => all.filter((n) => n !== notification))
+ if (!notification.viewed) {
+ const unseen = (index.session.unseen[notification.session] ?? empty).filter((n) => n !== notification)
+ updateUnseen("session", notification.session, unseen)
+ }
+ }
+
+ if (notification.directory) {
+ setIndex("project", "all", notification.directory, (all = []) => all.filter((n) => n !== notification))
+ if (!notification.viewed) {
+ const unseen = (index.project.unseen[notification.directory] ?? empty).filter((n) => n !== notification)
+ updateUnseen("project", notification.directory, unseen)
+ }
+ }
+ }
+
createEffect(() => {
if (!ready()) return
if (meta.pruned) return
meta.pruned = true
- setStore("list", pruneNotifications(store.list))
+ const list = pruneNotifications(store.list)
+ batch(() => {
+ setStore("list", list)
+ setIndex(reconcile(buildNotificationIndex(list), { merge: false }))
+ })
})
const append = (notification: Notification) => {
- setStore("list", (list) => pruneNotifications([...list, notification]))
- }
+ const list = pruneNotifications([...store.list, notification])
+ const keep = new Set(list)
+ const removed = store.list.filter((n) => !keep.has(n))
- const index = createMemo(() => buildNotificationIndex(store.list))
+ batch(() => {
+ if (keep.has(notification)) appendToIndex(notification)
+ removed.forEach((n) => removeFromIndex(n))
+ setStore("list", list)
+ })
+ }
const lookup = async (directory: string, sessionID?: string) => {
if (!sessionID) return undefined
@@ -181,36 +302,66 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
ready,
session: {
all(session: string) {
- return index().session.all.get(session) ?? empty
+ return index.session.all[session] ?? empty
},
unseen(session: string) {
- return index().session.unseen.get(session) ?? empty
+ return index.session.unseen[session] ?? empty
},
unseenCount(session: string) {
- return index().session.unseenCount.get(session) ?? 0
+ return index.session.unseenCount[session] ?? 0
},
unseenHasError(session: string) {
- return index().session.unseenHasError.get(session) ?? false
+ return index.session.unseenHasError[session] ?? false
},
markViewed(session: string) {
- setStore("list", (n) => n.session === session, "viewed", true)
+ const unseen = index.session.unseen[session] ?? empty
+ if (!unseen.length) return
+
+ const projects = [
+ ...new Set(unseen.flatMap((notification) => (notification.directory ? [notification.directory] : []))),
+ ]
+ batch(() => {
+ setStore("list", (n) => n.session === session && !n.viewed, "viewed", true)
+ updateUnseen("session", session, [])
+ projects.forEach((directory) => {
+ const next = (index.project.unseen[directory] ?? empty).filter(
+ (notification) => notification.session !== session,
+ )
+ updateUnseen("project", directory, next)
+ })
+ })
},
},
project: {
all(directory: string) {
- return index().project.all.get(directory) ?? empty
+ return index.project.all[directory] ?? empty
},
unseen(directory: string) {
- return index().project.unseen.get(directory) ?? empty
+ return index.project.unseen[directory] ?? empty
},
unseenCount(directory: string) {
- return index().project.unseenCount.get(directory) ?? 0
+ return index.project.unseenCount[directory] ?? 0
},
unseenHasError(directory: string) {
- return index().project.unseenHasError.get(directory) ?? false
+ return index.project.unseenHasError[directory] ?? false
},
markViewed(directory: string) {
- setStore("list", (n) => n.directory === directory, "viewed", true)
+ const unseen = index.project.unseen[directory] ?? empty
+ if (!unseen.length) return
+
+ const sessions = [
+ ...new Set(unseen.flatMap((notification) => (notification.session ? [notification.session] : []))),
+ ]
+ batch(() => {
+ setStore("list", (n) => n.directory === directory && !n.viewed, "viewed", true)
+ updateUnseen("project", directory, [])
+ sessions.forEach((session) => {
+ const next = (index.session.unseen[session] ?? empty).filter(
+ (notification) => notification.directory !== directory,
+ )
+ updateUnseen("session", session, next)
+ })
+ })
},
},
}
diff --git a/packages/app/src/context/terminal.tsx b/packages/app/src/context/terminal.tsx
index 0e6aa08cb..64f026219 100644
--- a/packages/app/src/context/terminal.tsx
+++ b/packages/app/src/context/terminal.tsx
@@ -101,11 +101,15 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
const all = store.all
const index = all.findIndex((x) => x.id === id)
if (index === -1) return
- const filtered = all.filter((x) => x.id !== id)
- const active = store.active === id ? filtered[0]?.id : store.active
+ const active = store.active === id ? (index === 0 ? all[1]?.id : all[0]?.id) : store.active
batch(() => {
- setStore("all", filtered)
setStore("active", active)
+ setStore(
+ "all",
+ produce((draft) => {
+ draft.splice(index, 1)
+ }),
+ )
})
}
@@ -157,10 +161,7 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
title: pty.data?.title ?? "Terminal",
titleNumber: nextNumber,
}
- setStore("all", (all) => {
- const newAll = [...all, newTerminal]
- return newAll
- })
+ setStore("all", store.all.length, newTerminal)
setStore("active", id)
})
.catch((error: unknown) => {
@@ -168,8 +169,11 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
})
},
update(pty: Partial<LocalPTY> & { id: string }) {
- const previous = store.all.find((x) => x.id === pty.id)
- if (previous) setStore("all", (all) => all.map((item) => (item.id === pty.id ? { ...item, ...pty } : item)))
+ const index = store.all.findIndex((x) => x.id === pty.id)
+ const previous = index >= 0 ? store.all[index] : undefined
+ if (index >= 0) {
+ setStore("all", index, (item) => ({ ...item, ...pty }))
+ }
sdk.client.pty
.update({
ptyID: pty.id,
@@ -178,7 +182,8 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
})
.catch((error: unknown) => {
if (previous) {
- setStore("all", (all) => all.map((item) => (item.id === pty.id ? previous : item)))
+ const currentIndex = store.all.findIndex((item) => item.id === pty.id)
+ if (currentIndex >= 0) setStore("all", currentIndex, previous)
}
console.error("Failed to update terminal", error)
})
@@ -232,15 +237,21 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
setStore("active", store.all[prevIndex]?.id)
},
async close(id: string) {
- batch(() => {
- const filtered = store.all.filter((x) => x.id !== id)
- if (store.active === id) {
- const index = store.all.findIndex((f) => f.id === id)
- const next = index > 0 ? index - 1 : 0
- setStore("active", filtered[next]?.id)
- }
- setStore("all", filtered)
- })
+ const index = store.all.findIndex((f) => f.id === id)
+ if (index !== -1) {
+ batch(() => {
+ if (store.active === id) {
+ const next = index > 0 ? store.all[index - 1]?.id : store.all[1]?.id
+ setStore("active", next)
+ }
+ setStore(
+ "all",
+ produce((all) => {
+ all.splice(index, 1)
+ }),
+ )
+ })
+ }
await sdk.client.pty.remove({ ptyID: id }).catch((error: unknown) => {
console.error("Failed to close terminal", error)