import { Log } from "@/util/log"import { Context } from "../util/context"import { Project } from "./project"import { State } from "./state"import { iife } from "@/util/iife"import { GlobalBus } from "@/bus/global"import { Filesystem } from "@/util/filesystem" interface Context { directory: string worktree: string project: Project.Info}const context = Context.create<Context>("instance")const cache = new Map<string, Promise<Context>>() const disposal = { all: undefined as Promise<void> | undefined,} export const Instance = { async provide<R>(input: { directory: string; init?: () => Promise<any>; fn: () => R }): Promise<R> { // Canonicalize so spelling variants of the same folder (trailing slash, `.`, // symlinks) resolve to one live instance and one project id. const directory = Project.canonicalize(input.directory) let existing = cache.get(directory) if (!existing) { Log.Default.info("creating instance", { directory }) existing = iife(async () => { const { project, sandbox } = await Project.fromDirectory(directory) const ctx = { directory, worktree: sandbox, project, } await context.provide(ctx, async () => { await input.init?.() }) return ctx }) cache.set(directory, existing) } const ctx = await existing return context.provide(ctx, async () => { return input.fn() }) }, get directory() { return context.use().directory }, get worktree() { return context.use().worktree }, get project() { return context.use().project }, /** * Check if a path is within the project boundary. * Returns true if path is inside Instance.directory OR Instance.worktree. * Paths within the worktree but outside the working directory should not trigger external_directory permission. */ containsPath(filepath: string) { if (Filesystem.contains(Instance.directory, filepath)) return true // Guard against a filesystem-root worktree ("/") matching ANY absolute path, // which would defeat external_directory permissions. Since folder-scoped // loading, non-git projects set worktree = the opened directory (not "/"), so // this only trips for a project literally opened at the fs root. if (Instance.worktree === "/") return false return Filesystem.contains(Instance.worktree, filepath) }, state<S>(init: () => S, dispose?: (state: Awaited<S>) => Promise<void>): () => S { return State.create(() => Instance.directory, init, dispose) }, async dispose() { Log.Default.info("disposing instance", { directory: Instance.directory }) await State.dispose(Instance.directory) cache.delete(Instance.directory) GlobalBus.emit("event", { directory: Instance.directory, payload: { type: "server.instance.disposed", properties: { directory: Instance.directory, }, }, }) }, async disposeAll() { if (disposal.all) return disposal.all disposal.all = iife(async () => { Log.Default.info("disposing all instances") const entries = [...cache.entries()] for (const [key, value] of entries) { if (cache.get(key) !== value) continue const ctx = await value.catch((error) => { Log.Default.warn("instance dispose failed", { key, error }) return undefined }) if (!ctx) { if (cache.get(key) === value) cache.delete(key) continue } if (cache.get(key) !== value) continue await context.provide(ctx, async () => { await Instance.dispose() }) } }).finally(() => { disposal.all = undefined }) return disposal.all },}
import { Log } from "@/util/log"
import { Context } from "../util/context"
import { Project } from "./project"
import { State } from "./state"
import { iife } from "@/util/iife"
import { GlobalBus } from "@/bus/global"
import { Filesystem } from "@/util/filesystem"
interface Context {
directory: string
worktree: string
project: Project.Info
}
const context = Context.create<Context>("instance")
const cache = new Map<string, Promise<Context>>()
const disposal = {
all: undefined as Promise<void> | undefined,
}
export const Instance = {
async provide<R>(input: { directory: string; init?: () => Promise<any>; fn: () => R }): Promise<R> {
// Canonicalize so spelling variants of the same folder (trailing slash, `.`,
// symlinks) resolve to one live instance and one project id.
const directory = Project.canonicalize(input.directory)
let existing = cache.get(directory)
if (!existing) {
Log.Default.info("creating instance", { directory })
existing = iife(async () => {
const { project, sandbox } = await Project.fromDirectory(directory)
const ctx = {
directory,
worktree: sandbox,
project,
}
await context.provide(ctx, async () => {
await input.init?.()
})
return ctx
})
cache.set(directory, existing)
}
const ctx = await existing
return context.provide(ctx, async () => {
return input.fn()
})
},
get directory() {
return context.use().directory
},
get worktree() {
return context.use().worktree
},
get project() {
return context.use().project
},
/**
* Check if a path is within the project boundary.
* Returns true if path is inside Instance.directory OR Instance.worktree.
* Paths within the worktree but outside the working directory should not trigger external_directory permission.
*/
containsPath(filepath: string) {
if (Filesystem.contains(Instance.directory, filepath)) return true
// Guard against a filesystem-root worktree ("/") matching ANY absolute path,
// which would defeat external_directory permissions. Since folder-scoped
// loading, non-git projects set worktree = the opened directory (not "/"), so
// this only trips for a project literally opened at the fs root.
if (Instance.worktree === "/") return false
return Filesystem.contains(Instance.worktree, filepath)
},
state<S>(init: () => S, dispose?: (state: Awaited<S>) => Promise<void>): () => S {
return State.create(() => Instance.directory, init, dispose)
},
async dispose() {
Log.Default.info("disposing instance", { directory: Instance.directory })
await State.dispose(Instance.directory)
cache.delete(Instance.directory)
GlobalBus.emit("event", {
directory: Instance.directory,
payload: {
type: "server.instance.disposed",
properties: {
directory: Instance.directory,
},
},
})
},
async disposeAll() {
if (disposal.all) return disposal.all
disposal.all = iife(async () => {
Log.Default.info("disposing all instances")
const entries = [...cache.entries()]
for (const [key, value] of entries) {
if (cache.get(key) !== value) continue
const ctx = await value.catch((error) => {
Log.Default.warn("instance dispose failed", { key, error })
return undefined
})
if (!ctx) {
if (cache.get(key) === value) cache.delete(key)
continue
}
if (cache.get(key) !== value) continue
await context.provide(ctx, async () => {
await Instance.dispose()
})
}
}).finally(() => {
disposal.all = undefined
})
return disposal.all
},
}