| 1 | import { composeError, Context } from '@deepseek-ai/cordis' |
| 2 | import { isNonNullable, type Dict } from '@deepseek-ai/cosmokit' |
| 3 | import { Entry, type EntryOptions } from './entry.ts' |
| 4 | import { EntryGroup } from './group.ts' |
| 5 | |
| 6 | /** Mutable tree of loader entries. Persistence is supplied by subclasses. */ |
| 7 | export abstract class EntryTree { |
| 8 | static readonly sep = ':' |
| 9 | |
| 10 | public ctx: Context |
| 11 | public enableLogs?: boolean |
| 12 | public root: EntryGroup |
| 13 | public store: Dict<Entry> = Object.create(null) |
| 14 | |
| 15 | constructor(ctx: Context) { |
| 16 | this.ctx = ctx.extend({ baseUrl: ctx.baseUrl }) |
| 17 | this.root = new EntryGroup(this.ctx, this) |
| 18 | const entry = this.ctx.fiber.entry |
| 19 | if (entry) entry.subtree = this |
| 20 | } |
| 21 | |
| 22 | get context(): Context { |
| 23 | return this.ctx |
| 24 | } |
| 25 | |
| 26 | /** Iterate entries in this tree and any nested subtrees. */ |
| 27 | * entries(): Generator<Entry, void, void> { |
| 28 | for (const entry of Object.values(this.store)) { |
| 29 | yield entry |
| 30 | if (!entry.subtree) continue |
| 31 | yield* entry.subtree.entries() |
| 32 | } |
| 33 | } |
| 34 | |
| 35 | /** Return pending import and lifecycle tasks owned by this tree. */ |
| 36 | getTasks() { |
| 37 | return [...this.entries()] |
| 38 | .map(entry => entry._initTask || entry.fiber?.inertia) |
| 39 | .filter(isNonNullable) |
| 40 | } |
| 41 | |
| 42 | /** Wait until this tree has no pending import or lifecycle tasks. */ |
| 43 | async await() { |
| 44 | while (true) { |
| 45 | const tasks = this.getTasks() |
| 46 | if (!tasks.length) return |
| 47 | await Promise.allSettled(tasks) |
| 48 | } |
| 49 | } |
| 50 | |
| 51 | ensureId(options: Partial<EntryOptions>) { |
| 52 | if (!options.id) { |
| 53 | do { |
| 54 | options.id = Math.random().toString(16).slice(2, 10) |
| 55 | } while (this.store[options.id]) |
| 56 | } |
| 57 | return options.id! |
| 58 | } |
| 59 | |
| 60 | /** Resolve an entry by id, including nested ids separated by `EntryTree.sep`. */ |
| 61 | resolve(id: string) { |
| 62 | const parts = id.split(EntryTree.sep) |
| 63 | let tree: EntryTree | undefined = this |
| 64 | const final = parts.pop()! |
| 65 | for (const part of parts) { |
| 66 | tree = tree.store[part]?.subtree |
| 67 | if (!tree) throw new Error(`cannot resolve entry ${id}`) |
| 68 | } |
| 69 | const entry = tree.store[final] |
| 70 | if (!entry) throw new Error(`cannot resolve entry ${id}`) |
| 71 | return entry |
| 72 | } |
| 73 | |
| 74 | resolveGroup(id: string | null) { |
| 75 | if (!id) return this.root |
| 76 | const entry = this.resolve(id) |
| 77 | if (!entry.subgroup) throw new Error(`entry ${id} is not a group`) |
| 78 | return entry.subgroup |
| 79 | } |
| 80 | |
| 81 | /** Create an entry in the root group or a nested group. */ |
| 82 | async create(options: Omit<EntryOptions, 'id'>, parent: string | null = null, position = Infinity) { |
| 83 | const group = this.resolveGroup(parent) |
| 84 | group.data.splice(position, 0, options as EntryOptions) |
| 85 | group.tree.write() |
| 86 | return group.create(options) |
| 87 | } |
| 88 | |
| 89 | /** Stop and remove an entry from its parent group. */ |
| 90 | remove(id: string) { |
| 91 | const entry = this.resolve(id) |
| 92 | entry.parent.remove(id) |
| 93 | entry.parent.tree.write() |
| 94 | } |
| 95 | |
| 96 | /** Update an entry and optionally move it to another group. */ |
| 97 | async update(id: string, options: Omit<EntryOptions, 'id' | 'name'>, parent?: string | null, position?: number) { |
| 98 | const entry = this.resolve(id) |
| 99 | const source = entry.parent |
| 100 | if (parent !== undefined) { |
| 101 | const target = this.resolveGroup(parent) |
| 102 | source.unlink(entry.options) |
| 103 | target.data.splice(position ?? Infinity, 0, entry.options) |
| 104 | target.tree.write() |
| 105 | entry.parent = target |
| 106 | } |
| 107 | source.tree.write() |
| 108 | return entry.update(options, false, true) |
| 109 | } |
| 110 | |
| 111 | /** Import a plugin module from a specifier or `cordis:` builtin. */ |
| 112 | import(name: string, getOuterStack?: () => string[]) { |
| 113 | if (name.startsWith('cordis:')) { |
| 114 | return this.ctx.loader.builtins[name.slice(7)] |
| 115 | } |
| 116 | return composeError(async (info) => { |
| 117 | // ModuleJob.run |
| 118 | // onImport.tracePromise.__proto__ |
| 119 | // internal.import |
| 120 | info.offset += 3 |
| 121 | if (this.ctx.loader.internal) { |
| 122 | return await this.ctx.loader.internal.import(name, this.ctx.baseUrl!, {}) |
| 123 | } else if (name.startsWith('.')) { |
| 124 | return await import(/* @vite-ignore */new URL(name, this.ctx.baseUrl).href) |
| 125 | } else { |
| 126 | return await import(/* @vite-ignore */name) |
| 127 | } |
| 128 | }, getOuterStack) |
| 129 | } |
| 130 | |
| 131 | /** Persist current tree state. In-memory trees may implement this as a no-op. */ |
| 132 | abstract write(): void |
| 133 | } |
| 134 |