@orkestrel/workspace 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +54 -0
- package/dist/src/core/index.cjs +976 -0
- package/dist/src/core/index.cjs.map +1 -0
- package/dist/src/core/index.d.cts +701 -0
- package/dist/src/core/index.d.ts +701 -0
- package/dist/src/core/index.js +946 -0
- package/dist/src/core/index.js.map +1 -0
- package/package.json +86 -0
|
@@ -0,0 +1,946 @@
|
|
|
1
|
+
import { arrayOf, holds, isArray, isNumber, isRecord, isString, literalOf, rawShape, stringShape } from "@orkestrel/contract";
|
|
2
|
+
import { createDatabase, createMemoryDriver } from "@orkestrel/database";
|
|
3
|
+
import { Emitter } from "@orkestrel/emitter";
|
|
4
|
+
//#region src/core/constants.ts
|
|
5
|
+
/**
|
|
6
|
+
* File extensions mapped to language tags for text content.
|
|
7
|
+
*
|
|
8
|
+
* Unknown extensions intentionally fall back to `text` in
|
|
9
|
+
* {@link import('./helpers.js').inferLanguage}.
|
|
10
|
+
*/
|
|
11
|
+
var EXTENSION_LANGUAGES = Object.freeze({
|
|
12
|
+
ts: "typescript",
|
|
13
|
+
tsx: "typescript",
|
|
14
|
+
js: "javascript",
|
|
15
|
+
jsx: "javascript",
|
|
16
|
+
mjs: "javascript",
|
|
17
|
+
cjs: "javascript",
|
|
18
|
+
json: "json",
|
|
19
|
+
md: "markdown",
|
|
20
|
+
html: "html",
|
|
21
|
+
htm: "html",
|
|
22
|
+
css: "css",
|
|
23
|
+
scss: "scss",
|
|
24
|
+
sass: "sass",
|
|
25
|
+
less: "less",
|
|
26
|
+
vue: "vue",
|
|
27
|
+
svelte: "svelte",
|
|
28
|
+
py: "python",
|
|
29
|
+
rb: "ruby",
|
|
30
|
+
rs: "rust",
|
|
31
|
+
go: "go",
|
|
32
|
+
java: "java",
|
|
33
|
+
kt: "kotlin",
|
|
34
|
+
swift: "swift",
|
|
35
|
+
c: "c",
|
|
36
|
+
cpp: "cpp",
|
|
37
|
+
h: "c",
|
|
38
|
+
hpp: "cpp",
|
|
39
|
+
cs: "csharp",
|
|
40
|
+
php: "php",
|
|
41
|
+
sql: "sql",
|
|
42
|
+
sh: "bash",
|
|
43
|
+
bash: "bash",
|
|
44
|
+
zsh: "bash",
|
|
45
|
+
ps1: "powershell",
|
|
46
|
+
yaml: "yaml",
|
|
47
|
+
yml: "yaml",
|
|
48
|
+
toml: "toml",
|
|
49
|
+
xml: "xml",
|
|
50
|
+
svg: "xml",
|
|
51
|
+
txt: "text"
|
|
52
|
+
});
|
|
53
|
+
//#endregion
|
|
54
|
+
//#region src/core/errors.ts
|
|
55
|
+
/** An invalid workspace edit or search operation. */
|
|
56
|
+
var WorkspaceError = class extends Error {
|
|
57
|
+
code;
|
|
58
|
+
context;
|
|
59
|
+
/**
|
|
60
|
+
* Create a workspace error.
|
|
61
|
+
*
|
|
62
|
+
* @param code - The machine-readable failure code
|
|
63
|
+
* @param message - The human-readable failure message
|
|
64
|
+
* @param context - Optional values associated with the failed operation
|
|
65
|
+
*/
|
|
66
|
+
constructor(code, message, context) {
|
|
67
|
+
super(message);
|
|
68
|
+
this.name = "WorkspaceError";
|
|
69
|
+
this.code = code;
|
|
70
|
+
if (context !== void 0) this.context = context;
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
/**
|
|
74
|
+
* Narrow a caught value to a {@link WorkspaceError}.
|
|
75
|
+
*
|
|
76
|
+
* @param value - The caught value
|
|
77
|
+
* @returns Whether the value is a workspace error
|
|
78
|
+
*
|
|
79
|
+
* @example
|
|
80
|
+
* ```ts
|
|
81
|
+
* if (isWorkspaceError(error)) error.code
|
|
82
|
+
* ```
|
|
83
|
+
*/
|
|
84
|
+
function isWorkspaceError(value) {
|
|
85
|
+
return value instanceof WorkspaceError;
|
|
86
|
+
}
|
|
87
|
+
//#endregion
|
|
88
|
+
//#region src/core/helpers.ts
|
|
89
|
+
/**
|
|
90
|
+
* Infer a language tag from the final file extension.
|
|
91
|
+
*
|
|
92
|
+
* @param path - The file path
|
|
93
|
+
* @returns The mapped language, or `text` when the extension is absent or unknown
|
|
94
|
+
*
|
|
95
|
+
* @example
|
|
96
|
+
* ```ts
|
|
97
|
+
* inferLanguage('src/main.ts') // 'typescript'
|
|
98
|
+
* ```
|
|
99
|
+
*/
|
|
100
|
+
function inferLanguage(path) {
|
|
101
|
+
const dot = path.lastIndexOf(".");
|
|
102
|
+
if (dot === -1) return "text";
|
|
103
|
+
return EXTENSION_LANGUAGES[path.slice(dot + 1).toLowerCase()] ?? "text";
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Determine whether content is the text arm.
|
|
107
|
+
*
|
|
108
|
+
* @param content - The file content
|
|
109
|
+
* @returns Whether the content carries text and a language
|
|
110
|
+
*
|
|
111
|
+
* @example
|
|
112
|
+
* ```ts
|
|
113
|
+
* isText({ text: 'hello', language: 'text' }) // true
|
|
114
|
+
* ```
|
|
115
|
+
*/
|
|
116
|
+
function isText(content) {
|
|
117
|
+
return "text" in content;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Determine whether content is the binary arm.
|
|
121
|
+
*
|
|
122
|
+
* @param content - The file content
|
|
123
|
+
* @returns Whether the content carries base64 data and a MIME
|
|
124
|
+
*
|
|
125
|
+
* @example
|
|
126
|
+
* ```ts
|
|
127
|
+
* isBinary({ data: 'AAAA', mime: 'image/png' }) // true
|
|
128
|
+
* ```
|
|
129
|
+
*/
|
|
130
|
+
function isBinary(content) {
|
|
131
|
+
return "data" in content;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Narrow an unknown value to an immutable file record.
|
|
135
|
+
*
|
|
136
|
+
* @param value - The value to inspect
|
|
137
|
+
* @returns Whether the value has the file shape
|
|
138
|
+
*
|
|
139
|
+
* @example
|
|
140
|
+
* ```ts
|
|
141
|
+
* isFile({ path: 'a.txt', content: { text: 'a', language: 'text' }, state: 'created', size: 1, lines: 1 })
|
|
142
|
+
* ```
|
|
143
|
+
*/
|
|
144
|
+
function isFile(value) {
|
|
145
|
+
return holds(() => {
|
|
146
|
+
if (!isRecord(value)) return false;
|
|
147
|
+
if (!isString(value.path) || !literalOf("created", "modified")(value.state)) return false;
|
|
148
|
+
if (!isNumber(value.size) || !isNumber(value.lines)) return false;
|
|
149
|
+
if (!isRecord(value.content)) return false;
|
|
150
|
+
const text = isString(value.content.text) && isString(value.content.language);
|
|
151
|
+
const binary = isString(value.content.data) && literalOf("image/png", "image/jpeg", "image/gif", "image/webp")(value.content.mime);
|
|
152
|
+
return text || binary;
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Narrow an unknown value to a workspace snapshot.
|
|
157
|
+
*
|
|
158
|
+
* @param value - The value read from a persistence boundary
|
|
159
|
+
* @returns Whether the value has the workspace snapshot shape
|
|
160
|
+
*
|
|
161
|
+
* @example
|
|
162
|
+
* ```ts
|
|
163
|
+
* isWorkspaceSnapshot({ id: 'work', files: [] }) // true
|
|
164
|
+
* ```
|
|
165
|
+
*/
|
|
166
|
+
function isWorkspaceSnapshot(value) {
|
|
167
|
+
return holds(() => isRecord(value) && isString(value.id) && arrayOf(isFile)(value.files));
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Compute the byte size of file content.
|
|
171
|
+
*
|
|
172
|
+
* @param content - The content to measure
|
|
173
|
+
* @returns UTF-8 bytes for text or decoded bytes for binary content
|
|
174
|
+
*
|
|
175
|
+
* @example
|
|
176
|
+
* ```ts
|
|
177
|
+
* computeSize({ text: 'café', language: 'text' }) // 5
|
|
178
|
+
* ```
|
|
179
|
+
*/
|
|
180
|
+
function computeSize(content) {
|
|
181
|
+
if (isText(content)) return new TextEncoder().encode(content.text).length;
|
|
182
|
+
return decodedSize(content.data);
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Count the lines in file content.
|
|
186
|
+
*
|
|
187
|
+
* @param content - The content to inspect
|
|
188
|
+
* @returns The text line count, or zero for empty text and binary content
|
|
189
|
+
*
|
|
190
|
+
* @example
|
|
191
|
+
* ```ts
|
|
192
|
+
* countLines({ text: 'a\nb', language: 'text' }) // 2
|
|
193
|
+
* ```
|
|
194
|
+
*/
|
|
195
|
+
function countLines(content) {
|
|
196
|
+
if (!isText(content)) return 0;
|
|
197
|
+
if (content.text.length === 0) return 0;
|
|
198
|
+
let count = 1;
|
|
199
|
+
for (const character of content.text) if (character === "\n") count += 1;
|
|
200
|
+
return count;
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Compute the decoded byte length of a base64 string.
|
|
204
|
+
*
|
|
205
|
+
* @param base64 - The base64 data
|
|
206
|
+
* @returns The decoded byte count
|
|
207
|
+
*
|
|
208
|
+
* @example
|
|
209
|
+
* ```ts
|
|
210
|
+
* decodedSize('AAAA') // 3
|
|
211
|
+
* ```
|
|
212
|
+
*/
|
|
213
|
+
function decodedSize(base64) {
|
|
214
|
+
let padding = 0;
|
|
215
|
+
if (base64.endsWith("==")) padding = 2;
|
|
216
|
+
else if (base64.endsWith("=")) padding = 1;
|
|
217
|
+
return Math.floor(base64.length * 3 / 4) - padding;
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Determine whether a 1-based range is structurally valid.
|
|
221
|
+
*
|
|
222
|
+
* @param range - The range to inspect
|
|
223
|
+
* @returns Whether both positions are positive and ordered
|
|
224
|
+
*
|
|
225
|
+
* @example
|
|
226
|
+
* ```ts
|
|
227
|
+
* isValidRange({ start: { line: 1, column: 1 }, end: { line: 1, column: 2 } }) // true
|
|
228
|
+
* ```
|
|
229
|
+
*/
|
|
230
|
+
function isValidRange(range) {
|
|
231
|
+
if (range.start.line < 1 || range.start.column < 1) return false;
|
|
232
|
+
if (range.end.line < 1 || range.end.column < 1) return false;
|
|
233
|
+
if (range.start.line > range.end.line) return false;
|
|
234
|
+
return !(range.start.line === range.end.line && range.start.column > range.end.column);
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Clamp a position to text bounds.
|
|
238
|
+
*
|
|
239
|
+
* @param text - The addressed text
|
|
240
|
+
* @param position - The requested position
|
|
241
|
+
* @returns The clamped position
|
|
242
|
+
*
|
|
243
|
+
* @example
|
|
244
|
+
* ```ts
|
|
245
|
+
* clampPosition('ab', { line: 9, column: 9 }) // { line: 1, column: 3 }
|
|
246
|
+
* ```
|
|
247
|
+
*/
|
|
248
|
+
function clampPosition(text, position) {
|
|
249
|
+
const lines = text.split("\n");
|
|
250
|
+
const line = Math.max(1, Math.min(position.line, lines.length));
|
|
251
|
+
const lineText = lines[line - 1] ?? "";
|
|
252
|
+
return {
|
|
253
|
+
line,
|
|
254
|
+
column: Math.max(1, Math.min(position.column, lineText.length + 1))
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Clamp both positions in a range to text bounds.
|
|
259
|
+
*
|
|
260
|
+
* @param text - The addressed text
|
|
261
|
+
* @param range - The requested range
|
|
262
|
+
* @returns The clamped range
|
|
263
|
+
*
|
|
264
|
+
* @example
|
|
265
|
+
* ```ts
|
|
266
|
+
* clampRange('ab', { start: { line: 1, column: 1 }, end: { line: 9, column: 9 } })
|
|
267
|
+
* ```
|
|
268
|
+
*/
|
|
269
|
+
function clampRange(text, range) {
|
|
270
|
+
return {
|
|
271
|
+
start: clampPosition(text, range.start),
|
|
272
|
+
end: clampPosition(text, range.end)
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Convert a 1-based position to a zero-based string offset.
|
|
277
|
+
*
|
|
278
|
+
* @param text - The addressed text
|
|
279
|
+
* @param position - The requested position
|
|
280
|
+
* @returns The bounded string offset
|
|
281
|
+
*
|
|
282
|
+
* @example
|
|
283
|
+
* ```ts
|
|
284
|
+
* offsetAt('ab\ncd', { line: 2, column: 1 }) // 3
|
|
285
|
+
* ```
|
|
286
|
+
*/
|
|
287
|
+
function offsetAt(text, position) {
|
|
288
|
+
const lines = text.split("\n");
|
|
289
|
+
let offset = 0;
|
|
290
|
+
for (let index = 0; index < position.line - 1 && index < lines.length; index += 1) offset += (lines[index]?.length ?? 0) + 1;
|
|
291
|
+
offset += position.column - 1;
|
|
292
|
+
return Math.min(offset, text.length);
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Slice a clamped half-open text range.
|
|
296
|
+
*
|
|
297
|
+
* @param text - The source text
|
|
298
|
+
* @param range - The requested range
|
|
299
|
+
* @returns The ranged substring
|
|
300
|
+
*
|
|
301
|
+
* @example
|
|
302
|
+
* ```ts
|
|
303
|
+
* sliceRange('hello', { start: { line: 1, column: 1 }, end: { line: 1, column: 6 } }) // 'hello'
|
|
304
|
+
* ```
|
|
305
|
+
*/
|
|
306
|
+
function sliceRange(text, range) {
|
|
307
|
+
const clamped = clampRange(text, range);
|
|
308
|
+
return text.slice(offsetAt(text, clamped.start), offsetAt(text, clamped.end));
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Replace a clamped half-open text range.
|
|
312
|
+
*
|
|
313
|
+
* @param text - The source text
|
|
314
|
+
* @param range - The requested range
|
|
315
|
+
* @param replacement - The replacement text
|
|
316
|
+
* @returns The spliced text
|
|
317
|
+
*
|
|
318
|
+
* @example
|
|
319
|
+
* ```ts
|
|
320
|
+
* spliceRange('hello', { start: { line: 1, column: 1 }, end: { line: 1, column: 6 } }, 'bye')
|
|
321
|
+
* ```
|
|
322
|
+
*/
|
|
323
|
+
function spliceRange(text, range, replacement) {
|
|
324
|
+
const clamped = clampRange(text, range);
|
|
325
|
+
const start = offsetAt(text, clamped.start);
|
|
326
|
+
const end = offsetAt(text, clamped.end);
|
|
327
|
+
return text.slice(0, start) + replacement + text.slice(end);
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* Assemble a nested range from four 1-based coordinates.
|
|
331
|
+
*
|
|
332
|
+
* @param fromLine - The start line
|
|
333
|
+
* @param fromColumn - The start column
|
|
334
|
+
* @param toLine - The end line
|
|
335
|
+
* @param toColumn - The end column
|
|
336
|
+
* @returns The assembled range without validation
|
|
337
|
+
*
|
|
338
|
+
* @example
|
|
339
|
+
* ```ts
|
|
340
|
+
* rangeOf(1, 1, 1, 2)
|
|
341
|
+
* ```
|
|
342
|
+
*/
|
|
343
|
+
function rangeOf(fromLine, fromColumn, toLine, toColumn) {
|
|
344
|
+
return {
|
|
345
|
+
start: {
|
|
346
|
+
line: fromLine,
|
|
347
|
+
column: fromColumn
|
|
348
|
+
},
|
|
349
|
+
end: {
|
|
350
|
+
line: toLine,
|
|
351
|
+
column: toColumn
|
|
352
|
+
}
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Escape regular-expression metacharacters for literal matching.
|
|
357
|
+
*
|
|
358
|
+
* @param value - The literal text
|
|
359
|
+
* @returns The escaped regular-expression source
|
|
360
|
+
*
|
|
361
|
+
* @example
|
|
362
|
+
* ```ts
|
|
363
|
+
* escapeRegExp('a.b') // 'a\\.b'
|
|
364
|
+
* ```
|
|
365
|
+
*/
|
|
366
|
+
function escapeRegExp(value) {
|
|
367
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
368
|
+
}
|
|
369
|
+
//#endregion
|
|
370
|
+
//#region src/core/workspaces/Workspace.ts
|
|
371
|
+
/**
|
|
372
|
+
* A mutable path-keyed editing surface over immutable files.
|
|
373
|
+
*
|
|
374
|
+
* Whole-file edits create text files, ranged edits operate only on existing text files, and
|
|
375
|
+
* binary files remain available through construction-time hydration. Mutations emit after the
|
|
376
|
+
* registry has changed.
|
|
377
|
+
*
|
|
378
|
+
* @example
|
|
379
|
+
* ```ts
|
|
380
|
+
* import { Workspace } from '@orkestrel/workspace'
|
|
381
|
+
*
|
|
382
|
+
* const workspace = new Workspace()
|
|
383
|
+
* workspace.write('notes.txt', 'hello')
|
|
384
|
+
* workspace.append('notes.txt', ' world')
|
|
385
|
+
* workspace.read('notes.txt') // 'hello world'
|
|
386
|
+
* ```
|
|
387
|
+
*/
|
|
388
|
+
var Workspace = class {
|
|
389
|
+
#id;
|
|
390
|
+
#files = /* @__PURE__ */ new Map();
|
|
391
|
+
#emitter;
|
|
392
|
+
/**
|
|
393
|
+
* Create a workspace.
|
|
394
|
+
*
|
|
395
|
+
* @param options - Optional identity, emitter configuration, and initial files
|
|
396
|
+
*/
|
|
397
|
+
constructor(options) {
|
|
398
|
+
this.#id = options?.id ?? crypto.randomUUID();
|
|
399
|
+
this.#emitter = new Emitter({
|
|
400
|
+
...options?.on === void 0 ? {} : { on: options.on },
|
|
401
|
+
...options?.error === void 0 ? {} : { error: options.error }
|
|
402
|
+
});
|
|
403
|
+
if (options?.seed) for (const file of options.seed) this.#files.set(file.path, file);
|
|
404
|
+
}
|
|
405
|
+
get id() {
|
|
406
|
+
return this.#id;
|
|
407
|
+
}
|
|
408
|
+
get emitter() {
|
|
409
|
+
return this.#emitter;
|
|
410
|
+
}
|
|
411
|
+
get count() {
|
|
412
|
+
return this.#files.size;
|
|
413
|
+
}
|
|
414
|
+
file(path) {
|
|
415
|
+
return this.#files.get(path);
|
|
416
|
+
}
|
|
417
|
+
files() {
|
|
418
|
+
return [...this.#files.values()];
|
|
419
|
+
}
|
|
420
|
+
read(path, range) {
|
|
421
|
+
if (isArray(path)) {
|
|
422
|
+
const result = {};
|
|
423
|
+
for (const one of path) {
|
|
424
|
+
const file = this.#files.get(one);
|
|
425
|
+
if (file && isText(file.content)) result[one] = file.content.text;
|
|
426
|
+
}
|
|
427
|
+
return result;
|
|
428
|
+
}
|
|
429
|
+
const file = this.#files.get(path);
|
|
430
|
+
if (!file) return void 0;
|
|
431
|
+
if (!range) return isText(file.content) ? file.content.text : void 0;
|
|
432
|
+
if (!isText(file.content)) throw new WorkspaceError("MODALITY", `Cannot read a range of a binary file: ${path}`, { path });
|
|
433
|
+
return {
|
|
434
|
+
content: sliceRange(file.content.text, range),
|
|
435
|
+
range: clampRange(file.content.text, range)
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
has(path) {
|
|
439
|
+
if (isArray(path)) return path.some((one) => this.#files.has(one));
|
|
440
|
+
return this.#files.has(path);
|
|
441
|
+
}
|
|
442
|
+
search(query, options) {
|
|
443
|
+
const pattern = this.#pattern(query, options);
|
|
444
|
+
const limit = options?.limit;
|
|
445
|
+
const matches = [];
|
|
446
|
+
for (const file of this.#files.values()) {
|
|
447
|
+
if (limit !== void 0 && matches.length >= limit) break;
|
|
448
|
+
if (!isText(file.content)) continue;
|
|
449
|
+
const lines = file.content.text.split("\n");
|
|
450
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
451
|
+
if (limit !== void 0 && matches.length >= limit) break;
|
|
452
|
+
const lineText = lines[index] ?? "";
|
|
453
|
+
pattern.lastIndex = 0;
|
|
454
|
+
let hit = pattern.exec(lineText);
|
|
455
|
+
while (hit !== null) {
|
|
456
|
+
if (limit !== void 0 && matches.length >= limit) break;
|
|
457
|
+
matches.push({
|
|
458
|
+
path: file.path,
|
|
459
|
+
line: index + 1,
|
|
460
|
+
column: hit.index + 1,
|
|
461
|
+
length: hit[0].length,
|
|
462
|
+
content: lineText
|
|
463
|
+
});
|
|
464
|
+
if (hit[0].length === 0) pattern.lastIndex += 1;
|
|
465
|
+
hit = pattern.exec(lineText);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
return matches;
|
|
470
|
+
}
|
|
471
|
+
replace(query, replacement, options) {
|
|
472
|
+
const pattern = this.#pattern(query, options);
|
|
473
|
+
const limit = options?.limit;
|
|
474
|
+
let occurrences = 0;
|
|
475
|
+
let files = 0;
|
|
476
|
+
for (const [path, file] of this.#files) {
|
|
477
|
+
if (limit !== void 0 && occurrences >= limit) break;
|
|
478
|
+
if (!isText(file.content)) continue;
|
|
479
|
+
const remaining = limit === void 0 ? void 0 : limit - occurrences;
|
|
480
|
+
let count = 0;
|
|
481
|
+
pattern.lastIndex = 0;
|
|
482
|
+
const next = file.content.text.replace(pattern, (match) => {
|
|
483
|
+
if (remaining !== void 0 && count >= remaining) return match;
|
|
484
|
+
count += 1;
|
|
485
|
+
return replacement;
|
|
486
|
+
});
|
|
487
|
+
if (count > 0) {
|
|
488
|
+
occurrences += count;
|
|
489
|
+
files += 1;
|
|
490
|
+
this.write(path, next);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
return {
|
|
494
|
+
occurrences,
|
|
495
|
+
files
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
write(path, content, range) {
|
|
499
|
+
if (isRecord(path)) {
|
|
500
|
+
for (const [one, text] of Object.entries(path)) this.#write(one, text);
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
const text = content ?? "";
|
|
504
|
+
if (!range) {
|
|
505
|
+
this.#write(path, text);
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
this.#splice(path, text, range);
|
|
509
|
+
}
|
|
510
|
+
prepend(path, content) {
|
|
511
|
+
if (isRecord(path)) {
|
|
512
|
+
for (const [one, text] of Object.entries(path)) this.#prepend(one, text);
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
this.#prepend(path, content ?? "");
|
|
516
|
+
}
|
|
517
|
+
append(path, content) {
|
|
518
|
+
if (isRecord(path)) {
|
|
519
|
+
for (const [one, text] of Object.entries(path)) this.#append(one, text);
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
this.#append(path, content ?? "");
|
|
523
|
+
}
|
|
524
|
+
move(from, to) {
|
|
525
|
+
if (isRecord(from)) {
|
|
526
|
+
let moved = false;
|
|
527
|
+
for (const [one, target] of Object.entries(from)) if (this.#move(one, target)) moved = true;
|
|
528
|
+
return moved;
|
|
529
|
+
}
|
|
530
|
+
return this.#move(from, to ?? "");
|
|
531
|
+
}
|
|
532
|
+
remove(path) {
|
|
533
|
+
if (isArray(path)) {
|
|
534
|
+
let removed = false;
|
|
535
|
+
for (const one of path) if (this.#remove(one)) removed = true;
|
|
536
|
+
return removed;
|
|
537
|
+
}
|
|
538
|
+
return this.#remove(path);
|
|
539
|
+
}
|
|
540
|
+
clear() {
|
|
541
|
+
this.#files.clear();
|
|
542
|
+
this.#emitter.emit("clear");
|
|
543
|
+
}
|
|
544
|
+
snapshot() {
|
|
545
|
+
return {
|
|
546
|
+
id: this.#id,
|
|
547
|
+
files: this.files()
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
destroy() {
|
|
551
|
+
this.#emitter.destroy();
|
|
552
|
+
}
|
|
553
|
+
#write(path, content) {
|
|
554
|
+
const existing = this.#files.get(path);
|
|
555
|
+
const file = createFile({
|
|
556
|
+
path,
|
|
557
|
+
content: {
|
|
558
|
+
text: content,
|
|
559
|
+
language: existing && isText(existing.content) ? existing.content.language : inferLanguage(path)
|
|
560
|
+
},
|
|
561
|
+
state: existing ? "modified" : "created"
|
|
562
|
+
});
|
|
563
|
+
this.#files.set(path, file);
|
|
564
|
+
this.#emitter.emit("write", file);
|
|
565
|
+
}
|
|
566
|
+
#splice(path, content, range) {
|
|
567
|
+
const existing = this.#files.get(path);
|
|
568
|
+
if (!existing) throw new WorkspaceError("MODALITY", `Cannot splice a range of a missing file: ${path}`, { path });
|
|
569
|
+
if (!isText(existing.content)) throw new WorkspaceError("MODALITY", `Cannot splice a range of a binary file: ${path}`, { path });
|
|
570
|
+
if (!isValidRange(range)) throw new WorkspaceError("RANGE", `Invalid range for file: ${path}`, {
|
|
571
|
+
path,
|
|
572
|
+
range
|
|
573
|
+
});
|
|
574
|
+
const file = createFile({
|
|
575
|
+
path,
|
|
576
|
+
content: {
|
|
577
|
+
text: spliceRange(existing.content.text, range, content),
|
|
578
|
+
language: existing.content.language
|
|
579
|
+
},
|
|
580
|
+
state: "modified"
|
|
581
|
+
});
|
|
582
|
+
this.#files.set(path, file);
|
|
583
|
+
this.#emitter.emit("write", file);
|
|
584
|
+
}
|
|
585
|
+
#prepend(path, content) {
|
|
586
|
+
const existing = this.#files.get(path);
|
|
587
|
+
let text = "";
|
|
588
|
+
if (existing) {
|
|
589
|
+
if (!isText(existing.content)) throw new WorkspaceError("MODALITY", `Cannot prepend text to a binary file: ${path}`, { path });
|
|
590
|
+
text = existing.content.text;
|
|
591
|
+
}
|
|
592
|
+
this.#write(path, content + text);
|
|
593
|
+
}
|
|
594
|
+
#append(path, content) {
|
|
595
|
+
const existing = this.#files.get(path);
|
|
596
|
+
let text = "";
|
|
597
|
+
if (existing) {
|
|
598
|
+
if (!isText(existing.content)) throw new WorkspaceError("MODALITY", `Cannot append text to a binary file: ${path}`, { path });
|
|
599
|
+
text = existing.content.text;
|
|
600
|
+
}
|
|
601
|
+
this.#write(path, text + content);
|
|
602
|
+
}
|
|
603
|
+
#move(from, to) {
|
|
604
|
+
if (from === to) return false;
|
|
605
|
+
const file = this.#files.get(from);
|
|
606
|
+
if (!file) return false;
|
|
607
|
+
const moved = createFile({
|
|
608
|
+
path: to,
|
|
609
|
+
content: file.content,
|
|
610
|
+
state: "modified"
|
|
611
|
+
});
|
|
612
|
+
const entries = [...this.#files];
|
|
613
|
+
this.#files.clear();
|
|
614
|
+
for (const [path, current] of entries) if (path === from) this.#files.set(to, moved);
|
|
615
|
+
else if (path !== to) this.#files.set(path, current);
|
|
616
|
+
this.#emitter.emit("move", from, to);
|
|
617
|
+
return true;
|
|
618
|
+
}
|
|
619
|
+
#remove(path) {
|
|
620
|
+
const removed = this.#files.delete(path);
|
|
621
|
+
if (removed) this.#emitter.emit("remove", path);
|
|
622
|
+
return removed;
|
|
623
|
+
}
|
|
624
|
+
#pattern(query, options) {
|
|
625
|
+
const source = options?.regex === true ? query : escapeRegExp(query);
|
|
626
|
+
const flags = options?.sensitive === false ? "gi" : "g";
|
|
627
|
+
try {
|
|
628
|
+
return new RegExp(source, flags);
|
|
629
|
+
} catch {
|
|
630
|
+
throw new WorkspaceError("PATTERN", `Invalid search pattern: ${query}`, { query });
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
};
|
|
634
|
+
//#endregion
|
|
635
|
+
//#region src/core/workspaces/WorkspaceManager.ts
|
|
636
|
+
/**
|
|
637
|
+
* An insertion-ordered workspace registry with an active selection.
|
|
638
|
+
*
|
|
639
|
+
* A supplied store adds lenient snapshot `open` and `save` operations. Event defaults flow into
|
|
640
|
+
* workspaces created through the registry, while observability remains owned by each workspace.
|
|
641
|
+
*
|
|
642
|
+
* @example
|
|
643
|
+
* ```ts
|
|
644
|
+
* import { WorkspaceManager } from '@orkestrel/workspace'
|
|
645
|
+
*
|
|
646
|
+
* const manager = new WorkspaceManager()
|
|
647
|
+
* const workspace = manager.add({ id: 'scratch' })
|
|
648
|
+
* manager.active === workspace
|
|
649
|
+
* ```
|
|
650
|
+
*/
|
|
651
|
+
var WorkspaceManager = class {
|
|
652
|
+
#workspaces = /* @__PURE__ */ new Map();
|
|
653
|
+
#active;
|
|
654
|
+
#on;
|
|
655
|
+
#error;
|
|
656
|
+
#store;
|
|
657
|
+
/**
|
|
658
|
+
* Create a registry.
|
|
659
|
+
*
|
|
660
|
+
* @param options - Default event hooks and optional snapshot store
|
|
661
|
+
*/
|
|
662
|
+
constructor(options) {
|
|
663
|
+
this.#on = options?.on;
|
|
664
|
+
this.#error = options?.error;
|
|
665
|
+
this.#store = options?.store;
|
|
666
|
+
}
|
|
667
|
+
get count() {
|
|
668
|
+
return this.#workspaces.size;
|
|
669
|
+
}
|
|
670
|
+
get active() {
|
|
671
|
+
return this.#active === void 0 ? void 0 : this.#workspaces.get(this.#active);
|
|
672
|
+
}
|
|
673
|
+
workspace(id) {
|
|
674
|
+
return this.#workspaces.get(id);
|
|
675
|
+
}
|
|
676
|
+
workspaces() {
|
|
677
|
+
return [...this.#workspaces.values()];
|
|
678
|
+
}
|
|
679
|
+
add(options) {
|
|
680
|
+
const on = options?.on ?? this.#on;
|
|
681
|
+
const error = options?.error ?? this.#error;
|
|
682
|
+
const workspace = new Workspace({
|
|
683
|
+
...options?.id === void 0 ? {} : { id: options.id },
|
|
684
|
+
...on === void 0 ? {} : { on },
|
|
685
|
+
...error === void 0 ? {} : { error },
|
|
686
|
+
...options?.seed === void 0 ? {} : { seed: options.seed }
|
|
687
|
+
});
|
|
688
|
+
this.#workspaces.set(workspace.id, workspace);
|
|
689
|
+
if (this.#active === void 0) this.#active = workspace.id;
|
|
690
|
+
return workspace;
|
|
691
|
+
}
|
|
692
|
+
switch(id) {
|
|
693
|
+
const workspace = this.#workspaces.get(id);
|
|
694
|
+
if (workspace === void 0) return void 0;
|
|
695
|
+
this.#active = id;
|
|
696
|
+
return workspace;
|
|
697
|
+
}
|
|
698
|
+
async open(id) {
|
|
699
|
+
const existing = this.#workspaces.get(id);
|
|
700
|
+
if (existing !== void 0) {
|
|
701
|
+
this.#active = id;
|
|
702
|
+
return existing;
|
|
703
|
+
}
|
|
704
|
+
if (this.#store === void 0) return void 0;
|
|
705
|
+
const snapshot = await this.#store.get(id);
|
|
706
|
+
if (snapshot === void 0) return void 0;
|
|
707
|
+
const workspace = this.add({
|
|
708
|
+
id,
|
|
709
|
+
seed: snapshot.files
|
|
710
|
+
});
|
|
711
|
+
this.#active = workspace.id;
|
|
712
|
+
return workspace;
|
|
713
|
+
}
|
|
714
|
+
async save(id) {
|
|
715
|
+
const workspace = this.#workspaces.get(id);
|
|
716
|
+
if (this.#store === void 0 || workspace === void 0) return false;
|
|
717
|
+
await this.#store.set(workspace.snapshot());
|
|
718
|
+
return true;
|
|
719
|
+
}
|
|
720
|
+
remove(ids) {
|
|
721
|
+
if (isArray(ids)) {
|
|
722
|
+
let removed = false;
|
|
723
|
+
for (const id of ids) if (this.#drop(id)) removed = true;
|
|
724
|
+
return removed;
|
|
725
|
+
}
|
|
726
|
+
return this.#drop(ids);
|
|
727
|
+
}
|
|
728
|
+
clear() {
|
|
729
|
+
for (const workspace of this.#workspaces.values()) workspace.destroy();
|
|
730
|
+
this.#workspaces.clear();
|
|
731
|
+
this.#active = void 0;
|
|
732
|
+
}
|
|
733
|
+
#drop(id) {
|
|
734
|
+
const workspace = this.#workspaces.get(id);
|
|
735
|
+
if (workspace === void 0) return false;
|
|
736
|
+
this.#workspaces.delete(id);
|
|
737
|
+
workspace.destroy();
|
|
738
|
+
if (this.#active === id) this.#active = void 0;
|
|
739
|
+
return true;
|
|
740
|
+
}
|
|
741
|
+
};
|
|
742
|
+
//#endregion
|
|
743
|
+
//#region src/core/workspaces/stores/DatabaseWorkspaceStore.ts
|
|
744
|
+
/**
|
|
745
|
+
* A workspace snapshot store over a database table.
|
|
746
|
+
*
|
|
747
|
+
* Snapshots occupy one opaque column and are narrowed when read back from the storage boundary.
|
|
748
|
+
*
|
|
749
|
+
* @example
|
|
750
|
+
* ```ts
|
|
751
|
+
* import { createDatabaseWorkspaceStore } from '@orkestrel/workspace'
|
|
752
|
+
*
|
|
753
|
+
* const store = createDatabaseWorkspaceStore()
|
|
754
|
+
* ```
|
|
755
|
+
*/
|
|
756
|
+
var DatabaseWorkspaceStore = class {
|
|
757
|
+
#table;
|
|
758
|
+
/**
|
|
759
|
+
* Wrap a workspace snapshot table.
|
|
760
|
+
*
|
|
761
|
+
* @param table - The table keyed by workspace identifier
|
|
762
|
+
*/
|
|
763
|
+
constructor(table) {
|
|
764
|
+
this.#table = table;
|
|
765
|
+
}
|
|
766
|
+
async get(id) {
|
|
767
|
+
const row = await this.#table.get(id);
|
|
768
|
+
if (row === void 0) return void 0;
|
|
769
|
+
return isWorkspaceSnapshot(row.snapshot) ? row.snapshot : void 0;
|
|
770
|
+
}
|
|
771
|
+
async set(snapshot) {
|
|
772
|
+
await this.#table.set({
|
|
773
|
+
id: snapshot.id,
|
|
774
|
+
snapshot
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
async delete(id) {
|
|
778
|
+
await this.#table.remove(id);
|
|
779
|
+
}
|
|
780
|
+
};
|
|
781
|
+
//#endregion
|
|
782
|
+
//#region src/core/workspaces/stores/MemoryWorkspaceStore.ts
|
|
783
|
+
/**
|
|
784
|
+
* A process-local workspace snapshot store.
|
|
785
|
+
*
|
|
786
|
+
* @example
|
|
787
|
+
* ```ts
|
|
788
|
+
* import { MemoryWorkspaceStore } from '@orkestrel/workspace'
|
|
789
|
+
*
|
|
790
|
+
* const store = new MemoryWorkspaceStore()
|
|
791
|
+
* ```
|
|
792
|
+
*/
|
|
793
|
+
var MemoryWorkspaceStore = class {
|
|
794
|
+
#snapshots = /* @__PURE__ */ new Map();
|
|
795
|
+
get(id) {
|
|
796
|
+
return Promise.resolve(this.#snapshots.get(id));
|
|
797
|
+
}
|
|
798
|
+
set(snapshot) {
|
|
799
|
+
this.#snapshots.set(snapshot.id, snapshot);
|
|
800
|
+
return Promise.resolve();
|
|
801
|
+
}
|
|
802
|
+
delete(id) {
|
|
803
|
+
this.#snapshots.delete(id);
|
|
804
|
+
return Promise.resolve();
|
|
805
|
+
}
|
|
806
|
+
};
|
|
807
|
+
//#endregion
|
|
808
|
+
//#region src/core/factories.ts
|
|
809
|
+
/**
|
|
810
|
+
* Create an immutable file with derived size and line counts.
|
|
811
|
+
*
|
|
812
|
+
* @param input - The file path, content, and optional state
|
|
813
|
+
* @returns A frozen file record
|
|
814
|
+
*
|
|
815
|
+
* @example
|
|
816
|
+
* ```ts
|
|
817
|
+
* import { createFile, createTextContent } from '@orkestrel/workspace'
|
|
818
|
+
*
|
|
819
|
+
* createFile({ path: 'a.txt', content: createTextContent('hello', 'text') })
|
|
820
|
+
* ```
|
|
821
|
+
*/
|
|
822
|
+
function createFile(input) {
|
|
823
|
+
return Object.freeze({
|
|
824
|
+
path: input.path,
|
|
825
|
+
content: input.content,
|
|
826
|
+
state: input.state ?? "created",
|
|
827
|
+
size: computeSize(input.content),
|
|
828
|
+
lines: countLines(input.content)
|
|
829
|
+
});
|
|
830
|
+
}
|
|
831
|
+
/**
|
|
832
|
+
* Create the text arm of {@link FileContent}.
|
|
833
|
+
*
|
|
834
|
+
* @param text - The text body
|
|
835
|
+
* @param language - The language tag
|
|
836
|
+
* @returns Text file content
|
|
837
|
+
*
|
|
838
|
+
* @example
|
|
839
|
+
* ```ts
|
|
840
|
+
* import { createTextContent } from '@orkestrel/workspace'
|
|
841
|
+
*
|
|
842
|
+
* createTextContent('hello', 'text')
|
|
843
|
+
* ```
|
|
844
|
+
*/
|
|
845
|
+
function createTextContent(text, language) {
|
|
846
|
+
return {
|
|
847
|
+
text,
|
|
848
|
+
language
|
|
849
|
+
};
|
|
850
|
+
}
|
|
851
|
+
/**
|
|
852
|
+
* Create the binary arm of {@link FileContent}.
|
|
853
|
+
*
|
|
854
|
+
* @param data - The base64 payload
|
|
855
|
+
* @param mime - The binary MIME
|
|
856
|
+
* @returns Binary file content
|
|
857
|
+
*
|
|
858
|
+
* @example
|
|
859
|
+
* ```ts
|
|
860
|
+
* import { createBinaryContent } from '@orkestrel/workspace'
|
|
861
|
+
*
|
|
862
|
+
* createBinaryContent('AAAA', 'image/png')
|
|
863
|
+
* ```
|
|
864
|
+
*/
|
|
865
|
+
function createBinaryContent(data, mime) {
|
|
866
|
+
return {
|
|
867
|
+
data,
|
|
868
|
+
mime
|
|
869
|
+
};
|
|
870
|
+
}
|
|
871
|
+
/**
|
|
872
|
+
* Create a workspace.
|
|
873
|
+
*
|
|
874
|
+
* @param options - Optional identity, emitter configuration, and initial files
|
|
875
|
+
* @returns A working workspace
|
|
876
|
+
*
|
|
877
|
+
* @example
|
|
878
|
+
* ```ts
|
|
879
|
+
* import { createWorkspace } from '@orkestrel/workspace'
|
|
880
|
+
*
|
|
881
|
+
* const workspace = createWorkspace()
|
|
882
|
+
* workspace.write('a.txt', 'hello')
|
|
883
|
+
* ```
|
|
884
|
+
*/
|
|
885
|
+
function createWorkspace(options) {
|
|
886
|
+
return new Workspace(options);
|
|
887
|
+
}
|
|
888
|
+
/**
|
|
889
|
+
* Create an in-memory workspace snapshot store.
|
|
890
|
+
*
|
|
891
|
+
* @returns A process-local workspace store
|
|
892
|
+
*
|
|
893
|
+
* @example
|
|
894
|
+
* ```ts
|
|
895
|
+
* import { createMemoryWorkspaceStore } from '@orkestrel/workspace'
|
|
896
|
+
*
|
|
897
|
+
* const store = createMemoryWorkspaceStore()
|
|
898
|
+
* ```
|
|
899
|
+
*/
|
|
900
|
+
function createMemoryWorkspaceStore() {
|
|
901
|
+
return new MemoryWorkspaceStore();
|
|
902
|
+
}
|
|
903
|
+
/**
|
|
904
|
+
* Create a database-backed workspace snapshot store.
|
|
905
|
+
*
|
|
906
|
+
* @param driver - The database driver; defaults to an in-memory driver
|
|
907
|
+
* @returns A workspace store backed by the supplied driver
|
|
908
|
+
*
|
|
909
|
+
* @example
|
|
910
|
+
* ```ts
|
|
911
|
+
* import { createDatabaseWorkspaceStore } from '@orkestrel/workspace'
|
|
912
|
+
*
|
|
913
|
+
* const store = createDatabaseWorkspaceStore()
|
|
914
|
+
* ```
|
|
915
|
+
*/
|
|
916
|
+
function createDatabaseWorkspaceStore(driver = createMemoryDriver()) {
|
|
917
|
+
const columns = {
|
|
918
|
+
id: stringShape(),
|
|
919
|
+
snapshot: rawShape({})
|
|
920
|
+
};
|
|
921
|
+
return new DatabaseWorkspaceStore(createDatabase({
|
|
922
|
+
driver,
|
|
923
|
+
tables: { workspaces: columns }
|
|
924
|
+
}).table("workspaces"));
|
|
925
|
+
}
|
|
926
|
+
/**
|
|
927
|
+
* Create an empty workspace registry.
|
|
928
|
+
*
|
|
929
|
+
* @param options - Default event hooks and optional durability
|
|
930
|
+
* @returns A workspace manager
|
|
931
|
+
*
|
|
932
|
+
* @example
|
|
933
|
+
* ```ts
|
|
934
|
+
* import { createWorkspaceManager } from '@orkestrel/workspace'
|
|
935
|
+
*
|
|
936
|
+
* const manager = createWorkspaceManager()
|
|
937
|
+
* manager.add()
|
|
938
|
+
* ```
|
|
939
|
+
*/
|
|
940
|
+
function createWorkspaceManager(options) {
|
|
941
|
+
return new WorkspaceManager(options);
|
|
942
|
+
}
|
|
943
|
+
//#endregion
|
|
944
|
+
export { DatabaseWorkspaceStore, EXTENSION_LANGUAGES, MemoryWorkspaceStore, Workspace, WorkspaceError, WorkspaceManager, clampPosition, clampRange, computeSize, countLines, createBinaryContent, createDatabaseWorkspaceStore, createFile, createMemoryWorkspaceStore, createTextContent, createWorkspace, createWorkspaceManager, decodedSize, escapeRegExp, inferLanguage, isBinary, isFile, isText, isValidRange, isWorkspaceError, isWorkspaceSnapshot, offsetAt, rangeOf, sliceRange, spliceRange };
|
|
945
|
+
|
|
946
|
+
//# sourceMappingURL=index.js.map
|