@eum-agent/file-explorer 1.0.0 → 1.1.0

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.
Files changed (4) hide show
  1. package/dist/index.js +181 -121
  2. package/dist/schema.js +70 -1
  3. package/package.json +5 -5
  4. package/ui/ui.js +393 -293
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { AccessErrorSchema, FileBytesParamsSchema, FileEntrySchema, FileTargetSchema, ListParamsSchema, ListResultSchema, MkdirParamsSchema, MoveParamsSchema, OperationResultSchema, OperationStatusParamsSchema, PathSchema, RemoveParamsSchema, RenameParamsSchema, RootsParamsSchema, RootsResultSchema, SegmentSchema, StatParamsSchema, StatResultSchema } from "./schema.js";
1
+ import { AccessErrorSchema, FileBytesParamsSchema, FileEntrySchema, FileTargetSchema, FilesChangedSchema, ListParamsSchema, ListResultSchema, MkdirParamsSchema, MoveFilesParamsSchema, MoveFilesResultSchema, OperationResultSchema, OperationStatusesParamsSchema, OperationStatusesResultSchema, PathSchema, RemoveFilesParamsSchema, RemoveFilesResultSchema, RenameParamsSchema, RootsParamsSchema, RootsResultSchema, SegmentSchema, StatsParamsSchema, StatsResultSchema } from "./schema.js";
2
2
  import { existsSync } from "node:fs";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { randomUUID } from "node:crypto";
@@ -50,6 +50,143 @@ function accessError(error) {
50
50
  return new FileAccessError(business in status ? business : "io-failed", status[business] ?? 500);
51
51
  }
52
52
  //#endregion
53
+ //#region src/batches.ts
54
+ const encoded = (value) => new TextEncoder().encode(JSON.stringify(value)).byteLength;
55
+ /** Runs independent members with owner cancellation and complete serialized-member limits. */
56
+ async function fileCollection(items, signal, work, maxItemBytes = Math.floor(8380416 / items.length)) {
57
+ const results = [];
58
+ for (const [index, target] of items.entries()) {
59
+ signal.throwIfAborted();
60
+ try {
61
+ const value = await work(target);
62
+ signal.throwIfAborted();
63
+ const member = {
64
+ index,
65
+ target,
66
+ ok: true,
67
+ value
68
+ };
69
+ if (encoded(member) > maxItemBytes) throw new FileAccessError("read-limit", 413);
70
+ results.push(member);
71
+ } catch (error) {
72
+ signal.throwIfAborted();
73
+ const failure = accessError(error);
74
+ results.push({
75
+ index,
76
+ target,
77
+ ok: false,
78
+ error: {
79
+ code: failure.code,
80
+ message: failure.message
81
+ }
82
+ });
83
+ }
84
+ }
85
+ return { results };
86
+ }
87
+ //#endregion
88
+ //#region src/listings.ts
89
+ /** Owner-local bounded immutable directory snapshots and opaque page cursors. */
90
+ /** Keeps at most eight sixty-second snapshots; every page still revalidates current scope. */
91
+ var Listings = class {
92
+ scope;
93
+ now;
94
+ snapshots = /* @__PURE__ */ new Map();
95
+ /** Shares the provider's scope and clock without retaining host descriptors. */
96
+ constructor(scope, now) {
97
+ this.scope = scope;
98
+ this.now = now;
99
+ }
100
+ /** Returns a 200-entry page of one immutable generation or refuses an expired/cross-target cursor. */
101
+ async list(params, signal) {
102
+ const selection = await this.scope.select(params.target, signal);
103
+ try {
104
+ const directory = selection.name ? await selection.parent.openDirectory([selection.name]) : selection.parent;
105
+ const current = this.scope.fingerprint(await directory.stat());
106
+ const now = this.now();
107
+ for (const [id, snapshot] of this.snapshots) if (now - snapshot.snapshotAt >= 6e4) this.snapshots.delete(id);
108
+ let snapshot;
109
+ let offset = 0;
110
+ if (params.cursor !== void 0) {
111
+ for (const candidate of this.snapshots.values()) for (const [position, cursor] of candidate.cursors) if (cursor === params.cursor) {
112
+ snapshot = candidate;
113
+ offset = position;
114
+ }
115
+ if (!snapshot || snapshot.target !== JSON.stringify(params.target)) throw new FileAccessError("stale-cursor", 409);
116
+ } else {
117
+ const observed = await directory.list(1e4);
118
+ const entries = [];
119
+ for (const item of observed) {
120
+ const path = [params.target.path, item.name].filter(Boolean).join("/");
121
+ if (item.observation.kind === "symlink") {
122
+ const linked = await this.scope.select({
123
+ ...params.target,
124
+ path
125
+ }, signal);
126
+ try {
127
+ entries.push(this.scope.entry(linked));
128
+ } finally {
129
+ await linked.close();
130
+ }
131
+ } else entries.push(this.scope.entry({
132
+ ...selection,
133
+ parent: directory,
134
+ name: item.name,
135
+ path: join(directory.path, item.name),
136
+ effectivePath: join(directory.path, item.name),
137
+ target: {
138
+ ...params.target,
139
+ path
140
+ },
141
+ observation: item.observation
142
+ }));
143
+ }
144
+ if (this.scope.fingerprint(await directory.stat()) !== current) throw new FileAccessError("stale", 409);
145
+ const order = {
146
+ directory: 0,
147
+ file: 1,
148
+ symlink: 2,
149
+ other: 3
150
+ };
151
+ entries.sort((a, b) => order[a.kind] - order[b.kind] || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
152
+ if (this.snapshots.size === 8) this.snapshots.delete(this.snapshots.keys().next().value);
153
+ snapshot = {
154
+ target: JSON.stringify(params.target),
155
+ entries,
156
+ listingId: randomUUID(),
157
+ snapshotAt: now,
158
+ fingerprint: current,
159
+ cursors: /* @__PURE__ */ new Map()
160
+ };
161
+ this.snapshots.set(snapshot.listingId, snapshot);
162
+ }
163
+ await selection.current();
164
+ const next = offset + 200;
165
+ let nextCursor;
166
+ if (next < snapshot.entries.length) {
167
+ nextCursor = snapshot.cursors.get(next) ?? randomUUID();
168
+ snapshot.cursors.set(next, nextCursor);
169
+ }
170
+ return {
171
+ entries: snapshot.entries.slice(offset, next),
172
+ nextCursor,
173
+ fingerprint: snapshot.fingerprint,
174
+ listingId: snapshot.listingId,
175
+ snapshotAt: snapshot.snapshotAt,
176
+ stale: snapshot.fingerprint !== current
177
+ };
178
+ } catch (error) {
179
+ throw accessError(error);
180
+ } finally {
181
+ await selection.close();
182
+ }
183
+ }
184
+ /** Releases in-memory generations on owner shutdown. */
185
+ close() {
186
+ this.snapshots.clear();
187
+ }
188
+ };
189
+ //#endregion
53
190
  //#region src/path-semantics.ts
54
191
  /** Host path syntax only; resolved object identity remains the platform's responsibility. */
55
192
  /** Requires an independent absolute root, excluding Windows drive-relative and root-relative forms. */
@@ -240,7 +377,8 @@ var FileScope = class {
240
377
  referenceUri(selection) {
241
378
  const formatted = this.referenceFormatter?.formatCurrent?.({
242
379
  gatewayId: selection.snapshot.gatewayId,
243
- ...selection.target
380
+ pathStyle: this.paths.sep === "\\" ? "windows" : "posix",
381
+ absolutePath: selection.path
244
382
  });
245
383
  if (!formatted) throw new FileAccessError("unavailable", 503);
246
384
  return formatted.uri;
@@ -256,108 +394,6 @@ var FileScope = class {
256
394
  }
257
395
  };
258
396
  //#endregion
259
- //#region src/listings.ts
260
- /** Owner-local bounded immutable directory snapshots and opaque page cursors. */
261
- /** Keeps at most eight sixty-second snapshots; every page still revalidates current scope. */
262
- var Listings = class {
263
- scope;
264
- now;
265
- snapshots = /* @__PURE__ */ new Map();
266
- /** Shares the provider's scope and clock without retaining host descriptors. */
267
- constructor(scope, now) {
268
- this.scope = scope;
269
- this.now = now;
270
- }
271
- /** Returns a 200-entry page of one immutable generation or refuses an expired/cross-target cursor. */
272
- async list(params, signal) {
273
- const selection = await this.scope.select(params.target, signal);
274
- try {
275
- const directory = selection.name ? await selection.parent.openDirectory([selection.name]) : selection.parent;
276
- const current = this.scope.fingerprint(await directory.stat());
277
- const now = this.now();
278
- for (const [id, snapshot] of this.snapshots) if (now - snapshot.snapshotAt >= 6e4) this.snapshots.delete(id);
279
- let snapshot;
280
- let offset = 0;
281
- if (params.cursor !== void 0) {
282
- for (const candidate of this.snapshots.values()) for (const [position, cursor] of candidate.cursors) if (cursor === params.cursor) {
283
- snapshot = candidate;
284
- offset = position;
285
- }
286
- if (!snapshot || snapshot.target !== JSON.stringify(params.target)) throw new FileAccessError("stale-cursor", 409);
287
- } else {
288
- const observed = await directory.list(1e4);
289
- const entries = [];
290
- for (const item of observed) {
291
- const path = [params.target.path, item.name].filter(Boolean).join("/");
292
- if (item.observation.kind === "symlink") {
293
- const linked = await this.scope.select({
294
- ...params.target,
295
- path
296
- }, signal);
297
- try {
298
- entries.push(this.scope.entry(linked));
299
- } finally {
300
- await linked.close();
301
- }
302
- } else entries.push(this.scope.entry({
303
- ...selection,
304
- parent: directory,
305
- name: item.name,
306
- path: join(directory.path, item.name),
307
- effectivePath: join(directory.path, item.name),
308
- target: {
309
- ...params.target,
310
- path
311
- },
312
- observation: item.observation
313
- }));
314
- }
315
- if (this.scope.fingerprint(await directory.stat()) !== current) throw new FileAccessError("stale", 409);
316
- const order = {
317
- directory: 0,
318
- file: 1,
319
- symlink: 2,
320
- other: 3
321
- };
322
- entries.sort((a, b) => order[a.kind] - order[b.kind] || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
323
- if (this.snapshots.size === 8) this.snapshots.delete(this.snapshots.keys().next().value);
324
- snapshot = {
325
- target: JSON.stringify(params.target),
326
- entries,
327
- listingId: randomUUID(),
328
- snapshotAt: now,
329
- fingerprint: current,
330
- cursors: /* @__PURE__ */ new Map()
331
- };
332
- this.snapshots.set(snapshot.listingId, snapshot);
333
- }
334
- await selection.current();
335
- const next = offset + 200;
336
- let nextCursor;
337
- if (next < snapshot.entries.length) {
338
- nextCursor = snapshot.cursors.get(next) ?? randomUUID();
339
- snapshot.cursors.set(next, nextCursor);
340
- }
341
- return {
342
- entries: snapshot.entries.slice(offset, next),
343
- nextCursor,
344
- fingerprint: snapshot.fingerprint,
345
- listingId: snapshot.listingId,
346
- snapshotAt: snapshot.snapshotAt,
347
- stale: snapshot.fingerprint !== current
348
- };
349
- } catch (error) {
350
- throw accessError(error);
351
- } finally {
352
- await selection.close();
353
- }
354
- }
355
- /** Releases in-memory generations on owner shutdown. */
356
- close() {
357
- this.snapshots.clear();
358
- }
359
- };
360
- //#endregion
361
397
  //#region src/locks.ts
362
398
  /** Atomic acquisition of ordered canonical path sets prevents opposing moves from deadlocking. */
363
399
  /** Serializes overlapping selected paths, including recursive ancestors. */
@@ -1243,6 +1279,27 @@ function registerFileExplorer(ctx) {
1243
1279
  signal: ctx.signal,
1244
1280
  instanceId: identity.instanceId
1245
1281
  });
1282
+ const changed = ctx.client.registerFrame({
1283
+ name: "changed",
1284
+ delivery: "broadcast",
1285
+ payload: FilesChangedSchema
1286
+ });
1287
+ const announced = /* @__PURE__ */ new WeakSet();
1288
+ async function mutate(items, request, work, workspace) {
1289
+ const workspaces = /* @__PURE__ */ new Set();
1290
+ try {
1291
+ return await fileCollection(items, AbortSignal.any([ctx.signal, request]), async (item) => {
1292
+ const value = await work(item, request);
1293
+ if (!announced.has(value) && (value.status === "succeeded" || value.error?.details?.destinationCommitted)) {
1294
+ announced.add(value);
1295
+ workspaces.add(workspace(item));
1296
+ }
1297
+ return value;
1298
+ });
1299
+ } finally {
1300
+ if (workspaces.size && !ctx.signal.aborted) changed.emit({ workspaces: [...workspaces] });
1301
+ }
1302
+ }
1246
1303
  const drain = () => provider.close();
1247
1304
  const unbind = ctx.services.get("work-lifecycle").register(drain);
1248
1305
  ctx.onDispose(async () => {
@@ -1262,10 +1319,10 @@ function registerFileExplorer(ctx) {
1262
1319
  handle: (params, call) => provider.list(params, call.signal)
1263
1320
  });
1264
1321
  ctx.client.registerMethod({
1265
- name: "stat",
1266
- params: StatParamsSchema,
1267
- result: StatResultSchema,
1268
- handle: (params, call) => provider.stat(params.target, call.signal)
1322
+ name: "stats",
1323
+ params: StatsParamsSchema,
1324
+ result: StatsResultSchema,
1325
+ handle: (params, call) => fileCollection(params.targets, AbortSignal.any([ctx.signal, call.signal]), (target) => provider.stat(target, call.signal), params.maxItemBytes)
1269
1326
  });
1270
1327
  ctx.client.registerMethod({
1271
1328
  name: "mkdir",
@@ -1280,22 +1337,25 @@ function registerFileExplorer(ctx) {
1280
1337
  handle: (params, call) => provider.rename(params, call.signal)
1281
1338
  });
1282
1339
  ctx.client.registerMethod({
1283
- name: "move",
1284
- params: MoveParamsSchema,
1285
- result: OperationResultSchema,
1286
- handle: (params, call) => provider.move(params, call.signal)
1340
+ name: "moveFiles",
1341
+ params: MoveFilesParamsSchema,
1342
+ result: MoveFilesResultSchema,
1343
+ handle: (params, call) => mutate(params.items, call.signal, provider.move, (item) => item.source.workspaceId)
1287
1344
  });
1288
1345
  ctx.client.registerMethod({
1289
- name: "remove",
1290
- params: RemoveParamsSchema,
1291
- result: OperationResultSchema,
1292
- handle: (params, call) => provider.remove(params, call.signal)
1346
+ name: "removeFiles",
1347
+ params: RemoveFilesParamsSchema,
1348
+ result: RemoveFilesResultSchema,
1349
+ handle: (params, call) => mutate(params.items, call.signal, provider.remove, (item) => item.target.workspaceId)
1293
1350
  });
1294
1351
  ctx.client.registerMethod({
1295
- name: "operationStatus",
1296
- params: OperationStatusParamsSchema,
1297
- result: OperationResultSchema,
1298
- handle: (params) => provider.operationStatus(params)
1352
+ name: "operationStatuses",
1353
+ params: OperationStatusesParamsSchema,
1354
+ result: OperationStatusesResultSchema,
1355
+ handle: (params, call) => fileCollection(params.operationIds, AbortSignal.any([ctx.signal, call.signal]), (operationId) => provider.operationStatus({
1356
+ instanceId: params.instanceId,
1357
+ operationId
1358
+ }), params.maxItemBytes)
1299
1359
  });
1300
1360
  ctx.client.registerResource({
1301
1361
  name: "fileBytes",
@@ -1314,7 +1374,7 @@ function registerFileExplorer(ctx) {
1314
1374
  }
1315
1375
  const plugin = {
1316
1376
  name: "@eum-agent/file-explorer",
1317
- dependencies: { "@eum-agent/file-references": "1.0.0" },
1377
+ dependencies: { "@eum-agent/file-references": "1.1.0" },
1318
1378
  requires: [
1319
1379
  "client-files",
1320
1380
  "workspace-roots",
package/dist/schema.js CHANGED
@@ -142,5 +142,74 @@ const OperationStatusParamsSchema = z.object({
142
142
  instanceId: GatewayIdSchema,
143
143
  operationId: z.uuid()
144
144
  }).strict();
145
+ const encoded = (value) => new TextEncoder().encode(JSON.stringify(value)).byteLength;
146
+ const budget = {
147
+ maxItemBytes: z.number().int().min(1).max(8388608).default(262144),
148
+ maxTotalBytes: z.number().int().min(1).max(8388608).default(8388608)
149
+ };
150
+ /** Rejects input and declared normalized response costs before any filesystem work. */
151
+ function admit(input, targets, memberBytes, totalBytes, ctx) {
152
+ if (encoded(input) * 2 > 8388608 || targets.length * memberBytes + 8192 > totalBytes || targets.some((target) => encoded(target) + 4096 > memberBytes)) ctx.addIssue({
153
+ code: "custom",
154
+ message: "File collection exceeds its byte budget"
155
+ });
156
+ }
157
+ /** Complete file observations with independent encoded response budgets. */
158
+ const StatsParamsSchema = z.object({
159
+ targets: z.array(FileTargetSchema).min(1).max(64),
160
+ ...budget
161
+ }).strict().superRefine((p, ctx) => admit(p, p.targets, p.maxItemBytes, p.maxTotalBytes, ctx));
162
+ /** Retained operation queries never execute work, including unknown identities. */
163
+ const OperationStatusesParamsSchema = z.object({
164
+ instanceId: GatewayIdSchema,
165
+ operationIds: z.array(z.uuid()).min(1).max(64),
166
+ ...budget
167
+ }).strict().superRefine((p, ctx) => admit(p, p.operationIds, p.maxItemBytes, p.maxTotalBytes, ctx));
168
+ /** Validates complete mutation members and refuses conflicting reuse before any member starts. */
169
+ function mutations(schema) {
170
+ return z.object({ items: z.array(schema).min(1).max(64) }).strict().superRefine((p, ctx) => {
171
+ if (encoded(p) * 2 > 8388608) ctx.addIssue({
172
+ code: "custom",
173
+ message: "File collection exceeds its byte budget"
174
+ });
175
+ const identities = /* @__PURE__ */ new Map();
176
+ for (const member of p.items) {
177
+ const input = JSON.stringify(member);
178
+ if (identities.has(member.operationId) && identities.get(member.operationId) !== input) ctx.addIssue({
179
+ code: "custom",
180
+ message: "Operation identity has conflicting members"
181
+ });
182
+ identities.set(member.operationId, input);
183
+ }
184
+ });
185
+ }
186
+ /** Each move retains its own complete operation identity and physical outcome. */
187
+ const MoveFilesParamsSchema = mutations(MoveParamsSchema);
188
+ /** Each removal retains its own selected fingerprint and recursive decision. */
189
+ const RemoveFilesParamsSchema = mutations(RemoveParamsSchema);
190
+ /** One notification lists all workspaces physically changed by this collection. */
191
+ const FilesChangedSchema = z.object({ workspaces: z.array(IdentitySchema).min(1).max(64) }).strict();
192
+ /** Complete successful members and bounded safe refusals retain their original order. */
193
+ function collectionResult(target, value) {
194
+ return z.object({ results: z.array(z.discriminatedUnion("ok", [z.object({
195
+ index: z.number().int().min(0).max(63),
196
+ target,
197
+ ok: z.literal(true),
198
+ value
199
+ }).strict(), z.object({
200
+ index: z.number().int().min(0).max(63),
201
+ target,
202
+ ok: z.literal(false),
203
+ error: AccessErrorSchema
204
+ }).strict()])).min(1).max(64) }).strict();
205
+ }
206
+ /** Ordered explicit file observations. */
207
+ const StatsResultSchema = collectionResult(FileTargetSchema, StatResultSchema);
208
+ /** Ordered retained operation facts. */
209
+ const OperationStatusesResultSchema = collectionResult(z.uuid(), OperationResultSchema);
210
+ /** Ordered move results keep complete immutable operation members as their safe target. */
211
+ const MoveFilesResultSchema = collectionResult(MoveParamsSchema, OperationResultSchema);
212
+ /** Ordered removal results keep complete immutable operation members as their safe target. */
213
+ const RemoveFilesResultSchema = collectionResult(RemoveParamsSchema, OperationResultSchema);
145
214
  //#endregion
146
- export { AccessErrorSchema, DigestSchema, ErrorDetailsSchema, FileBytesParamsSchema, FileEntrySchema, FileSourceObservationSchema, FileTargetSchema, GatewayIdSchema, IdentitySchema, ListParamsSchema, ListResultSchema, MkdirParamsSchema, MoveParamsSchema, OperationResultSchema, OperationStatusParamsSchema, PathSchema, RemoveParamsSchema, RenameParamsSchema, RootsParamsSchema, RootsResultSchema, SegmentSchema, StatParamsSchema, StatResultSchema };
215
+ export { AccessErrorSchema, DigestSchema, ErrorDetailsSchema, FileBytesParamsSchema, FileEntrySchema, FileSourceObservationSchema, FileTargetSchema, FilesChangedSchema, GatewayIdSchema, IdentitySchema, ListParamsSchema, ListResultSchema, MkdirParamsSchema, MoveFilesParamsSchema, MoveFilesResultSchema, MoveParamsSchema, OperationResultSchema, OperationStatusParamsSchema, OperationStatusesParamsSchema, OperationStatusesResultSchema, PathSchema, RemoveFilesParamsSchema, RemoveFilesResultSchema, RemoveParamsSchema, RenameParamsSchema, RootsParamsSchema, RootsResultSchema, SegmentSchema, StatParamsSchema, StatResultSchema, StatsParamsSchema, StatsResultSchema, collectionResult };
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "en-US": "Workspace Files",
5
5
  "zh-CN": "工作空间文件"
6
6
  },
7
- "version": "1.0.0",
7
+ "version": "1.1.0",
8
8
  "type": "module",
9
9
  "description": "Sessionless workspace file browsing, transfers and private references",
10
10
  "license": "MIT",
@@ -24,14 +24,14 @@
24
24
  "zod": "^4.4.3"
25
25
  },
26
26
  "devDependencies": {
27
- "@eum-agent/plugin": "1.0.0",
27
+ "@eum-agent/plugin": "1.1.0",
28
28
  "@eum-agent/protocol": "1.0.0",
29
29
  "react": "^19.2.0",
30
30
  "react-dom": "^19.2.0",
31
31
  "@types/react": "^19.2.18",
32
32
  "@types/react-dom": "^19.2.5",
33
33
  "vite": "^8.2.2",
34
- "@eum-agent/infra-local": "1.0.0"
34
+ "@eum-agent/infra-local": "1.0.1"
35
35
  },
36
36
  "yaoguang": {
37
37
  "description": {
@@ -39,11 +39,11 @@
39
39
  "zh-CN": "独立的工作区文件浏览、传输与私有引用"
40
40
  },
41
41
  "dependencies": {
42
- "@eum-agent/file-references": "1.0.0"
42
+ "@eum-agent/file-references": "1.1.0"
43
43
  },
44
44
  "kind": "extension",
45
45
  "foundationDependencies": {
46
- "@eum-agent/infra-local": "1.0.0"
46
+ "@eum-agent/infra-local": "1.0.1"
47
47
  }
48
48
  },
49
49
  "scripts": {
package/ui/ui.js CHANGED
@@ -2163,6 +2163,62 @@ var $ZodUnion = /*@__PURE__*/ $constructor("$ZodUnion", (inst, def) => {
2163
2163
  });
2164
2164
  };
2165
2165
  });
2166
+ var $ZodDiscriminatedUnion = /*@__PURE__*/ $constructor("$ZodDiscriminatedUnion", (inst, def) => {
2167
+ def.inclusive = false;
2168
+ $ZodUnion.init(inst, def);
2169
+ const _super = inst._zod.parse;
2170
+ defineLazy(inst._zod, "propValues", () => {
2171
+ const propValues = {};
2172
+ for (const option of def.options) {
2173
+ const pv = option._zod.propValues;
2174
+ if (!pv || Object.keys(pv).length === 0) throw new Error(`Invalid discriminated union option at index "${def.options.indexOf(option)}"`);
2175
+ for (const [k, v] of Object.entries(pv)) {
2176
+ if (!propValues[k]) propValues[k] = /* @__PURE__ */ new Set();
2177
+ for (const val of v) propValues[k].add(val);
2178
+ }
2179
+ }
2180
+ return propValues;
2181
+ });
2182
+ const disc = cached(() => {
2183
+ const opts = def.options;
2184
+ const map = /* @__PURE__ */ new Map();
2185
+ for (const o of opts) {
2186
+ const values = o._zod.propValues?.[def.discriminator];
2187
+ if (!values || values.size === 0) throw new Error(`Invalid discriminated union option at index "${def.options.indexOf(o)}"`);
2188
+ for (const v of values) {
2189
+ if (map.has(v)) throw new Error(`Duplicate discriminator value "${String(v)}"`);
2190
+ map.set(v, o);
2191
+ }
2192
+ }
2193
+ return map;
2194
+ });
2195
+ inst._zod.parse = (payload, ctx) => {
2196
+ const input = payload.value;
2197
+ if (!isObject(input)) {
2198
+ payload.issues.push({
2199
+ code: "invalid_type",
2200
+ expected: "object",
2201
+ input,
2202
+ inst
2203
+ });
2204
+ return payload;
2205
+ }
2206
+ const opt = disc.value.get(input?.[def.discriminator]);
2207
+ if (opt) return opt._zod.run(payload, ctx);
2208
+ if (def.unionFallback || ctx.direction === "backward") return _super(payload, ctx);
2209
+ payload.issues.push({
2210
+ code: "invalid_union",
2211
+ errors: [],
2212
+ note: "No matching discriminator",
2213
+ discriminator: def.discriminator,
2214
+ options: Array.from(disc.value.keys()),
2215
+ input,
2216
+ path: [def.discriminator],
2217
+ inst
2218
+ });
2219
+ return payload;
2220
+ };
2221
+ });
2166
2222
  var $ZodIntersection = /*@__PURE__*/ $constructor("$ZodIntersection", (inst, def) => {
2167
2223
  $ZodType.init(inst, def);
2168
2224
  inst._zod.parse = (payload, ctx) => {
@@ -4215,6 +4271,18 @@ function union(options, params) {
4215
4271
  ...normalizeParams(params)
4216
4272
  });
4217
4273
  }
4274
+ var ZodDiscriminatedUnion = /*@__PURE__*/ $constructor("ZodDiscriminatedUnion", (inst, def) => {
4275
+ ZodUnion.init(inst, def);
4276
+ $ZodDiscriminatedUnion.init(inst, def);
4277
+ });
4278
+ function discriminatedUnion(discriminator, options, params) {
4279
+ return new ZodDiscriminatedUnion({
4280
+ type: "union",
4281
+ options,
4282
+ discriminator,
4283
+ ...normalizeParams(params)
4284
+ });
4285
+ }
4218
4286
  var ZodIntersection = /*@__PURE__*/ $constructor("ZodIntersection", (inst, def) => {
4219
4287
  $ZodIntersection.init(inst, def);
4220
4288
  ZodType.init(inst, def);
@@ -4466,7 +4534,8 @@ var FileTargetSchema = object({
4466
4534
  path: PathSchema
4467
4535
  }).strict();
4468
4536
  object({ workspaceId: IdentitySchema }).strict();
4469
- object({ target: FileTargetSchema }).strict();
4537
+ /** Stat always names one explicit current root-relative selection. */
4538
+ var StatParamsSchema = object({ target: FileTargetSchema }).strict();
4470
4539
  /** Display metadata for one current filesystem object. */
4471
4540
  var FileEntrySchema = object({
4472
4541
  name: string(),
@@ -4518,8 +4587,7 @@ var FileSourceObservationSchema = object({
4518
4587
  size: number().int().nonnegative().max(Number.MAX_SAFE_INTEGER),
4519
4588
  modifiedAt: number().min(-864e13).max(864e13)
4520
4589
  }).strict();
4521
- /** File resource business parameters; PUT additionally requires operationId and source. */
4522
- var FileBytesParamsSchema = object({
4590
+ object({
4523
4591
  target: FileTargetSchema,
4524
4592
  expectedFingerprint: DigestSchema.optional(),
4525
4593
  operationId: uuid().optional(),
@@ -4564,7 +4632,8 @@ object({
4564
4632
  replace: boolean().optional(),
4565
4633
  targetFingerprint: DigestSchema.optional()
4566
4634
  }).strict();
4567
- object({
4635
+ /** Same-workspace move to an explicit final target path. */
4636
+ var MoveParamsSchema = object({
4568
4637
  source: FileTargetSchema,
4569
4638
  destination: FileTargetSchema,
4570
4639
  sourceFingerprint: DigestSchema,
@@ -4572,30 +4641,206 @@ object({
4572
4641
  replace: boolean().optional(),
4573
4642
  targetFingerprint: DigestSchema.optional()
4574
4643
  }).strict();
4575
- object({
4644
+ /** Removal requires the selected revision and an explicit recursive choice. */
4645
+ var RemoveParamsSchema = object({
4576
4646
  target: FileTargetSchema,
4577
4647
  fingerprint: DigestSchema,
4578
4648
  recursive: boolean(),
4579
4649
  operationId: uuid()
4580
4650
  }).strict();
4581
- object({
4651
+ /** Query retained facts without executing an operation. */
4652
+ var OperationStatusParamsSchema = object({
4582
4653
  instanceId: GatewayIdSchema,
4583
4654
  operationId: uuid()
4584
4655
  }).strict();
4585
- //#endregion
4586
- //#region ui-src/references.ts
4587
- /** Formats an explicit current file without reading it or changing the selected workspace. */
4588
- function currentReference(ctx, gatewayId, workspaceId, rootId, path) {
4589
- const reader = ctx.services.get("file-reference-reader");
4590
- if (!reader) throw Object.assign(/* @__PURE__ */ new Error("Reference reader unavailable"), { code: "unavailable" });
4591
- return reader.format({
4592
- kind: "workspace-file",
4593
- gatewayId,
4594
- workspaceId,
4595
- rootId,
4596
- path
4656
+ var encoded = (value) => new TextEncoder().encode(JSON.stringify(value)).byteLength;
4657
+ var budget = {
4658
+ maxItemBytes: number().int().min(1).max(8388608).default(262144),
4659
+ maxTotalBytes: number().int().min(1).max(8388608).default(8388608)
4660
+ };
4661
+ /** Rejects input and declared normalized response costs before any filesystem work. */
4662
+ function admit(input, targets, memberBytes, totalBytes, ctx) {
4663
+ if (encoded(input) * 2 > 8388608 || targets.length * memberBytes + 8192 > totalBytes || targets.some((target) => encoded(target) + 4096 > memberBytes)) ctx.addIssue({
4664
+ code: "custom",
4665
+ message: "File collection exceeds its byte budget"
4597
4666
  });
4598
4667
  }
4668
+ object({
4669
+ targets: array(FileTargetSchema).min(1).max(64),
4670
+ ...budget
4671
+ }).strict().superRefine((p, ctx) => admit(p, p.targets, p.maxItemBytes, p.maxTotalBytes, ctx));
4672
+ object({
4673
+ instanceId: GatewayIdSchema,
4674
+ operationIds: array(uuid()).min(1).max(64),
4675
+ ...budget
4676
+ }).strict().superRefine((p, ctx) => admit(p, p.operationIds, p.maxItemBytes, p.maxTotalBytes, ctx));
4677
+ /** Validates complete mutation members and refuses conflicting reuse before any member starts. */
4678
+ function mutations(schema) {
4679
+ return object({ items: array(schema).min(1).max(64) }).strict().superRefine((p, ctx) => {
4680
+ if (encoded(p) * 2 > 8388608) ctx.addIssue({
4681
+ code: "custom",
4682
+ message: "File collection exceeds its byte budget"
4683
+ });
4684
+ const identities = /* @__PURE__ */ new Map();
4685
+ for (const member of p.items) {
4686
+ const input = JSON.stringify(member);
4687
+ if (identities.has(member.operationId) && identities.get(member.operationId) !== input) ctx.addIssue({
4688
+ code: "custom",
4689
+ message: "Operation identity has conflicting members"
4690
+ });
4691
+ identities.set(member.operationId, input);
4692
+ }
4693
+ });
4694
+ }
4695
+ mutations(MoveParamsSchema);
4696
+ mutations(RemoveParamsSchema);
4697
+ /** One notification lists all workspaces physically changed by this collection. */
4698
+ var FilesChangedSchema = object({ workspaces: array(IdentitySchema).min(1).max(64) }).strict();
4699
+ /** Complete successful members and bounded safe refusals retain their original order. */
4700
+ function collectionResult(target, value) {
4701
+ return object({ results: array(discriminatedUnion("ok", [object({
4702
+ index: number().int().min(0).max(63),
4703
+ target,
4704
+ ok: literal(true),
4705
+ value
4706
+ }).strict(), object({
4707
+ index: number().int().min(0).max(63),
4708
+ target,
4709
+ ok: literal(false),
4710
+ error: AccessErrorSchema
4711
+ }).strict()])).min(1).max(64) }).strict();
4712
+ }
4713
+ /** Ordered explicit file observations. */
4714
+ var StatsResultSchema = collectionResult(FileTargetSchema, StatResultSchema);
4715
+ /** Ordered retained operation facts. */
4716
+ var OperationStatusesResultSchema = collectionResult(uuid(), OperationResultSchema);
4717
+ /** Ordered move results keep complete immutable operation members as their safe target. */
4718
+ var MoveFilesResultSchema = collectionResult(MoveParamsSchema, OperationResultSchema);
4719
+ /** Ordered removal results keep complete immutable operation members as their safe target. */
4720
+ var RemoveFilesResultSchema = collectionResult(RemoveParamsSchema, OperationResultSchema);
4721
+ //#endregion
4722
+ //#region ui-src/collections.ts
4723
+ /** Owner-local coalescing turns concurrent file intents into actual collection RPCs. */
4724
+ var inputs = {
4725
+ stat: StatParamsSchema,
4726
+ move: MoveParamsSchema,
4727
+ remove: RemoveParamsSchema,
4728
+ operationStatus: OperationStatusParamsSchema
4729
+ };
4730
+ var definitions = {
4731
+ stat: {
4732
+ name: "stats",
4733
+ key: "targets",
4734
+ limit: 31,
4735
+ result: StatsResultSchema
4736
+ },
4737
+ move: {
4738
+ name: "moveFiles",
4739
+ key: "items",
4740
+ limit: 64,
4741
+ result: MoveFilesResultSchema
4742
+ },
4743
+ remove: {
4744
+ name: "removeFiles",
4745
+ key: "items",
4746
+ limit: 64,
4747
+ result: RemoveFilesResultSchema
4748
+ },
4749
+ operationStatus: {
4750
+ name: "operationStatuses",
4751
+ key: "operationIds",
4752
+ limit: 31,
4753
+ result: OperationStatusesResultSchema
4754
+ }
4755
+ };
4756
+ /** Groups only one owner and exact shared startup identity; each consumer retains cancellation. */
4757
+ var FileCollections = class {
4758
+ invoke;
4759
+ run;
4760
+ groups = /* @__PURE__ */ new Map();
4761
+ scheduled = false;
4762
+ /** Uses the owner's existing bounded transport admission, with no automatic mutation retries. */
4763
+ constructor(invoke, run) {
4764
+ this.invoke = invoke;
4765
+ this.run = run;
4766
+ }
4767
+ /** Queues one validated control intent and preserves its complete independent operation identity. */
4768
+ enqueue(method, params, signal) {
4769
+ signal.throwIfAborted();
4770
+ const input = inputs[method].parse(params);
4771
+ const instanceId = "instanceId" in input ? input.instanceId : void 0;
4772
+ const target = method === "stat" && "target" in input ? input.target : method === "operationStatus" && "operationId" in input ? input.operationId : input;
4773
+ const key = JSON.stringify([method, instanceId]);
4774
+ const group = this.groups.get(key) ?? {
4775
+ method,
4776
+ instanceId,
4777
+ items: []
4778
+ };
4779
+ this.groups.set(key, group);
4780
+ const promise = new Promise((resolve, reject) => {
4781
+ const item = {
4782
+ input,
4783
+ target,
4784
+ signal,
4785
+ resolve,
4786
+ reject,
4787
+ done: false,
4788
+ abort: () => {
4789
+ if (item.done) return;
4790
+ item.done = true;
4791
+ signal.removeEventListener("abort", item.abort);
4792
+ reject(signal.reason);
4793
+ }
4794
+ };
4795
+ group.items.push(item);
4796
+ signal.addEventListener("abort", item.abort, { once: true });
4797
+ });
4798
+ if (!this.scheduled) {
4799
+ this.scheduled = true;
4800
+ queueMicrotask(() => this.flush());
4801
+ }
4802
+ return promise;
4803
+ }
4804
+ flush() {
4805
+ this.scheduled = false;
4806
+ const groups = this.groups;
4807
+ this.groups = /* @__PURE__ */ new Map();
4808
+ for (const group of groups.values()) {
4809
+ const definition = definitions[group.method];
4810
+ const active = group.items.filter((item) => !item.done);
4811
+ for (let start = 0; start < active.length; start += definition.limit) this.dispatch(group, active.slice(start, start + definition.limit));
4812
+ }
4813
+ }
4814
+ finish(item, value, error) {
4815
+ if (item.done) return;
4816
+ item.done = true;
4817
+ item.signal.removeEventListener("abort", item.abort);
4818
+ if (error !== void 0) item.reject(error);
4819
+ else item.resolve(value);
4820
+ }
4821
+ async dispatch(group, queued) {
4822
+ try {
4823
+ await this.run(async () => {
4824
+ const items = queued.filter((item) => !item.done);
4825
+ if (!items.length) return;
4826
+ const definition = definitions[group.method];
4827
+ const params = {
4828
+ ...group.instanceId === void 0 ? {} : { instanceId: group.instanceId },
4829
+ [definition.key]: items.map((item) => item.target)
4830
+ };
4831
+ const result = definition.result.parse(await this.invoke(definition.name, params));
4832
+ if (result.results.length !== items.length || result.results.some((member, index) => member.index !== index || JSON.stringify(member.target) !== JSON.stringify(items[index]?.target))) throw Object.assign(/* @__PURE__ */ new Error("File collection returned unexpected members"), { code: "io-failed" });
4833
+ for (const [index, item] of items.entries()) {
4834
+ const member = result.results[index];
4835
+ if (member.ok) this.finish(item, member.value);
4836
+ else this.finish(item, void 0, Object.assign(new Error(member.error.message), member.error));
4837
+ }
4838
+ });
4839
+ } catch (error) {
4840
+ for (const item of queued) this.finish(item, void 0, error);
4841
+ }
4842
+ }
4843
+ };
4599
4844
  //#endregion
4600
4845
  //#region ui-src/api.ts
4601
4846
  var results = {
@@ -4619,10 +4864,20 @@ function errorCode(error) {
4619
4864
  var FileApi = class {
4620
4865
  active = 0;
4621
4866
  waiting = [];
4867
+ collections;
4622
4868
  ctx;
4623
4869
  /** Receives public browser ports only. */
4624
4870
  constructor(ctx) {
4625
4871
  this.ctx = ctx;
4872
+ this.collections = new FileCollections((method, params) => ctx.invoke(method, params), async (work) => {
4873
+ await this.permit(ctx.signal);
4874
+ try {
4875
+ ctx.signal.throwIfAborted();
4876
+ await work();
4877
+ } finally {
4878
+ this.release();
4879
+ }
4880
+ });
4626
4881
  }
4627
4882
  /** Reads installation identity and the current authenticated resource surface. */
4628
4883
  async bootstrap(signal) {
@@ -4676,6 +4931,11 @@ var FileApi = class {
4676
4931
  /** Validates owner results once against the sole server/browser business schema. */
4677
4932
  async invoke(method, params, signal) {
4678
4933
  signal.throwIfAborted();
4934
+ if (method === "stat" || method === "move" || method === "remove" || method === "operationStatus") {
4935
+ const value = await this.collections.enqueue(method, params, signal);
4936
+ signal.throwIfAborted();
4937
+ return results[method].parse(value);
4938
+ }
4679
4939
  await this.permit(signal);
4680
4940
  try {
4681
4941
  signal.throwIfAborted();
@@ -5033,7 +5293,7 @@ var FileActions = class {
5033
5293
  this.context.signal
5034
5294
  ]);
5035
5295
  const observed = await this.api.invoke("stat", { target }, signal);
5036
- const value = kind === "path" ? observed.realPath : currentReference(this.ctx, session.identity.gatewayId, target.workspaceId, target.rootId, target.path);
5296
+ const value = kind === "path" ? observed.realPath : observed.entry.uri;
5037
5297
  try {
5038
5298
  await navigator.clipboard.writeText(value);
5039
5299
  signal.throwIfAborted();
@@ -5182,7 +5442,7 @@ var FileTransfers = class {
5182
5442
  id: this.ctx.uuid(),
5183
5443
  batch,
5184
5444
  name: file.name,
5185
- reference: currentReference(this.ctx, session.identity.gatewayId, directory.workspaceId, directory.rootId, [directory.path, file.name].filter(Boolean).join("/")),
5445
+ reference: [directory.path, file.name].filter(Boolean).join("/"),
5186
5446
  size: file.size,
5187
5447
  modifiedAt: file.lastModified,
5188
5448
  direction: "upload",
@@ -5203,8 +5463,8 @@ var FileTransfers = class {
5203
5463
  this.jobs.push(...jobs);
5204
5464
  this.emit();
5205
5465
  (async () => {
5206
- for (const job of jobs) {
5207
- if (job.record.status !== "waiting") continue;
5466
+ await Promise.all(jobs.map(async (job) => {
5467
+ if (job.record.status !== "waiting") return;
5208
5468
  try {
5209
5469
  job.observed = await this.observe(job);
5210
5470
  if (job.observed) this.update(job, {
@@ -5217,7 +5477,7 @@ var FileTransfers = class {
5217
5477
  error: errorCode(error)
5218
5478
  });
5219
5479
  }
5220
- }
5480
+ }));
5221
5481
  for (const job of jobs) job.prepared = true;
5222
5482
  if (generation === this.generation) this.pump();
5223
5483
  })();
@@ -5237,8 +5497,8 @@ var FileTransfers = class {
5237
5497
  * Rejects genuine selection or admission failures without creating a transfer record.
5238
5498
  */
5239
5499
  async download(session, descriptor, params, metadata) {
5240
- const current = FileBytesParamsSchema.safeParse(params);
5241
- const reference = current.success ? currentReference(this.ctx, session.identity.gatewayId, current.data.target.workspaceId, current.data.target.rootId, current.data.target.path) : void 0;
5500
+ const current = FileEntrySchema.safeParse(metadata.metadata);
5501
+ const reference = current.success ? current.data.uri : void 0;
5242
5502
  if (!reference) throw Object.assign(/* @__PURE__ */ new Error("download identity missing"), { code: "invalid-params" });
5243
5503
  this.capacity(1);
5244
5504
  const controller = new AbortController();
@@ -5263,7 +5523,7 @@ var FileTransfers = class {
5263
5523
  direction: "download",
5264
5524
  status: "waiting",
5265
5525
  size: metadata.bytes.contentLength,
5266
- target: current.success ? current.data.target : void 0
5526
+ target: FileTargetSchema.safeParse(params && typeof params === "object" && "target" in params ? params.target : void 0).data
5267
5527
  },
5268
5528
  session,
5269
5529
  controller,
@@ -5434,7 +5694,7 @@ var FileTransfers = class {
5434
5694
  const path = [target.path.split("/").slice(0, -1).join("/"), valid].filter(Boolean).join("/");
5435
5695
  this.update(job, {
5436
5696
  name: valid,
5437
- reference: currentReference(this.ctx, job.session.identity.gatewayId, target.workspaceId, target.rootId, path),
5697
+ reference: path,
5438
5698
  target: {
5439
5699
  ...target,
5440
5700
  path
@@ -5511,117 +5771,40 @@ var FileTransfers = class {
5511
5771
  };
5512
5772
  //#endregion
5513
5773
  //#region ui-src/viewer.ts
5514
- /** Validates decoded raster dimensions without retaining the bitmap or temporary URL. */
5515
- async function decodeImage(blob, signal) {
5516
- signal.throwIfAborted();
5517
- if (typeof createImageBitmap === "function") {
5518
- const bitmap = await createImageBitmap(blob);
5519
- try {
5520
- signal.throwIfAborted();
5521
- if (bitmap.width > 1e4 || bitmap.height > 1e4) throw Object.assign(/* @__PURE__ */ new Error("image side too large"), { code: "preview-too-large" });
5522
- } finally {
5523
- bitmap.close();
5524
- }
5525
- return;
5526
- }
5527
- const url = URL.createObjectURL(blob);
5528
- try {
5529
- await new Promise((resolve, reject) => {
5530
- const image = new Image();
5531
- const stop = () => {
5532
- image.src = "";
5533
- reject(signal.reason);
5534
- };
5535
- const clear = () => {
5536
- signal.removeEventListener("abort", stop);
5537
- image.onload = null;
5538
- image.onerror = null;
5539
- };
5540
- signal.addEventListener("abort", stop, { once: true });
5541
- image.onload = () => {
5542
- clear();
5543
- if (image.naturalWidth > 1e4 || image.naturalHeight > 1e4) reject(Object.assign(/* @__PURE__ */ new Error("image side too large"), { code: "preview-too-large" }));
5544
- else resolve();
5545
- };
5546
- image.onerror = () => {
5547
- clear();
5548
- reject(Object.assign(/* @__PURE__ */ new Error("image decode failed"), { code: "unsupported-preview" }));
5549
- };
5550
- image.src = url;
5551
- });
5552
- signal.throwIfAborted();
5553
- } finally {
5554
- URL.revokeObjectURL(url);
5555
- }
5556
- }
5557
- var imageTypes = {
5558
- png: "image/png",
5559
- jpg: "image/jpeg",
5560
- jpeg: "image/jpeg",
5561
- gif: "image/gif",
5562
- webp: "image/webp",
5563
- bmp: "image/bmp",
5564
- avif: "image/avif"
5565
- };
5566
- function rasterBytes(bytes, mime) {
5567
- const ascii = (start, end) => new TextDecoder("ascii").decode(bytes.subarray(start, end));
5568
- if (!(mime === "image/png" ? [
5569
- 137,
5570
- 80,
5571
- 78,
5572
- 71,
5573
- 13,
5574
- 10,
5575
- 26,
5576
- 10
5577
- ].every((v, i) => bytes[i] === v) : mime === "image/jpeg" ? bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255 : mime === "image/gif" ? ["GIF87a", "GIF89a"].includes(ascii(0, 6)) : mime === "image/webp" ? ascii(0, 4) === "RIFF" && ascii(8, 12) === "WEBP" : mime === "image/bmp" ? ascii(0, 2) === "BM" : mime === "image/avif" ? ascii(4, 8) === "ftyp" && ["avif", "avis"].includes(ascii(8, 12)) : false)) throw Object.assign(/* @__PURE__ */ new Error("raster signature mismatch"), { code: "unsupported-preview" });
5578
- }
5579
- var languages = {
5580
- svg: "xml",
5581
- html: "html",
5582
- htm: "html",
5583
- xml: "xml",
5584
- json: "json",
5585
- js: "javascript",
5586
- ts: "typescript",
5587
- tsx: "typescript",
5588
- jsx: "javascript",
5589
- css: "css",
5590
- py: "python",
5591
- sh: "shell",
5592
- md: "markdown",
5593
- yaml: "yaml",
5594
- yml: "yaml",
5595
- c: "c",
5596
- h: "c",
5597
- rs: "rust",
5598
- go: "go",
5599
- sql: "sql"
5600
- };
5601
- var textExtensions = /* @__PURE__ */ new Set([
5602
- "txt",
5603
- "log",
5604
- "csv",
5605
- "ini",
5606
- "conf",
5607
- ...Object.keys(languages)
5608
- ]);
5609
- /** Owns bounded previews of the optional file-management page selection. */
5774
+ /** Adapts the shared readonly preview to the Files selection lifetime, without a second renderer. */
5610
5775
  var FileViewer = class {
5611
- ctx;
5612
- decode;
5613
- controller = new AbortController();
5614
5776
  state;
5615
- url;
5616
5777
  listeners = /* @__PURE__ */ new Set();
5617
- /** Receives public ports and an injectable decoder for DOM-only fixtures. */
5618
- constructor(ctx, decode = decodeImage) {
5778
+ stop;
5779
+ controller = new AbortController();
5780
+ ctx;
5781
+ /** Reader withdrawal invalidates the sidebar and all its download affordances synchronously. */
5782
+ constructor(ctx) {
5619
5783
  this.ctx = ctx;
5620
- this.decode = decode;
5784
+ const off = ctx.services.subscribe("file-reference-reader", () => {
5785
+ if (!ctx.services.get("file-reference-reader") && this.state) {
5786
+ const href = this.state.href, name = this.state.name;
5787
+ this.close();
5788
+ this.state = {
5789
+ href,
5790
+ name,
5791
+ content: {
5792
+ status: "unavailable",
5793
+ lifecycle: "closed",
5794
+ canDownload: false
5795
+ }
5796
+ };
5797
+ this.emit();
5798
+ }
5799
+ });
5800
+ ctx.signal.addEventListener("abort", () => {
5801
+ off();
5802
+ this.close();
5803
+ }, { once: true });
5621
5804
  }
5622
- /** Returns this owner's dialog state. */
5805
+ /** Current immutable sidebar projection. */
5623
5806
  get = () => this.state;
5624
- /** Observes preview progress and safe failure states. */
5807
+ /** Observes shared content progress and selection changes. */
5625
5808
  subscribe = (listener) => {
5626
5809
  this.listeners.add(listener);
5627
5810
  return () => {
@@ -5631,67 +5814,7 @@ var FileViewer = class {
5631
5814
  emit() {
5632
5815
  for (const listener of this.listeners) listener();
5633
5816
  }
5634
- patch(value) {
5635
- if (!this.state) return;
5636
- this.state = {
5637
- ...this.state,
5638
- ...value
5639
- };
5640
- this.emit();
5641
- }
5642
- async source(session, href, signal, selected, expectedFingerprint) {
5643
- const reader = this.ctx.services.get("file-reference-reader");
5644
- if (!reader) throw Object.assign(/* @__PURE__ */ new Error("Reference reader unavailable"), { code: "unavailable" });
5645
- const description = await reader.describe(href, signal);
5646
- signal.throwIfAborted();
5647
- const entry = description.source.kind === "workspace-file" ? FileEntrySchema.extend({ realPath: string().optional() }).parse(description.metadata.metadata) : void 0;
5648
- const stat = entry ? {
5649
- entry,
5650
- realPath: entry.realPath ?? ""
5651
- } : void 0;
5652
- if (expectedFingerprint && stat?.entry.fingerprint !== expectedFingerprint) throw Object.assign(/* @__PURE__ */ new Error("Selected file changed"), { code: "stale" });
5653
- const metadata = description.metadata;
5654
- selected?.({
5655
- stat,
5656
- params: description.params,
5657
- descriptor: description.resource,
5658
- name: description.name
5659
- });
5660
- return {
5661
- stat,
5662
- params: description.params,
5663
- descriptor: description.resource,
5664
- metadata: entry?.kind === "directory" ? void 0 : metadata,
5665
- name: description.name
5666
- };
5667
- }
5668
- async collect(descriptor, params, metadata, limit, signal) {
5669
- if (metadata.bytes.contentLength > limit) throw Object.assign(/* @__PURE__ */ new Error("preview input too large"), { code: "preview-too-large" });
5670
- const read = await this.ctx.bytes.read(descriptor, params, { signal });
5671
- try {
5672
- signal.throwIfAborted();
5673
- if (read.bytes.contentLength > limit || read.bytes.contentLength !== metadata.bytes.contentLength) throw Object.assign(/* @__PURE__ */ new Error("preview changed"), { code: "preview-too-large" });
5674
- const chunks = [];
5675
- let length = 0;
5676
- for await (const chunk of read.body) {
5677
- signal.throwIfAborted();
5678
- length += chunk.byteLength;
5679
- if (length > limit || length > read.bytes.contentLength) throw Object.assign(/* @__PURE__ */ new Error("preview grew"), { code: "preview-too-large" });
5680
- chunks.push(chunk);
5681
- }
5682
- if (length !== read.bytes.contentLength) throw Object.assign(/* @__PURE__ */ new Error("preview length mismatch"), { code: "io-failed" });
5683
- const result = new Uint8Array(length);
5684
- let offset = 0;
5685
- for (const chunk of chunks) {
5686
- result.set(chunk, offset);
5687
- offset += chunk.length;
5688
- }
5689
- return result;
5690
- } finally {
5691
- await read.close();
5692
- }
5693
- }
5694
- /** Reads bounded content for one reference; an optional listing fingerprint refuses changed sources. */
5817
+ /** Creates its own preview, independently of an already open reader dialog. */
5695
5818
  async open(session, href, expectedFingerprint) {
5696
5819
  this.close();
5697
5820
  this.controller = new AbortController();
@@ -5700,84 +5823,60 @@ var FileViewer = class {
5700
5823
  session.signal,
5701
5824
  this.controller.signal
5702
5825
  ]);
5703
- this.state = {
5704
- href,
5705
- name: href,
5706
- content: { status: "loading" },
5707
- original: false,
5708
- zoom: 1
5826
+ const reader = this.ctx.services.get("file-reference-reader");
5827
+ if (!reader) {
5828
+ this.state = {
5829
+ href,
5830
+ name: href,
5831
+ content: {
5832
+ status: "unavailable",
5833
+ lifecycle: "closed",
5834
+ canDownload: false
5835
+ }
5836
+ };
5837
+ this.emit();
5838
+ return;
5839
+ }
5840
+ const handle = reader.createPreview({
5841
+ uri: href,
5842
+ expectedFingerprint
5843
+ });
5844
+ const update = () => {
5845
+ if (!signal.aborted) {
5846
+ this.state = {
5847
+ ...this.state,
5848
+ href,
5849
+ name: handle.get().metadata?.name ?? href,
5850
+ content: handle.get(),
5851
+ handle,
5852
+ Content: reader.PreviewContent
5853
+ };
5854
+ this.emit();
5855
+ }
5709
5856
  };
5710
- this.emit();
5857
+ update();
5858
+ this.stop = handle.subscribe(update);
5859
+ signal.addEventListener("abort", () => {
5860
+ try {
5861
+ handle.close();
5862
+ } catch {}
5863
+ }, { once: true });
5711
5864
  try {
5712
- const source = await this.source(session, href, signal, (value) => this.patch(value), expectedFingerprint);
5713
- signal.throwIfAborted();
5714
- this.patch(source);
5715
- if (!source.metadata) {
5716
- this.patch({ content: { status: "unsupported" } });
5717
- return;
5718
- }
5719
- const extension = source.name.split(".").pop()?.toLowerCase() ?? "";
5720
- const mediaType = source.metadata.metadata?.mediaType ?? source.metadata.bytes.contentType;
5721
- const mime = imageTypes[extension] ?? (Object.values(imageTypes).includes(mediaType) ? mediaType : void 0);
5722
- if (!mime && !textExtensions.has(extension) && source.metadata.bytes.contentLength > 1048576) {
5723
- this.patch({ content: { status: "unsupported" } });
5724
- return;
5725
- }
5726
- const bytes = await this.collect(source.descriptor, source.params, source.metadata, mime ? 20971520 : 1048576, signal);
5727
- signal.throwIfAborted();
5728
- if (mime) {
5729
- rasterBytes(bytes, mime);
5730
- const blob = new Blob([bytes], { type: mime });
5731
- await this.decode(blob, signal);
5732
- signal.throwIfAborted();
5733
- this.url = URL.createObjectURL(blob);
5734
- this.patch({ content: {
5735
- status: "ready",
5736
- kind: "image",
5737
- url: this.url,
5738
- alt: source.name
5739
- } });
5740
- } else {
5741
- let text;
5742
- try {
5743
- text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
5744
- if ([...text].some((character) => character.charCodeAt(0) < 32 && ![
5745
- "\n",
5746
- "\r",
5747
- " "
5748
- ].includes(character))) throw new Error("binary control");
5749
- } catch {
5750
- this.patch({ content: { status: "unsupported" } });
5751
- return;
5752
- }
5753
- signal.throwIfAborted();
5754
- this.patch({ content: {
5755
- status: "ready",
5756
- kind: languages[extension] ? "code" : "text",
5757
- text,
5758
- ...languages[extension] ? { language: languages[extension] } : {}
5759
- } });
5865
+ const description = await reader.describe(href, signal);
5866
+ if (!signal.aborted && this.state) {
5867
+ this.state = {
5868
+ ...this.state,
5869
+ description
5870
+ };
5871
+ this.emit();
5760
5872
  }
5761
- } catch (error) {
5762
- if (!signal.aborted) this.patch({ content: {
5763
- status: "failed",
5764
- message: this.ctx.i18n.t(`error.${errorCode(error)}`)
5765
- } });
5766
- }
5767
- }
5768
- /** Toggles owner image layout without changing the readonly input. */
5769
- toggleOriginal() {
5770
- if (this.state) this.patch({ original: !this.state.original });
5771
- }
5772
- /** Adjusts image zoom within one quarter to four times original size. */
5773
- zoom(delta) {
5774
- if (this.state) this.patch({ zoom: Math.max(.25, Math.min(4, this.state.zoom + delta)) });
5873
+ } catch {}
5775
5874
  }
5776
- /** Aborts pending IO, releases its owned preview URL, and removes visible dialog state. */
5875
+ /** Withdraws selection immediately while the reader retains any pending cleanup. */
5777
5876
  close = () => {
5877
+ this.stop?.();
5878
+ this.stop = void 0;
5778
5879
  this.controller.abort();
5779
- if (this.url) URL.revokeObjectURL(this.url);
5780
- this.url = void 0;
5781
5880
  this.state = void 0;
5782
5881
  this.emit();
5783
5882
  };
@@ -5865,6 +5964,10 @@ var FileBrowser = class {
5865
5964
  this.disposeCallbacks.push(ctx.onCore("config.workspaceChanged", () => {
5866
5965
  if (this.state.workspaceId) this.syncSelection(true);
5867
5966
  }));
5967
+ this.disposeCallbacks.push(ctx.on("changed", (event) => {
5968
+ const changed = FilesChangedSchema.safeParse(event.payload);
5969
+ if (changed.success && this.state.workspaceId && changed.data.workspaces.includes(this.state.workspaceId) && this.state.status === "online") this.refresh();
5970
+ }));
5868
5971
  ctx.signal.addEventListener("abort", this.dispose, { once: true });
5869
5972
  }
5870
5973
  /** Returns a stable immutable snapshot until a visible state change. */
@@ -7042,6 +7145,7 @@ function FilePreview({ ctx, model, onClose, onDownload, onCopy, downloading = fa
7042
7145
  const t = ctx.i18n.t;
7043
7146
  const selected = browser.selected;
7044
7147
  const content = state?.content;
7148
+ const Content = state?.Content;
7045
7149
  return /* @__PURE__ */ jsxs("aside", {
7046
7150
  className: "fx-preview-pane",
7047
7151
  "aria-label": t("preview"),
@@ -7109,23 +7213,7 @@ function FilePreview({ ctx, model, onClose, onDownload, onCopy, downloading = fa
7109
7213
  }) : browser.selectedError ? /* @__PURE__ */ jsx("p", {
7110
7214
  role: "alert",
7111
7215
  children: t(`error.${browser.selectedError}`)
7112
- }) : content?.status === "ready" ? content.kind === "image" ? /* @__PURE__ */ jsx("div", {
7113
- className: "fx-preview-image",
7114
- children: /* @__PURE__ */ jsx("img", {
7115
- src: content.url,
7116
- alt: state?.name ?? selected?.name ?? ""
7117
- })
7118
- }) : /* @__PURE__ */ jsx("pre", {
7119
- className: "fx-code-preview",
7120
- children: content.text.split("\n").map((line, index) => /* @__PURE__ */ jsxs("span", {
7121
- className: "fx-code-line",
7122
- children: [/* @__PURE__ */ jsx("span", {
7123
- className: "fx-line-number",
7124
- "aria-hidden": "true",
7125
- children: index + 1
7126
- }), /* @__PURE__ */ jsx("span", { children: line || "\xA0" })]
7127
- }, `${index}:${line}`))
7128
- }) : content?.status === "failed" ? /* @__PURE__ */ jsx("p", {
7216
+ }) : state?.handle && Content ? /* @__PURE__ */ jsx(Content, { handle: state.handle }) : content?.status === "failed" ? /* @__PURE__ */ jsx("p", {
7129
7217
  role: "alert",
7130
7218
  children: content.message
7131
7219
  }) : content?.status === "loading" ? /* @__PURE__ */ jsx("p", {
@@ -7133,7 +7221,7 @@ function FilePreview({ ctx, model, onClose, onDownload, onCopy, downloading = fa
7133
7221
  children: t("loading")
7134
7222
  }) : /* @__PURE__ */ jsxs("div", {
7135
7223
  className: "fx-preview-placeholder",
7136
- children: [/* @__PURE__ */ jsx(Icon, { name: "file-text" }), /* @__PURE__ */ jsx("span", { children: t(selected ? "preview-unsupported" : "select-preview") })]
7224
+ children: [/* @__PURE__ */ jsx(Icon, { name: "file-text" }), /* @__PURE__ */ jsx("span", { children: t(content?.status === "unavailable" ? "unavailable" : selected ? "preview-unsupported" : "select-preview") })]
7137
7225
  })
7138
7226
  })
7139
7227
  }),
@@ -7152,8 +7240,8 @@ function FilePreview({ ctx, model, onClose, onDownload, onCopy, downloading = fa
7152
7240
  /* @__PURE__ */ jsx("dd", { children: fileSize(selected.size) }),
7153
7241
  /* @__PURE__ */ jsx("dt", { children: t("modified") }),
7154
7242
  /* @__PURE__ */ jsx("dd", { children: new Date(selected.modifiedAt).toLocaleString() }),
7155
- state?.metadata && /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("dt", { children: t("body-size") }), /* @__PURE__ */ jsx("dd", { children: fileSize(state.metadata.bytes.contentLength) })] }),
7156
- state?.stat && /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("dt", { children: t("host-path") }), /* @__PURE__ */ jsx("dd", { children: state.stat.realPath })] }),
7243
+ state?.description?.metadata && /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("dt", { children: t("body-size") }), /* @__PURE__ */ jsx("dd", { children: fileSize(state.description.metadata.bytes.contentLength) })] }),
7244
+ state?.description?.source.kind === "gateway-file" && /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("dt", { children: t("host-path") }), /* @__PURE__ */ jsx("dd", { children: state.description.source.absolutePath })] }),
7157
7245
  /* @__PURE__ */ jsx("dt", { children: t("uri") }),
7158
7246
  /* @__PURE__ */ jsx("dd", { children: selected.uri })
7159
7247
  ]
@@ -7171,7 +7259,7 @@ function FilePreview({ ctx, model, onClose, onDownload, onCopy, downloading = fa
7171
7259
  children: [/* @__PURE__ */ jsxs(Button, {
7172
7260
  size: "sm",
7173
7261
  loading: downloading,
7174
- disabled: downloading || !selected || selected.kind === "directory" || !browser.selectedMetadata,
7262
+ disabled: downloading || !selected || selected.kind === "directory" || !browser.selectedMetadata || !content?.canDownload,
7175
7263
  onClick: onDownload,
7176
7264
  children: [/* @__PURE__ */ jsx(Icon, { name: "download" }), t("download")]
7177
7265
  }), /* @__PURE__ */ jsxs(Button, {
@@ -8430,19 +8518,31 @@ function registerFiles(ctx) {
8430
8518
  ctx.styles.add(styles);
8431
8519
  const model = new FileBrowser(ctx);
8432
8520
  ctx.services.provide("file-reference-actions", { actions(description) {
8433
- if (description.source.kind !== "workspace-file") return [];
8521
+ if (description.source.kind !== "gateway-file") return [];
8434
8522
  const source = description.source;
8435
- const kind = description.metadata.metadata && typeof description.metadata.metadata === "object" && "kind" in description.metadata.metadata ? description.metadata.metadata.kind : void 0;
8523
+ const state = model.get();
8524
+ if (!state.workspaceId) return [];
8525
+ const separator = source.pathStyle === "windows" ? /[\\/]/ : /\//;
8526
+ const parts = source.absolutePath.split(separator);
8527
+ if (parts.some((part) => part === "." || part === "..")) return [];
8528
+ const root = state.roots.find((item) => {
8529
+ const rootParts = item.path.split(separator).filter(Boolean);
8530
+ const pathParts = parts.filter(Boolean);
8531
+ return rootParts.every((part, index) => pathParts[index] === part);
8532
+ });
8533
+ if (!root) return [];
8534
+ const relative = parts.filter(Boolean).slice(root.path.split(separator).filter(Boolean).length);
8535
+ const target = {
8536
+ workspaceId: state.workspaceId,
8537
+ rootId: root.id,
8538
+ path: relative.slice(0, -1).join("/")
8539
+ };
8436
8540
  return [{
8437
8541
  id: "locate",
8438
8542
  label: ctx.i18n.t("locate"),
8439
8543
  run: () => {
8440
8544
  if (ctx.signal.aborted || ctx.navigation.openPage("files").status !== "opened") return;
8441
- model.locate({
8442
- workspaceId: source.workspaceId,
8443
- rootId: source.rootId,
8444
- path: kind === "directory" ? source.path : source.path.split("/").slice(0, -1).join("/")
8445
- });
8545
+ model.locate(target);
8446
8546
  }
8447
8547
  }];
8448
8548
  } });