fix(app): start MCP servers only for open directories (#28937)
This commit is contained in:
@@ -178,7 +178,7 @@ export const createDirSyncContext = (directory: string, serverSync: ReturnType<t
|
|||||||
type Child = ReturnType<(typeof serverSync)["child"]>
|
type Child = ReturnType<(typeof serverSync)["child"]>
|
||||||
type Setter = Child[1]
|
type Setter = Child[1]
|
||||||
|
|
||||||
const current = createMemo(() => serverSync.child(directory))
|
const current = createMemo(() => serverSync.child(directory, { mcp: true }))
|
||||||
const target = (directory?: string) => {
|
const target = (directory?: string) => {
|
||||||
if (!directory || directory === directory) return current()
|
if (!directory || directory === directory) return current()
|
||||||
return serverSync.child(directory)
|
return serverSync.child(directory)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const provider = { all: new Map(), connected: [], default: {} } satisfies Normal
|
|||||||
|
|
||||||
describe("bootstrapDirectory", () => {
|
describe("bootstrapDirectory", () => {
|
||||||
test("marks a loading directory partial during bootstrap and complete after success", async () => {
|
test("marks a loading directory partial during bootstrap and complete after success", async () => {
|
||||||
|
const mcpReads: string[] = []
|
||||||
const [store, setStore] = createStore<State>({
|
const [store, setStore] = createStore<State>({
|
||||||
status: "loading",
|
status: "loading",
|
||||||
agent: [],
|
agent: [],
|
||||||
@@ -44,6 +45,7 @@ describe("bootstrapDirectory", () => {
|
|||||||
|
|
||||||
await bootstrapDirectory({
|
await bootstrapDirectory({
|
||||||
directory: "/project",
|
directory: "/project",
|
||||||
|
mcp: false,
|
||||||
global: {
|
global: {
|
||||||
config: {} satisfies Config,
|
config: {} satisfies Config,
|
||||||
path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" },
|
path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" },
|
||||||
@@ -55,10 +57,20 @@ describe("bootstrapDirectory", () => {
|
|||||||
config: { get: async () => ({ data: {} }) },
|
config: { get: async () => ({ data: {} }) },
|
||||||
session: { status: async () => ({ data: {} }) },
|
session: { status: async () => ({ data: {} }) },
|
||||||
vcs: { get: async () => ({ data: undefined }) },
|
vcs: { get: async () => ({ data: undefined }) },
|
||||||
command: { list: async () => ({ data: [] }) },
|
command: {
|
||||||
|
list: async () => {
|
||||||
|
mcpReads.push("command")
|
||||||
|
return { data: [] }
|
||||||
|
},
|
||||||
|
},
|
||||||
permission: { list: async () => ({ data: [] }) },
|
permission: { list: async () => ({ data: [] }) },
|
||||||
question: { list: async () => ({ data: [] }) },
|
question: { list: async () => ({ data: [] }) },
|
||||||
mcp: { status: async () => ({ data: {} }) },
|
mcp: {
|
||||||
|
status: async () => {
|
||||||
|
mcpReads.push("status")
|
||||||
|
return { data: {} }
|
||||||
|
},
|
||||||
|
},
|
||||||
provider: { list: async () => ({ data: { all: [], connected: [], default: {} } }) },
|
provider: { list: async () => ({ data: { all: [], connected: [], default: {} } }) },
|
||||||
} as unknown as OpencodeClient,
|
} as unknown as OpencodeClient,
|
||||||
store,
|
store,
|
||||||
@@ -74,5 +86,6 @@ describe("bootstrapDirectory", () => {
|
|||||||
await new Promise((resolve) => setTimeout(resolve, 80))
|
await new Promise((resolve) => setTimeout(resolve, 80))
|
||||||
|
|
||||||
expect(store.status).toBe("complete")
|
expect(store.status).toBe("complete")
|
||||||
|
expect(mcpReads).toEqual([])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -198,6 +198,7 @@ export const loadPathQuery = (directory: string | null, sdk: OpencodeClient) =>
|
|||||||
|
|
||||||
export async function bootstrapDirectory(input: {
|
export async function bootstrapDirectory(input: {
|
||||||
directory: string
|
directory: string
|
||||||
|
mcp: boolean
|
||||||
sdk: OpencodeClient
|
sdk: OpencodeClient
|
||||||
store: Store<State>
|
store: Store<State>
|
||||||
setStore: SetStoreFunction<State>
|
setStore: SetStoreFunction<State>
|
||||||
@@ -250,7 +251,7 @@ export async function bootstrapDirectory(input: {
|
|||||||
if (next) input.vcsCache.setStore("value", next)
|
if (next) input.vcsCache.setStore("value", next)
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
() => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? []))),
|
input.mcp && (() => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? [])))),
|
||||||
() =>
|
() =>
|
||||||
retry(() =>
|
retry(() =>
|
||||||
input.sdk.permission.list().then((x) => {
|
input.sdk.permission.list().then((x) => {
|
||||||
@@ -304,7 +305,7 @@ export async function bootstrapDirectory(input: {
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
() => Promise.resolve(input.loadSessions(input.directory)),
|
() => Promise.resolve(input.loadSessions(input.directory)),
|
||||||
() => input.queryClient.fetchQuery(loadMcpQuery(input.directory, input.sdk)),
|
input.mcp && (() => input.queryClient.fetchQuery(loadMcpQuery(input.directory, input.sdk))),
|
||||||
() =>
|
() =>
|
||||||
input.queryClient.fetchQuery(loadProvidersQuery(input.directory, input.sdk)).catch((err) => {
|
input.queryClient.fetchQuery(loadProvidersQuery(input.directory, input.sdk)).catch((err) => {
|
||||||
const project = getFilename(input.directory)
|
const project = getFilename(input.directory)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type { State } from "./types"
|
|||||||
import type { QueryOptionsApi } from "../server-sync"
|
import type { QueryOptionsApi } from "../server-sync"
|
||||||
|
|
||||||
let createChildStoreManager: typeof import("./child-store").createChildStoreManager
|
let createChildStoreManager: typeof import("./child-store").createChildStoreManager
|
||||||
|
const queryGroups: Array<() => { queries: Array<{ enabled?: boolean }> }> = []
|
||||||
|
|
||||||
const child = () => createStore({} as State)
|
const child = () => createStore({} as State)
|
||||||
const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse
|
const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse
|
||||||
@@ -48,12 +49,15 @@ beforeAll(async () => {
|
|||||||
persisted: (_target: string, store: unknown[]) => [store[0], store[1], null, () => true],
|
persisted: (_target: string, store: unknown[]) => [store[0], store[1], null, () => true],
|
||||||
}))
|
}))
|
||||||
mock.module("@tanstack/solid-query", () => ({
|
mock.module("@tanstack/solid-query", () => ({
|
||||||
useQueries: () => [
|
useQueries: (options: () => { queries: Array<{ enabled?: boolean }> }) => {
|
||||||
{ isLoading: false, data: { state: "", config: "", worktree: "", directory: "", home: "" } },
|
queryGroups.push(options)
|
||||||
{ isLoading: false, data: {} },
|
return [
|
||||||
{ isLoading: false, data: [] },
|
{ isLoading: false, data: { state: "", config: "", worktree: "", directory: "", home: "" } },
|
||||||
{ isLoading: false, data: provider },
|
{ isLoading: false, data: {} },
|
||||||
],
|
{ isLoading: false, data: [] },
|
||||||
|
{ isLoading: false, data: provider },
|
||||||
|
]
|
||||||
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
createChildStoreManager = (await import("./child-store")).createChildStoreManager
|
createChildStoreManager = (await import("./child-store")).createChildStoreManager
|
||||||
@@ -73,6 +77,7 @@ describe("createChildStoreManager", () => {
|
|||||||
isBooting: () => false,
|
isBooting: () => false,
|
||||||
isLoadingSessions: () => false,
|
isLoadingSessions: () => false,
|
||||||
onBootstrap() {},
|
onBootstrap() {},
|
||||||
|
onMcp() {},
|
||||||
onDispose() {},
|
onDispose() {},
|
||||||
translate: (key) => key,
|
translate: (key) => key,
|
||||||
queryOptions: queryOptionsApi,
|
queryOptions: queryOptionsApi,
|
||||||
@@ -103,6 +108,7 @@ describe("createChildStoreManager", () => {
|
|||||||
onBootstrap(directory) {
|
onBootstrap(directory) {
|
||||||
bootstraps.push(directory)
|
bootstraps.push(directory)
|
||||||
},
|
},
|
||||||
|
onMcp() {},
|
||||||
onDispose() {},
|
onDispose() {},
|
||||||
translate: (key) => key,
|
translate: (key) => key,
|
||||||
queryOptions: queryOptionsApi,
|
queryOptions: queryOptionsApi,
|
||||||
@@ -121,4 +127,45 @@ describe("createChildStoreManager", () => {
|
|||||||
dispose()
|
dispose()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("enables MCP only when requested for the directory", () => {
|
||||||
|
let manager: ReturnType<typeof createChildStoreManager> | undefined
|
||||||
|
const offset = queryGroups.length
|
||||||
|
const mcpLoads: string[] = []
|
||||||
|
|
||||||
|
const dispose = createOwner((owner) => {
|
||||||
|
manager = createChildStoreManager({
|
||||||
|
owner,
|
||||||
|
isBooting: () => false,
|
||||||
|
isLoadingSessions: () => false,
|
||||||
|
onBootstrap() {},
|
||||||
|
onMcp(directory) {
|
||||||
|
mcpLoads.push(directory)
|
||||||
|
},
|
||||||
|
onDispose() {},
|
||||||
|
translate: (key) => key,
|
||||||
|
queryOptions: queryOptionsApi,
|
||||||
|
global: { provider },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!manager) throw new Error("manager required")
|
||||||
|
const [, setStore] = manager.child("/project", { bootstrap: false })
|
||||||
|
const queries = queryGroups[offset]
|
||||||
|
if (!queries) throw new Error("queries required")
|
||||||
|
expect(queries().queries[1]?.enabled).toBe(false)
|
||||||
|
|
||||||
|
setStore("status", "complete")
|
||||||
|
manager.child("/project", { bootstrap: false, mcp: true })
|
||||||
|
expect(queries().queries[1]?.enabled).toBe(true)
|
||||||
|
expect(mcpLoads).toEqual(["/project"])
|
||||||
|
|
||||||
|
manager.disableMcp("/project")
|
||||||
|
expect(queries().queries[1]?.enabled).toBe(false)
|
||||||
|
expect(manager.mcp("/project")).toBe(false)
|
||||||
|
} finally {
|
||||||
|
dispose()
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createRoot, getOwner, onCleanup, runWithOwner, type Owner } from "solid-js"
|
import { createRoot, createSignal, getOwner, onCleanup, runWithOwner, type Owner } from "solid-js"
|
||||||
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
|
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
import type { VcsInfo } from "@opencode-ai/sdk/v2/client"
|
import type { VcsInfo } from "@opencode-ai/sdk/v2/client"
|
||||||
@@ -24,6 +24,7 @@ export function createChildStoreManager(input: {
|
|||||||
isBooting: (directory: string) => boolean
|
isBooting: (directory: string) => boolean
|
||||||
isLoadingSessions: (directory: string) => boolean
|
isLoadingSessions: (directory: string) => boolean
|
||||||
onBootstrap: (directory: string) => void
|
onBootstrap: (directory: string) => void
|
||||||
|
onMcp: (directory: string, setStore: SetStoreFunction<State>) => void
|
||||||
onDispose: (directory: string) => void
|
onDispose: (directory: string) => void
|
||||||
translate: (key: string, vars?: Record<string, string | number>) => string
|
translate: (key: string, vars?: Record<string, string | number>) => string
|
||||||
queryOptions: QueryOptionsApi
|
queryOptions: QueryOptionsApi
|
||||||
@@ -39,6 +40,8 @@ export function createChildStoreManager(input: {
|
|||||||
const pins = new Map<string, number>()
|
const pins = new Map<string, number>()
|
||||||
const ownerPins = new WeakMap<object, Set<string>>()
|
const ownerPins = new WeakMap<object, Set<string>>()
|
||||||
const disposers = new Map<string, () => void>()
|
const disposers = new Map<string, () => void>()
|
||||||
|
const mcpDirectories = new Set<string>()
|
||||||
|
const mcpToggles = new Map<string, (enabled: boolean) => void>()
|
||||||
|
|
||||||
const markKey = (key: DirectoryKey) => {
|
const markKey = (key: DirectoryKey) => {
|
||||||
if (!key) return
|
if (!key) return
|
||||||
@@ -110,6 +113,8 @@ export function createChildStoreManager(input: {
|
|||||||
metaCache.delete(key)
|
metaCache.delete(key)
|
||||||
iconCache.delete(key)
|
iconCache.delete(key)
|
||||||
lifecycle.delete(key)
|
lifecycle.delete(key)
|
||||||
|
mcpDirectories.delete(key)
|
||||||
|
mcpToggles.delete(key)
|
||||||
const dispose = disposers.get(key)
|
const dispose = disposers.get(key)
|
||||||
if (dispose) {
|
if (dispose) {
|
||||||
dispose()
|
dispose()
|
||||||
@@ -173,11 +178,12 @@ export function createChildStoreManager(input: {
|
|||||||
createRoot((dispose) => {
|
createRoot((dispose) => {
|
||||||
const initialMeta = meta[0].value
|
const initialMeta = meta[0].value
|
||||||
const initialIcon = icon[0].value
|
const initialIcon = icon[0].value
|
||||||
|
const [mcpEnabled, setMcpEnabled] = createSignal(false)
|
||||||
|
|
||||||
const [pathQuery, mcpQuery, lspQuery, providerQuery] = useQueries(() => ({
|
const [pathQuery, mcpQuery, lspQuery, providerQuery] = useQueries(() => ({
|
||||||
queries: [
|
queries: [
|
||||||
input.queryOptions.path(key),
|
input.queryOptions.path(key),
|
||||||
input.queryOptions.mcp(key),
|
{ ...input.queryOptions.mcp(key), enabled: mcpEnabled() },
|
||||||
input.queryOptions.lsp(key),
|
input.queryOptions.lsp(key),
|
||||||
input.queryOptions.providers(key),
|
input.queryOptions.providers(key),
|
||||||
],
|
],
|
||||||
@@ -236,6 +242,7 @@ export function createChildStoreManager(input: {
|
|||||||
})
|
})
|
||||||
children[key] = child
|
children[key] = child
|
||||||
disposers.set(key, dispose)
|
disposers.set(key, dispose)
|
||||||
|
mcpToggles.set(key, setMcpEnabled)
|
||||||
|
|
||||||
const onPersistedInit = (init: Promise<string> | string | null, run: () => void) => {
|
const onPersistedInit = (init: Promise<string> | string | null, run: () => void) => {
|
||||||
if (!(init instanceof Promise)) return
|
if (!(init instanceof Promise)) return
|
||||||
@@ -274,6 +281,7 @@ export function createChildStoreManager(input: {
|
|||||||
const key = directoryKey(directory)
|
const key = directoryKey(directory)
|
||||||
const childStore = ensureChild(directory)
|
const childStore = ensureChild(directory)
|
||||||
pinForOwner(key)
|
pinForOwner(key)
|
||||||
|
if (options.mcp) enableMcp(directory, key, childStore)
|
||||||
const shouldBootstrap = options.bootstrap ?? true
|
const shouldBootstrap = options.bootstrap ?? true
|
||||||
if (shouldBootstrap && childStore[0].status === "loading") {
|
if (shouldBootstrap && childStore[0].status === "loading") {
|
||||||
input.onBootstrap(directory)
|
input.onBootstrap(directory)
|
||||||
@@ -284,6 +292,7 @@ export function createChildStoreManager(input: {
|
|||||||
function peek(directory: string, options: ChildOptions = {}) {
|
function peek(directory: string, options: ChildOptions = {}) {
|
||||||
const key = directoryKey(directory)
|
const key = directoryKey(directory)
|
||||||
const childStore = ensureChild(directory)
|
const childStore = ensureChild(directory)
|
||||||
|
if (options.mcp) enableMcp(directory, key, childStore)
|
||||||
const shouldBootstrap = options.bootstrap ?? true
|
const shouldBootstrap = options.bootstrap ?? true
|
||||||
if (shouldBootstrap && childStore[0].status === "loading") {
|
if (shouldBootstrap && childStore[0].status === "loading") {
|
||||||
input.onBootstrap(directory)
|
input.onBootstrap(directory)
|
||||||
@@ -291,6 +300,19 @@ export function createChildStoreManager(input: {
|
|||||||
return childStore
|
return childStore
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function enableMcp(directory: string, key: DirectoryKey, childStore: [Store<State>, SetStoreFunction<State>]) {
|
||||||
|
if (mcpDirectories.has(key)) return
|
||||||
|
mcpDirectories.add(key)
|
||||||
|
mcpToggles.get(key)?.(true)
|
||||||
|
if (childStore[0].status !== "loading") input.onMcp(directory, childStore[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
function disableMcp(directory: string) {
|
||||||
|
const key = directoryKey(directory)
|
||||||
|
if (!mcpDirectories.delete(key)) return
|
||||||
|
mcpToggles.get(key)?.(false)
|
||||||
|
}
|
||||||
|
|
||||||
function projectMeta(directory: string, patch: ProjectMeta) {
|
function projectMeta(directory: string, patch: ProjectMeta) {
|
||||||
const key = directoryKey(directory)
|
const key = directoryKey(directory)
|
||||||
const [store, setStore] = ensureChild(directory)
|
const [store, setStore] = ensureChild(directory)
|
||||||
@@ -330,6 +352,8 @@ export function createChildStoreManager(input: {
|
|||||||
pin,
|
pin,
|
||||||
unpin,
|
unpin,
|
||||||
pinned,
|
pinned,
|
||||||
|
mcp: (directory: string) => mcpDirectories.has(directoryKey(directory)),
|
||||||
|
disableMcp,
|
||||||
disposeDirectory,
|
disposeDirectory,
|
||||||
runEviction,
|
runEviction,
|
||||||
vcsCache,
|
vcsCache,
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ export type IconCache = {
|
|||||||
|
|
||||||
export type ChildOptions = {
|
export type ChildOptions = {
|
||||||
bootstrap?: boolean
|
bootstrap?: boolean
|
||||||
|
mcp?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DirState = {
|
export type DirState = {
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import { PathKey } from "@/utils/path-key"
|
|||||||
import { createDirSyncContext } from "./directory-sync"
|
import { createDirSyncContext } from "./directory-sync"
|
||||||
import { createSimpleContext, NormalizedProviderListResponse } from "@opencode-ai/ui/context"
|
import { createSimpleContext, NormalizedProviderListResponse } from "@opencode-ai/ui/context"
|
||||||
import { createRefCountMap } from "@/utils/refcount"
|
import { createRefCountMap } from "@/utils/refcount"
|
||||||
|
import { retry } from "@opencode-ai/core/util/retry"
|
||||||
|
|
||||||
type GlobalStore = {
|
type GlobalStore = {
|
||||||
ready: boolean
|
ready: boolean
|
||||||
@@ -205,6 +206,15 @@ export function createServerSyncContext() {
|
|||||||
onBootstrap: (directory) => {
|
onBootstrap: (directory) => {
|
||||||
void bootstrapInstance(directory)
|
void bootstrapInstance(directory)
|
||||||
},
|
},
|
||||||
|
onMcp: (directory, setStore) => {
|
||||||
|
void retry(() => sdkFor(directory).command.list().then((x) => setStore("command", x.data ?? []))).catch((err) => {
|
||||||
|
showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: language.t("toast.project.reloadFailed.title", { project: getFilename(directory) }),
|
||||||
|
description: formatServerError(err, language.t),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
onDispose: (directory) => {
|
onDispose: (directory) => {
|
||||||
const key = directoryKey(directory)
|
const key = directoryKey(directory)
|
||||||
queue.clear(key)
|
queue.clear(key)
|
||||||
@@ -311,6 +321,7 @@ export function createServerSyncContext() {
|
|||||||
const sdk = sdkFor(directory)
|
const sdk = sdkFor(directory)
|
||||||
await bootstrapDirectory({
|
await bootstrapDirectory({
|
||||||
directory,
|
directory,
|
||||||
|
mcp: children.mcp(key),
|
||||||
global: {
|
global: {
|
||||||
config: globalStore.config,
|
config: globalStore.config,
|
||||||
path: globalStore.path,
|
path: globalStore.path,
|
||||||
@@ -437,6 +448,7 @@ export function createServerSyncContext() {
|
|||||||
},
|
},
|
||||||
child: children.child,
|
child: children.child,
|
||||||
peek: children.peek,
|
peek: children.peek,
|
||||||
|
disableMcp: children.disableMcp,
|
||||||
queryOptions: queryOptionsApi,
|
queryOptions: queryOptionsApi,
|
||||||
// bootstrap,
|
// bootstrap,
|
||||||
updateConfig: updateConfigMutation.mutateAsync,
|
updateConfig: updateConfigMutation.mutateAsync,
|
||||||
@@ -454,7 +466,11 @@ export const { use: useServerSync, provider: ServerSyncProvider } = createSimple
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...sync,
|
...sync,
|
||||||
createDirSyncContext: createRefCountMap((dir) => createDirSyncContext(dir, sync)),
|
createDirSyncContext: createRefCountMap(
|
||||||
|
(dir) => createDirSyncContext(dir, sync),
|
||||||
|
(dir) => sync.disableMcp(dir),
|
||||||
|
directoryKey,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
49
packages/app/src/utils/refcount.test.ts
Normal file
49
packages/app/src/utils/refcount.test.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { createRoot } from "solid-js"
|
||||||
|
import { createRefCountMap } from "./refcount"
|
||||||
|
import { pathKey } from "./path-key"
|
||||||
|
|
||||||
|
describe("createRefCountMap", () => {
|
||||||
|
test("removes an item after its last owner is disposed", () => {
|
||||||
|
const removed: string[] = []
|
||||||
|
const map = createRefCountMap(
|
||||||
|
(key) => key,
|
||||||
|
(key) => removed.push(key),
|
||||||
|
)
|
||||||
|
const first = createRoot((dispose) => {
|
||||||
|
map("/project")
|
||||||
|
return dispose
|
||||||
|
})
|
||||||
|
const second = createRoot((dispose) => {
|
||||||
|
map("/project")
|
||||||
|
return dispose
|
||||||
|
})
|
||||||
|
|
||||||
|
first()
|
||||||
|
expect(removed).toEqual([])
|
||||||
|
second()
|
||||||
|
expect(removed).toEqual(["/project"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("keeps equivalent path consumers until the last owner is disposed", () => {
|
||||||
|
const removed: string[] = []
|
||||||
|
const map = createRefCountMap(
|
||||||
|
(key) => key,
|
||||||
|
(key) => removed.push(key),
|
||||||
|
pathKey,
|
||||||
|
)
|
||||||
|
const first = createRoot((dispose) => {
|
||||||
|
map("C:\\repo")
|
||||||
|
return dispose
|
||||||
|
})
|
||||||
|
const second = createRoot((dispose) => {
|
||||||
|
map("C:/repo/")
|
||||||
|
return dispose
|
||||||
|
})
|
||||||
|
|
||||||
|
first()
|
||||||
|
expect(removed).toEqual([])
|
||||||
|
second()
|
||||||
|
expect(removed).toEqual(["C:/repo"])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,26 +1,32 @@
|
|||||||
import { onCleanup } from "solid-js"
|
import { onCleanup } from "solid-js"
|
||||||
|
|
||||||
export function createRefCountMap<T>(create: (key: string) => T) {
|
export function createRefCountMap<T>(
|
||||||
|
create: (key: string) => T,
|
||||||
|
remove?: (key: string) => void,
|
||||||
|
identity: (key: string) => string = (key) => key,
|
||||||
|
) {
|
||||||
const items = new Map<string, T>()
|
const items = new Map<string, T>()
|
||||||
const refCounts = new Map<string, number>()
|
const refCounts = new Map<string, number>()
|
||||||
|
|
||||||
return (key: string) => {
|
return (key: string) => {
|
||||||
|
const id = identity(key)
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
refCounts.set(key, (refCounts.get(key) ?? 0) - 1)
|
refCounts.set(id, (refCounts.get(id) ?? 0) - 1)
|
||||||
if (refCounts.get(key) === 0) {
|
if (refCounts.get(id) === 0) {
|
||||||
items.delete(key)
|
remove?.(id)
|
||||||
refCounts.delete(key)
|
items.delete(id)
|
||||||
|
refCounts.delete(id)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const cached = items.get(key)
|
const cached = items.get(id)
|
||||||
if (cached) {
|
if (cached) {
|
||||||
refCounts.set(key, (refCounts.get(key) ?? 0) + 1)
|
refCounts.set(id, (refCounts.get(id) ?? 0) + 1)
|
||||||
return cached
|
return cached
|
||||||
}
|
}
|
||||||
const item = create(key)
|
const item = create(key)
|
||||||
items.set(key, item)
|
items.set(id, item)
|
||||||
refCounts.set(key, 1)
|
refCounts.set(id, 1)
|
||||||
return item
|
return item
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user