ignore: refactoring and tests (#12460)
This commit is contained in:
25
packages/app/src/context/command.test.ts
Normal file
25
packages/app/src/context/command.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { upsertCommandRegistration } from "./command"
|
||||
|
||||
describe("upsertCommandRegistration", () => {
|
||||
test("replaces keyed registrations", () => {
|
||||
const one = () => [{ id: "one", title: "One" }]
|
||||
const two = () => [{ id: "two", title: "Two" }]
|
||||
|
||||
const next = upsertCommandRegistration([{ key: "layout", options: one }], { key: "layout", options: two })
|
||||
|
||||
expect(next).toHaveLength(1)
|
||||
expect(next[0]?.options).toBe(two)
|
||||
})
|
||||
|
||||
test("keeps unkeyed registrations additive", () => {
|
||||
const one = () => [{ id: "one", title: "One" }]
|
||||
const two = () => [{ id: "two", title: "Two" }]
|
||||
|
||||
const next = upsertCommandRegistration([{ options: one }], { options: two })
|
||||
|
||||
expect(next).toHaveLength(2)
|
||||
expect(next[0]?.options).toBe(two)
|
||||
expect(next[1]?.options).toBe(one)
|
||||
})
|
||||
})
|
||||
@@ -64,6 +64,16 @@ export type CommandCatalogItem = {
|
||||
slash?: string
|
||||
}
|
||||
|
||||
export type CommandRegistration = {
|
||||
key?: string
|
||||
options: Accessor<CommandOption[]>
|
||||
}
|
||||
|
||||
export function upsertCommandRegistration(registrations: CommandRegistration[], entry: CommandRegistration) {
|
||||
if (entry.key === undefined) return [entry, ...registrations]
|
||||
return [entry, ...registrations.filter((x) => x.key !== entry.key)]
|
||||
}
|
||||
|
||||
export function parseKeybind(config: string): Keybind[] {
|
||||
if (!config || config === "none") return []
|
||||
|
||||
@@ -166,9 +176,10 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
||||
const settings = useSettings()
|
||||
const language = useLanguage()
|
||||
const [store, setStore] = createStore({
|
||||
registrations: [] as Accessor<CommandOption[]>[],
|
||||
registrations: [] as CommandRegistration[],
|
||||
suspendCount: 0,
|
||||
})
|
||||
const warnedDuplicates = new Set<string>()
|
||||
|
||||
const [catalog, setCatalog, _, catalogReady] = persisted(
|
||||
Persist.global("command.catalog.v1"),
|
||||
@@ -187,8 +198,14 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
||||
const all: CommandOption[] = []
|
||||
|
||||
for (const reg of store.registrations) {
|
||||
for (const opt of reg()) {
|
||||
if (seen.has(opt.id)) continue
|
||||
for (const opt of reg.options()) {
|
||||
if (seen.has(opt.id)) {
|
||||
if (import.meta.env.DEV && !warnedDuplicates.has(opt.id)) {
|
||||
warnedDuplicates.add(opt.id)
|
||||
console.warn(`[command] duplicate command id \"${opt.id}\" registered; keeping first entry`)
|
||||
}
|
||||
continue
|
||||
}
|
||||
seen.add(opt.id)
|
||||
all.push(opt)
|
||||
}
|
||||
@@ -296,14 +313,25 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
||||
document.removeEventListener("keydown", handleKeyDown)
|
||||
})
|
||||
|
||||
function register(cb: () => CommandOption[]): void
|
||||
function register(key: string, cb: () => CommandOption[]): void
|
||||
function register(key: string | (() => CommandOption[]), cb?: () => CommandOption[]) {
|
||||
const id = typeof key === "string" ? key : undefined
|
||||
const next = typeof key === "function" ? key : cb
|
||||
if (!next) return
|
||||
const options = createMemo(next)
|
||||
const entry: CommandRegistration = {
|
||||
key: id,
|
||||
options,
|
||||
}
|
||||
setStore("registrations", (arr) => upsertCommandRegistration(arr, entry))
|
||||
onCleanup(() => {
|
||||
setStore("registrations", (arr) => arr.filter((x) => x !== entry))
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
register(cb: () => CommandOption[]) {
|
||||
const results = createMemo(cb)
|
||||
setStore("registrations", (arr) => [results, ...arr])
|
||||
onCleanup(() => {
|
||||
setStore("registrations", (arr) => arr.filter((x) => x !== results))
|
||||
})
|
||||
},
|
||||
register,
|
||||
trigger(id: string, source?: "palette" | "keybind" | "slash") {
|
||||
run(id, source)
|
||||
},
|
||||
|
||||
136
packages/app/src/context/global-sync.test.ts
Normal file
136
packages/app/src/context/global-sync.test.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
canDisposeDirectory,
|
||||
estimateRootSessionTotal,
|
||||
loadRootSessionsWithFallback,
|
||||
pickDirectoriesToEvict,
|
||||
} from "./global-sync"
|
||||
|
||||
describe("pickDirectoriesToEvict", () => {
|
||||
test("keeps pinned stores and evicts idle stores", () => {
|
||||
const now = 5_000
|
||||
const picks = pickDirectoriesToEvict({
|
||||
stores: ["a", "b", "c", "d"],
|
||||
state: new Map([
|
||||
["a", { lastAccessAt: 1_000 }],
|
||||
["b", { lastAccessAt: 4_900 }],
|
||||
["c", { lastAccessAt: 4_800 }],
|
||||
["d", { lastAccessAt: 3_000 }],
|
||||
]),
|
||||
pins: new Set(["a"]),
|
||||
max: 2,
|
||||
ttl: 1_500,
|
||||
now,
|
||||
})
|
||||
|
||||
expect(picks).toEqual(["d", "c"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("loadRootSessionsWithFallback", () => {
|
||||
test("uses limited roots query when supported", async () => {
|
||||
const calls: Array<{ directory: string; roots: true; limit?: number }> = []
|
||||
let fallback = 0
|
||||
|
||||
const result = await loadRootSessionsWithFallback({
|
||||
directory: "dir",
|
||||
limit: 10,
|
||||
list: async (query) => {
|
||||
calls.push(query)
|
||||
return { data: [] }
|
||||
},
|
||||
onFallback: () => {
|
||||
fallback += 1
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.data).toEqual([])
|
||||
expect(result.limited).toBe(true)
|
||||
expect(calls).toEqual([{ directory: "dir", roots: true, limit: 10 }])
|
||||
expect(fallback).toBe(0)
|
||||
})
|
||||
|
||||
test("falls back to full roots query on limited-query failure", async () => {
|
||||
const calls: Array<{ directory: string; roots: true; limit?: number }> = []
|
||||
let fallback = 0
|
||||
|
||||
const result = await loadRootSessionsWithFallback({
|
||||
directory: "dir",
|
||||
limit: 25,
|
||||
list: async (query) => {
|
||||
calls.push(query)
|
||||
if (query.limit) throw new Error("unsupported")
|
||||
return { data: [] }
|
||||
},
|
||||
onFallback: () => {
|
||||
fallback += 1
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.data).toEqual([])
|
||||
expect(result.limited).toBe(false)
|
||||
expect(calls).toEqual([
|
||||
{ directory: "dir", roots: true, limit: 25 },
|
||||
{ directory: "dir", roots: true },
|
||||
])
|
||||
expect(fallback).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("estimateRootSessionTotal", () => {
|
||||
test("keeps exact total for full fetches", () => {
|
||||
expect(estimateRootSessionTotal({ count: 42, limit: 10, limited: false })).toBe(42)
|
||||
})
|
||||
|
||||
test("marks has-more for full-limit limited fetches", () => {
|
||||
expect(estimateRootSessionTotal({ count: 10, limit: 10, limited: true })).toBe(11)
|
||||
})
|
||||
|
||||
test("keeps exact total when limited fetch is under limit", () => {
|
||||
expect(estimateRootSessionTotal({ count: 9, limit: 10, limited: true })).toBe(9)
|
||||
})
|
||||
})
|
||||
|
||||
describe("canDisposeDirectory", () => {
|
||||
test("rejects pinned or inflight directories", () => {
|
||||
expect(
|
||||
canDisposeDirectory({
|
||||
directory: "dir",
|
||||
hasStore: true,
|
||||
pinned: true,
|
||||
booting: false,
|
||||
loadingSessions: false,
|
||||
}),
|
||||
).toBe(false)
|
||||
expect(
|
||||
canDisposeDirectory({
|
||||
directory: "dir",
|
||||
hasStore: true,
|
||||
pinned: false,
|
||||
booting: true,
|
||||
loadingSessions: false,
|
||||
}),
|
||||
).toBe(false)
|
||||
expect(
|
||||
canDisposeDirectory({
|
||||
directory: "dir",
|
||||
hasStore: true,
|
||||
pinned: false,
|
||||
booting: false,
|
||||
loadingSessions: true,
|
||||
}),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
test("accepts idle unpinned directory store", () => {
|
||||
expect(
|
||||
canDisposeDirectory({
|
||||
directory: "dir",
|
||||
hasStore: true,
|
||||
pinned: false,
|
||||
booting: false,
|
||||
loadingSessions: false,
|
||||
}),
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -27,6 +27,7 @@ import type { InitError } from "../pages/error"
|
||||
import {
|
||||
batch,
|
||||
createContext,
|
||||
createRoot,
|
||||
createEffect,
|
||||
untrack,
|
||||
getOwner,
|
||||
@@ -131,6 +132,96 @@ function normalizeProviderList(input: ProviderListResponse): ProviderListRespons
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_DIR_STORES = 30
|
||||
const DIR_IDLE_TTL_MS = 20 * 60 * 1000
|
||||
|
||||
type DirState = {
|
||||
lastAccessAt: number
|
||||
}
|
||||
|
||||
type EvictPlan = {
|
||||
stores: string[]
|
||||
state: Map<string, DirState>
|
||||
pins: Set<string>
|
||||
max: number
|
||||
ttl: number
|
||||
now: number
|
||||
}
|
||||
|
||||
export function pickDirectoriesToEvict(input: EvictPlan) {
|
||||
const overflow = Math.max(0, input.stores.length - input.max)
|
||||
let pendingOverflow = overflow
|
||||
const sorted = input.stores
|
||||
.filter((dir) => !input.pins.has(dir))
|
||||
.slice()
|
||||
.sort((a, b) => (input.state.get(a)?.lastAccessAt ?? 0) - (input.state.get(b)?.lastAccessAt ?? 0))
|
||||
|
||||
const output: string[] = []
|
||||
for (const dir of sorted) {
|
||||
const last = input.state.get(dir)?.lastAccessAt ?? 0
|
||||
const idle = input.now - last >= input.ttl
|
||||
if (!idle && pendingOverflow <= 0) continue
|
||||
output.push(dir)
|
||||
if (pendingOverflow > 0) pendingOverflow -= 1
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
type RootLoadArgs = {
|
||||
directory: string
|
||||
limit: number
|
||||
list: (query: { directory: string; roots: true; limit?: number }) => Promise<{ data?: Session[] }>
|
||||
onFallback: () => void
|
||||
}
|
||||
|
||||
type RootLoadResult = {
|
||||
data?: Session[]
|
||||
limit: number
|
||||
limited: boolean
|
||||
}
|
||||
|
||||
export async function loadRootSessionsWithFallback(input: RootLoadArgs) {
|
||||
try {
|
||||
const result = await input.list({ directory: input.directory, roots: true, limit: input.limit })
|
||||
return {
|
||||
data: result.data,
|
||||
limit: input.limit,
|
||||
limited: true,
|
||||
} satisfies RootLoadResult
|
||||
} catch {
|
||||
input.onFallback()
|
||||
const result = await input.list({ directory: input.directory, roots: true })
|
||||
return {
|
||||
data: result.data,
|
||||
limit: input.limit,
|
||||
limited: false,
|
||||
} satisfies RootLoadResult
|
||||
}
|
||||
}
|
||||
|
||||
export function estimateRootSessionTotal(input: { count: number; limit: number; limited: boolean }) {
|
||||
if (!input.limited) return input.count
|
||||
if (input.count < input.limit) return input.count
|
||||
return input.count + 1
|
||||
}
|
||||
|
||||
type DisposeCheck = {
|
||||
directory: string
|
||||
hasStore: boolean
|
||||
pinned: boolean
|
||||
booting: boolean
|
||||
loadingSessions: boolean
|
||||
}
|
||||
|
||||
export function canDisposeDirectory(input: DisposeCheck) {
|
||||
if (!input.directory) return false
|
||||
if (!input.hasStore) return false
|
||||
if (input.pinned) return false
|
||||
if (input.booting) return false
|
||||
if (input.loadingSessions) return false
|
||||
return true
|
||||
}
|
||||
|
||||
function createGlobalSync() {
|
||||
const globalSDK = useGlobalSDK()
|
||||
const platform = usePlatform()
|
||||
@@ -140,8 +231,133 @@ function createGlobalSync() {
|
||||
const vcsCache = new Map<string, VcsCache>()
|
||||
const metaCache = new Map<string, MetaCache>()
|
||||
const iconCache = new Map<string, IconCache>()
|
||||
const lifecycle = new Map<string, DirState>()
|
||||
const pins = new Map<string, number>()
|
||||
const ownerPins = new WeakMap<object, Set<string>>()
|
||||
const disposers = new Map<string, () => void>()
|
||||
const stats = {
|
||||
evictions: 0,
|
||||
loadSessionsFallback: 0,
|
||||
}
|
||||
|
||||
const sdkCache = new Map<string, ReturnType<typeof createOpencodeClient>>()
|
||||
|
||||
const updateStats = () => {
|
||||
if (!import.meta.env.DEV) return
|
||||
;(
|
||||
globalThis as {
|
||||
__OPENCODE_GLOBAL_SYNC_STATS?: {
|
||||
activeDirectoryStores: number
|
||||
evictions: number
|
||||
loadSessionsFullFetchFallback: number
|
||||
}
|
||||
}
|
||||
).__OPENCODE_GLOBAL_SYNC_STATS = {
|
||||
activeDirectoryStores: Object.keys(children).length,
|
||||
evictions: stats.evictions,
|
||||
loadSessionsFullFetchFallback: stats.loadSessionsFallback,
|
||||
}
|
||||
}
|
||||
|
||||
const mark = (directory: string) => {
|
||||
if (!directory) return
|
||||
lifecycle.set(directory, { lastAccessAt: Date.now() })
|
||||
runEviction()
|
||||
}
|
||||
|
||||
const pin = (directory: string) => {
|
||||
if (!directory) return
|
||||
pins.set(directory, (pins.get(directory) ?? 0) + 1)
|
||||
mark(directory)
|
||||
}
|
||||
|
||||
const unpin = (directory: string) => {
|
||||
if (!directory) return
|
||||
const next = (pins.get(directory) ?? 0) - 1
|
||||
if (next > 0) {
|
||||
pins.set(directory, next)
|
||||
return
|
||||
}
|
||||
pins.delete(directory)
|
||||
runEviction()
|
||||
}
|
||||
|
||||
const pinned = (directory: string) => (pins.get(directory) ?? 0) > 0
|
||||
|
||||
const pinForOwner = (directory: string) => {
|
||||
const current = getOwner()
|
||||
if (!current) return
|
||||
if (current === owner) return
|
||||
const key = current as object
|
||||
const set = ownerPins.get(key)
|
||||
if (set?.has(directory)) return
|
||||
if (set) set.add(directory)
|
||||
else ownerPins.set(key, new Set([directory]))
|
||||
pin(directory)
|
||||
onCleanup(() => {
|
||||
const set = ownerPins.get(key)
|
||||
if (set) {
|
||||
set.delete(directory)
|
||||
if (set.size === 0) ownerPins.delete(key)
|
||||
}
|
||||
unpin(directory)
|
||||
})
|
||||
}
|
||||
|
||||
function disposeDirectory(directory: string) {
|
||||
if (
|
||||
!canDisposeDirectory({
|
||||
directory,
|
||||
hasStore: !!children[directory],
|
||||
pinned: pinned(directory),
|
||||
booting: booting.has(directory),
|
||||
loadingSessions: sessionLoads.has(directory),
|
||||
})
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
queued.delete(directory)
|
||||
sessionMeta.delete(directory)
|
||||
sdkCache.delete(directory)
|
||||
vcsCache.delete(directory)
|
||||
metaCache.delete(directory)
|
||||
iconCache.delete(directory)
|
||||
lifecycle.delete(directory)
|
||||
|
||||
const dispose = disposers.get(directory)
|
||||
if (dispose) {
|
||||
dispose()
|
||||
disposers.delete(directory)
|
||||
}
|
||||
|
||||
delete children[directory]
|
||||
updateStats()
|
||||
return true
|
||||
}
|
||||
|
||||
function runEviction() {
|
||||
const stores = Object.keys(children)
|
||||
if (stores.length === 0) return
|
||||
const list = pickDirectoriesToEvict({
|
||||
stores,
|
||||
state: lifecycle,
|
||||
pins: new Set(stores.filter(pinned)),
|
||||
max: MAX_DIR_STORES,
|
||||
ttl: DIR_IDLE_TTL_MS,
|
||||
now: Date.now(),
|
||||
})
|
||||
|
||||
if (list.length === 0) return
|
||||
let changed = false
|
||||
for (const directory of list) {
|
||||
if (!disposeDirectory(directory)) continue
|
||||
stats.evictions += 1
|
||||
changed = true
|
||||
}
|
||||
if (changed) updateStats()
|
||||
}
|
||||
|
||||
const sdkFor = (directory: string) => {
|
||||
const cached = sdkCache.get(directory)
|
||||
if (cached) return cached
|
||||
@@ -379,52 +595,56 @@ function createGlobalSync() {
|
||||
if (!icon) throw new Error("Failed to create persisted project icon")
|
||||
iconCache.set(directory, { store: icon[0], setStore: icon[1], ready: icon[3] })
|
||||
|
||||
const init = () => {
|
||||
const child = createStore<State>({
|
||||
project: "",
|
||||
projectMeta: meta[0].value,
|
||||
icon: icon[0].value,
|
||||
provider: { all: [], connected: [], default: {} },
|
||||
config: {},
|
||||
path: { state: "", config: "", worktree: "", directory: "", home: "" },
|
||||
status: "loading" as const,
|
||||
agent: [],
|
||||
command: [],
|
||||
session: [],
|
||||
sessionTotal: 0,
|
||||
session_status: {},
|
||||
session_diff: {},
|
||||
todo: {},
|
||||
permission: {},
|
||||
question: {},
|
||||
mcp: {},
|
||||
lsp: [],
|
||||
vcs: vcsStore.value,
|
||||
limit: 5,
|
||||
message: {},
|
||||
part: {},
|
||||
})
|
||||
const init = () =>
|
||||
createRoot((dispose) => {
|
||||
const child = createStore<State>({
|
||||
project: "",
|
||||
projectMeta: meta[0].value,
|
||||
icon: icon[0].value,
|
||||
provider: { all: [], connected: [], default: {} },
|
||||
config: {},
|
||||
path: { state: "", config: "", worktree: "", directory: "", home: "" },
|
||||
status: "loading" as const,
|
||||
agent: [],
|
||||
command: [],
|
||||
session: [],
|
||||
sessionTotal: 0,
|
||||
session_status: {},
|
||||
session_diff: {},
|
||||
todo: {},
|
||||
permission: {},
|
||||
question: {},
|
||||
mcp: {},
|
||||
lsp: [],
|
||||
vcs: vcsStore.value,
|
||||
limit: 5,
|
||||
message: {},
|
||||
part: {},
|
||||
})
|
||||
|
||||
children[directory] = child
|
||||
children[directory] = child
|
||||
disposers.set(directory, dispose)
|
||||
|
||||
createEffect(() => {
|
||||
if (!vcsReady()) return
|
||||
const cached = vcsStore.value
|
||||
if (!cached?.branch) return
|
||||
child[1]("vcs", (value) => value ?? cached)
|
||||
})
|
||||
createEffect(() => {
|
||||
if (!vcsReady()) return
|
||||
const cached = vcsStore.value
|
||||
if (!cached?.branch) return
|
||||
child[1]("vcs", (value) => value ?? cached)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
child[1]("projectMeta", meta[0].value)
|
||||
})
|
||||
createEffect(() => {
|
||||
child[1]("projectMeta", meta[0].value)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
child[1]("icon", icon[0].value)
|
||||
createEffect(() => {
|
||||
child[1]("icon", icon[0].value)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
runWithOwner(owner, init)
|
||||
updateStats()
|
||||
}
|
||||
mark(directory)
|
||||
const childStore = children[directory]
|
||||
if (!childStore) throw new Error("Failed to create store")
|
||||
return childStore
|
||||
@@ -432,6 +652,7 @@ function createGlobalSync() {
|
||||
|
||||
function child(directory: string, options: ChildOptions = {}) {
|
||||
const childStore = ensureChild(directory)
|
||||
pinForOwner(directory)
|
||||
const shouldBootstrap = options.bootstrap ?? true
|
||||
if (shouldBootstrap && childStore[0].status === "loading") {
|
||||
void bootstrapInstance(directory)
|
||||
@@ -443,6 +664,7 @@ function createGlobalSync() {
|
||||
const pending = sessionLoads.get(directory)
|
||||
if (pending) return pending
|
||||
|
||||
pin(directory)
|
||||
const [store, setStore] = child(directory, { bootstrap: false })
|
||||
const meta = sessionMeta.get(directory)
|
||||
if (meta && meta.limit >= store.limit) {
|
||||
@@ -450,11 +672,20 @@ function createGlobalSync() {
|
||||
if (next.length !== store.session.length) {
|
||||
setStore("session", reconcile(next, { key: "id" }))
|
||||
}
|
||||
unpin(directory)
|
||||
return
|
||||
}
|
||||
|
||||
const promise = globalSDK.client.session
|
||||
.list({ directory, roots: true })
|
||||
const limit = Math.max(store.limit + sessionRecentLimit, sessionRecentLimit)
|
||||
const promise = loadRootSessionsWithFallback({
|
||||
directory,
|
||||
limit,
|
||||
list: (query) => globalSDK.client.session.list(query),
|
||||
onFallback: () => {
|
||||
stats.loadSessionsFallback += 1
|
||||
updateStats()
|
||||
},
|
||||
})
|
||||
.then((x) => {
|
||||
const nonArchived = (x.data ?? [])
|
||||
.filter((s) => !!s?.id)
|
||||
@@ -468,8 +699,13 @@ function createGlobalSync() {
|
||||
const children = store.session.filter((s) => !!s.parentID)
|
||||
const sessions = trimSessions([...nonArchived, ...children], { limit, permission: store.permission })
|
||||
|
||||
// Store total session count (used for "load more" pagination)
|
||||
setStore("sessionTotal", nonArchived.length)
|
||||
// Store root session total for "load more" pagination.
|
||||
// For limited root queries, preserve has-more behavior by treating
|
||||
// full-limit responses as "potentially more".
|
||||
setStore(
|
||||
"sessionTotal",
|
||||
estimateRootSessionTotal({ count: nonArchived.length, limit: x.limit, limited: x.limited }),
|
||||
)
|
||||
setStore("session", reconcile(sessions, { key: "id" }))
|
||||
sessionMeta.set(directory, { limit })
|
||||
})
|
||||
@@ -482,6 +718,7 @@ function createGlobalSync() {
|
||||
sessionLoads.set(directory, promise)
|
||||
promise.finally(() => {
|
||||
sessionLoads.delete(directory)
|
||||
unpin(directory)
|
||||
})
|
||||
return promise
|
||||
}
|
||||
@@ -491,6 +728,7 @@ function createGlobalSync() {
|
||||
const pending = booting.get(directory)
|
||||
if (pending) return pending
|
||||
|
||||
pin(directory)
|
||||
const promise = (async () => {
|
||||
const [store, setStore] = ensureChild(directory)
|
||||
const cache = vcsCache.get(directory)
|
||||
@@ -605,6 +843,7 @@ function createGlobalSync() {
|
||||
booting.set(directory, promise)
|
||||
promise.finally(() => {
|
||||
booting.delete(directory)
|
||||
unpin(directory)
|
||||
})
|
||||
return promise
|
||||
}
|
||||
@@ -670,6 +909,7 @@ function createGlobalSync() {
|
||||
|
||||
const existing = children[directory]
|
||||
if (!existing) return
|
||||
mark(directory)
|
||||
|
||||
const [store, setStore] = existing
|
||||
|
||||
@@ -955,6 +1195,11 @@ function createGlobalSync() {
|
||||
if (!timer) return
|
||||
clearTimeout(timer)
|
||||
})
|
||||
onCleanup(() => {
|
||||
for (const directory of Object.keys(children)) {
|
||||
disposeDirectory(directory)
|
||||
}
|
||||
})
|
||||
|
||||
async function bootstrap() {
|
||||
const health = await globalSDK.client.global
|
||||
|
||||
66
packages/app/src/context/notification-index.ts
Normal file
66
packages/app/src/context/notification-index.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
type NotificationIndexItem = {
|
||||
directory?: string
|
||||
session?: string
|
||||
viewed: boolean
|
||||
type: string
|
||||
}
|
||||
|
||||
export function buildNotificationIndex<T extends NotificationIndexItem>(list: T[]) {
|
||||
const sessionAll = new Map<string, T[]>()
|
||||
const sessionUnseen = new Map<string, T[]>()
|
||||
const sessionUnseenCount = new Map<string, number>()
|
||||
const sessionUnseenHasError = new Map<string, boolean>()
|
||||
const projectAll = new Map<string, T[]>()
|
||||
const projectUnseen = new Map<string, T[]>()
|
||||
const projectUnseenCount = new Map<string, number>()
|
||||
const projectUnseenHasError = new Map<string, boolean>()
|
||||
|
||||
for (const notification of list) {
|
||||
const session = notification.session
|
||||
if (session) {
|
||||
const all = sessionAll.get(session)
|
||||
if (all) all.push(notification)
|
||||
else sessionAll.set(session, [notification])
|
||||
|
||||
if (!notification.viewed) {
|
||||
const unseen = sessionUnseen.get(session)
|
||||
if (unseen) unseen.push(notification)
|
||||
else sessionUnseen.set(session, [notification])
|
||||
|
||||
sessionUnseenCount.set(session, (sessionUnseenCount.get(session) ?? 0) + 1)
|
||||
if (notification.type === "error") sessionUnseenHasError.set(session, true)
|
||||
}
|
||||
}
|
||||
|
||||
const directory = notification.directory
|
||||
if (directory) {
|
||||
const all = projectAll.get(directory)
|
||||
if (all) all.push(notification)
|
||||
else projectAll.set(directory, [notification])
|
||||
|
||||
if (!notification.viewed) {
|
||||
const unseen = projectUnseen.get(directory)
|
||||
if (unseen) unseen.push(notification)
|
||||
else projectUnseen.set(directory, [notification])
|
||||
|
||||
projectUnseenCount.set(directory, (projectUnseenCount.get(directory) ?? 0) + 1)
|
||||
if (notification.type === "error") projectUnseenHasError.set(directory, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
session: {
|
||||
all: sessionAll,
|
||||
unseen: sessionUnseen,
|
||||
unseenCount: sessionUnseenCount,
|
||||
unseenHasError: sessionUnseenHasError,
|
||||
},
|
||||
project: {
|
||||
all: projectAll,
|
||||
unseen: projectUnseen,
|
||||
unseenCount: projectUnseenCount,
|
||||
unseenHasError: projectUnseenHasError,
|
||||
},
|
||||
}
|
||||
}
|
||||
73
packages/app/src/context/notification.test.ts
Normal file
73
packages/app/src/context/notification.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { buildNotificationIndex } from "./notification-index"
|
||||
|
||||
type Notification = {
|
||||
type: "turn-complete" | "error"
|
||||
session: string
|
||||
directory: string
|
||||
viewed: boolean
|
||||
time: number
|
||||
}
|
||||
|
||||
const turn = (session: string, directory: string, viewed = false): Notification => ({
|
||||
type: "turn-complete",
|
||||
session,
|
||||
directory,
|
||||
viewed,
|
||||
time: 1,
|
||||
})
|
||||
|
||||
const error = (session: string, directory: string, viewed = false): Notification => ({
|
||||
type: "error",
|
||||
session,
|
||||
directory,
|
||||
viewed,
|
||||
time: 1,
|
||||
})
|
||||
|
||||
describe("buildNotificationIndex", () => {
|
||||
test("builds unseen counts and unseen error flags", () => {
|
||||
const list = [
|
||||
turn("s1", "d1", false),
|
||||
error("s1", "d1", false),
|
||||
turn("s1", "d1", true),
|
||||
turn("s2", "d1", false),
|
||||
error("s3", "d2", true),
|
||||
]
|
||||
|
||||
const index = buildNotificationIndex(list)
|
||||
|
||||
expect(index.session.all.get("s1")?.length).toBe(3)
|
||||
expect(index.session.unseen.get("s1")?.length).toBe(2)
|
||||
expect(index.session.unseenCount.get("s1")).toBe(2)
|
||||
expect(index.session.unseenHasError.get("s1")).toBe(true)
|
||||
|
||||
expect(index.session.unseenCount.get("s2")).toBe(1)
|
||||
expect(index.session.unseenHasError.get("s2") ?? false).toBe(false)
|
||||
expect(index.session.unseenCount.get("s3") ?? 0).toBe(0)
|
||||
expect(index.session.unseenHasError.get("s3") ?? false).toBe(false)
|
||||
|
||||
expect(index.project.unseenCount.get("d1")).toBe(3)
|
||||
expect(index.project.unseenHasError.get("d1")).toBe(true)
|
||||
expect(index.project.unseenCount.get("d2") ?? 0).toBe(0)
|
||||
expect(index.project.unseenHasError.get("d2") ?? false).toBe(false)
|
||||
})
|
||||
|
||||
test("updates selectors after viewed transitions", () => {
|
||||
const list = [turn("s1", "d1", false), error("s1", "d1", false), turn("s2", "d1", false)]
|
||||
const next = list.map((item) => (item.session === "s1" ? { ...item, viewed: true } : item))
|
||||
|
||||
const before = buildNotificationIndex(list)
|
||||
const after = buildNotificationIndex(next)
|
||||
|
||||
expect(before.session.unseenCount.get("s1")).toBe(2)
|
||||
expect(before.session.unseenHasError.get("s1")).toBe(true)
|
||||
expect(before.project.unseenCount.get("d1")).toBe(3)
|
||||
expect(before.project.unseenHasError.get("d1")).toBe(true)
|
||||
|
||||
expect(after.session.unseenCount.get("s1") ?? 0).toBe(0)
|
||||
expect(after.session.unseenHasError.get("s1") ?? false).toBe(false)
|
||||
expect(after.project.unseenCount.get("d1")).toBe(1)
|
||||
expect(after.project.unseenHasError.get("d1") ?? false).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -13,6 +13,7 @@ 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
|
||||
@@ -81,49 +82,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
|
||||
setStore("list", (list) => pruneNotifications([...list, notification]))
|
||||
}
|
||||
|
||||
const index = createMemo(() => {
|
||||
const sessionAll = new Map<string, Notification[]>()
|
||||
const sessionUnseen = new Map<string, Notification[]>()
|
||||
const projectAll = new Map<string, Notification[]>()
|
||||
const projectUnseen = new Map<string, Notification[]>()
|
||||
|
||||
for (const notification of store.list) {
|
||||
const session = notification.session
|
||||
if (session) {
|
||||
const list = sessionAll.get(session)
|
||||
if (list) list.push(notification)
|
||||
else sessionAll.set(session, [notification])
|
||||
if (!notification.viewed) {
|
||||
const unseen = sessionUnseen.get(session)
|
||||
if (unseen) unseen.push(notification)
|
||||
else sessionUnseen.set(session, [notification])
|
||||
}
|
||||
}
|
||||
|
||||
const directory = notification.directory
|
||||
if (directory) {
|
||||
const list = projectAll.get(directory)
|
||||
if (list) list.push(notification)
|
||||
else projectAll.set(directory, [notification])
|
||||
if (!notification.viewed) {
|
||||
const unseen = projectUnseen.get(directory)
|
||||
if (unseen) unseen.push(notification)
|
||||
else projectUnseen.set(directory, [notification])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
session: {
|
||||
all: sessionAll,
|
||||
unseen: sessionUnseen,
|
||||
},
|
||||
project: {
|
||||
all: projectAll,
|
||||
unseen: projectUnseen,
|
||||
},
|
||||
}
|
||||
})
|
||||
const index = createMemo(() => buildNotificationIndex(store.list))
|
||||
|
||||
const unsub = globalSDK.event.listen((e) => {
|
||||
const event = e.details
|
||||
@@ -208,6 +167,12 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
|
||||
unseen(session: string) {
|
||||
return index().session.unseen.get(session) ?? empty
|
||||
},
|
||||
unseenCount(session: string) {
|
||||
return index().session.unseenCount.get(session) ?? 0
|
||||
},
|
||||
unseenHasError(session: string) {
|
||||
return index().session.unseenHasError.get(session) ?? false
|
||||
},
|
||||
markViewed(session: string) {
|
||||
setStore("list", (n) => n.session === session, "viewed", true)
|
||||
},
|
||||
@@ -219,6 +184,12 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
|
||||
unseen(directory: string) {
|
||||
return index().project.unseen.get(directory) ?? empty
|
||||
},
|
||||
unseenCount(directory: string) {
|
||||
return index().project.unseenCount.get(directory) ?? 0
|
||||
},
|
||||
unseenHasError(directory: string) {
|
||||
return index().project.unseenHasError.get(directory) ?? false
|
||||
},
|
||||
markViewed(directory: string) {
|
||||
setStore("list", (n) => n.directory === directory, "viewed", true)
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user