返回 DeepSeek-Reasonix
fileNavigationOwner.ts
根目录 / desktop / frontend / src / lib / fileNavigationOwner.ts
1 import {
2 sameAccessContext,
3 type FileAccessContext,
4 type FileResourceRef,
5 type ResolvedFileResource,
6 } from "./fileResource";
7
8 /** Navigation parameters: how a resource is shown, never what identifies it. */
9 export type FileNavigationParams = Readonly<{
10 action: "preview" | "source" | "reveal-tree";
11 /** Dock surface the command targets: the file preview or the change list. */
12 view: "files" | "changed";
13 }>;
14
15 export type FileNavigationAction = FileNavigationParams["action"];
16
17 /** What an open command produced, for the row or preview area that asked for it. */
18 export type FileNavigationOutcome =
19 | Readonly<{ status: "opened"; resource: ResolvedFileResource }>
20 | Readonly<{ status: "cancelled"; reason: "superseded" | "closed" | "disposed" | "unavailable" }>
21 | Readonly<{ status: "failed"; error: Error }>;
22
23 /** One open preview: the confirmed resource plus the mode its tab renders in. */
24 export type FilePreviewEntry = Readonly<{ resource: ResolvedFileResource; source: boolean }>;
25
26 export type FileNavigationCommand = Readonly<{ ref: FileResourceRef; params: FileNavigationParams }>;
27
28 /**
29 * A dock instance: the dock tab that shows a resource, and the session tab
30 * whose scope authorizes reading it. The dock tab identifies the record — a
31 * project keeps its previews when the session changes — while the session tab
32 * only decides the credentials, so `bindScope` rebinds it.
33 */
34 export type FileNavigationScope = Readonly<{ sessionTabId: string; dockTabId: string }>;
35
36 export const fileNavigationKey = (scope: FileNavigationScope): string => scope.dockTabId;
37
38 /** The last explicit navigation this record committed; a render never replays it. */
39 export type FileNavigationIntent = Readonly<{
40 /** Advances per navigation command; a repeat of the same one keeps the value. */
41 revision: number;
42 resource: ResolvedFileResource;
43 params: FileNavigationParams;
44 }>;
45
46 /**
47 * What a dock currently shows, in two parts. `resource` names the space the
48 * paths belong to (a project, a remote host): another one replaces the record.
49 * `session` names the credentials the space is read with (a topic, a session
50 * generation): another one keeps what is on screen but drops the access
51 * contexts captured under it.
52 */
53 export type FileNavigationScopeKey = Readonly<{ resource: string; session: string }>;
54
55 export type FileNavigationSnapshot = Readonly<{
56 /** What this dock currently shows; null until a panel binds one. */
57 scope: FileNavigationScopeKey | null;
58 sessionTabId: string;
59 dockTabId: string;
60 /** Bumped whenever this dock's record is dropped and created again. */
61 generation: number;
62 /** Open preview entries, least recently used first. */
63 entries: readonly FilePreviewEntry[];
64 /** File-preview selection: the entry the preview area shows. */
65 selected: FilePreviewEntry | null;
66 /** Paths rendering as source, in entry order. */
67 sourcePaths: readonly string[];
68 /** Last explicit navigation command. */
69 navigation: FileNavigationIntent | null;
70 /** Display revision: bumped by every command that changed what is shown. */
71 revision: number;
72 /** Read-input revision: bumped only when the selected file's read inputs change. */
73 contentRevision: number;
74 /** Advances with every reveal-tree command so a repeat still expands the tree. */
75 treeReveal: number;
76 /** Aborted when this record's lifetime ends: dock closed, scope or runtime changed. */
77 signal: AbortSignal;
78 }>;
79
80 export const FILE_PREVIEW_LIMIT = 5;
81
82 export type FileNavigationPorts = {
83 /** Confirm a caller reference through the existing backend entry points. */
84 resolve(ref: FileResourceRef): ResolvedFileResource | Promise<ResolvedFileResource>;
85 /** Bring the dock that presents this resource forward; returns its dock tab id. */
86 revealDock(ref: FileResourceRef): string;
87 };
88
89 export type FileNavigationRestore = Readonly<{
90 paths: readonly string[];
91 selectedPath: string | null;
92 hostId: string;
93 }>;
94
95 /** A one-shot operation in a dock instance, e.g. creating a browser preview. */
96 export type FileNavigationOperation = Readonly<{
97 signal: AbortSignal;
98 /** True while no newer operation for the same dock has taken over. */
99 owns(): boolean;
100 finish(): void;
101 }>;
102
103 type Record = {
104 snapshot: FileNavigationSnapshot;
105 /** Lifetime of this dock instance's record; a reset aborts it. */
106 lifetime: AbortController;
107 /** Open command still resolving; a newer one supersedes it. */
108 pending: { controller: AbortController; resource: string | null } | null;
109 };
110
111 const SUPERSEDED: FileNavigationOutcome = { status: "cancelled", reason: "superseded" };
112 /** The resource space a command moves its dock to, when the dock is host-scoped. */
113 const resourceOf = (ref: FileResourceRef): string | null => (ref.hostId === "local" ? null : ref.hostId);
114 const asError = (reason: unknown): Error => (reason instanceof Error ? reason : new Error(String(reason)));
115 const isPromise = <T>(value: T | Promise<T>): value is Promise<T> =>
116 typeof (value as { then?: unknown } | null)?.then === "function";
117
118 /**
119 * Navigation state for one running app instance, held outside React.
120 *
121 * Records are keyed by dock instance. A record carries the
122 * resource identity, its access context, the navigation parameters and a
123 * monotonic revision, so a panel reads a committed result instead of publishing
124 * requests while it renders. Only an explicit command advances a revision, an
125 * unchanged record returns the same snapshot reference, and a record whose dock
126 * closed, scope changed or runtime went away is cancelled rather than replayed.
127 */
128 export class FileNavigationOwner {
129 private records = new Map<string, Record>();
130 /** One-shot operations per dock instance, keyed like records. */
131 private operations = new Map<string, AbortController>();
132 /** Survives record deletion so a reused dock tab id never repeats a generation. */
133 private generations = new Map<string, number>();
134 /** One counter for the whole instance, so no two records repeat a revision. */
135 private navigationRevision = 0;
136 private listeners = new Set<() => void>();
137 private disposed = false;
138 private ports: FileNavigationPorts;
139
140 constructor(ports: FileNavigationPorts) {
141 this.ports = ports;
142 }
143
144 subscribe = (listener: () => void): (() => void) => {
145 this.listeners.add(listener);
146 return () => { this.listeners.delete(listener); };
147 };
148
149 /** Stable per key: an unchanged record returns the same snapshot reference. */
150 getSnapshot = (key: string): FileNavigationSnapshot | null => this.records.get(key)?.snapshot ?? null;
151
152 /**
153 * Bind what a dock shows. Another resource space starts a new lifetime;
154 * another session in the same space keeps the previews and their positions
155 * but drops the access context they were read with, because a presented tool
156 * scope belongs to the session that captured it.
157 */
158 bindScope(scope: FileNavigationScope, key: FileNavigationScopeKey): void {
159 const record = this.ensure(scope);
160 const current = record.snapshot.scope;
161 if (current?.resource === key.resource && current.session === key.session) return;
162 if (current === null) {
163 record.snapshot = { ...record.snapshot, scope: key };
164 this.notify();
165 return;
166 }
167 if (current.resource !== key.resource) {
168 // A command that is moving this dock to another resource space is still
169 // resolving: it owns the dock now, so the bind drops the previous space's
170 // previews instead of cancelling the command that caused the switch.
171 if (record.pending && record.pending.resource === key.resource) this.replaceResource(record, key);
172 else this.reset(scope, key);
173 return;
174 }
175 this.rebind(scope, record, key);
176 }
177
178 /**
179 * Keep only the given dock keys, cancelling the rest. Called with the open
180 * dock tabs of the active session, so a closed tab, another session or
181 * another workspace ends its records instead of restoring stale access.
182 */
183 retain(openKeys: Iterable<string>): void {
184 const keep = new Set(openKeys);
185 let dropped = false;
186 for (const [key, record] of Array.from(this.records)) {
187 if (keep.has(key)) continue;
188 this.drop(key, record);
189 dropped = true;
190 }
191 for (const [key, operation] of Array.from(this.operations)) {
192 if (keep.has(key)) continue;
193 operation.abort();
194 this.operations.delete(key);
195 }
196 if (dropped) this.notify();
197 }
198
199 /**
200 * Begin a one-shot operation in a dock instance. A newer operation for the
201 * same dock supersedes it, and the dock's lifetime ending cancels it; an
202 * unrelated dock keeps running, so no panel cancels another's work.
203 */
204 beginOperation(scope: FileNavigationScope): FileNavigationOperation {
205 const key = fileNavigationKey(scope);
206 const previous = this.operations.get(key);
207 previous?.abort();
208 const controller = new AbortController();
209 if (this.disposed) controller.abort();
210 this.operations.set(key, controller);
211 return {
212 signal: controller.signal,
213 owns: () => this.operations.get(key) === controller && !controller.signal.aborted,
214 finish: () => { if (this.operations.get(key) === controller) this.operations.delete(key); },
215 };
216 }
217
218 /**
219 * Open a resource in the dock that presents it. Callers outside a dock (a
220 * transcript row, a Markdown link) use this; a panel acting on its own
221 * contents uses `openIn`, because it already is the target dock.
222 */
223 open(command: FileNavigationCommand): FileNavigationOutcome | Promise<FileNavigationOutcome> {
224 if (this.disposed) return { status: "cancelled", reason: "disposed" };
225 let scope: FileNavigationScope;
226 try {
227 scope = { sessionTabId: command.ref.tabId, dockTabId: this.ports.revealDock(command.ref) };
228 } catch (error) {
229 return { status: "failed", error: asError(error) };
230 }
231 return this.openIn(scope, command);
232 }
233
234 /** Open a resource in a dock instance the caller already identified. */
235 openIn(
236 scope: FileNavigationScope,
237 command: FileNavigationCommand,
238 ): FileNavigationOutcome | Promise<FileNavigationOutcome> {
239 if (this.disposed) return { status: "cancelled", reason: "disposed" };
240 const key = fileNavigationKey(scope);
241 const record = this.ensure(scope);
242 const operation = this.begin(record, resourceOf(command.ref));
243 let resolved: ResolvedFileResource | Promise<ResolvedFileResource>;
244 try {
245 resolved = this.ports.resolve(command.ref);
246 } catch (error) {
247 return this.settle(key, record, operation, SUPERSEDED, (): FileNavigationOutcome =>
248 ({ status: "failed", error: asError(error) }));
249 }
250 if (!isPromise(resolved)) {
251 return this.settle(key, record, operation, SUPERSEDED, (): FileNavigationOutcome => {
252 this.commitNavigation(record, resolved, command.params);
253 return { status: "opened", resource: resolved };
254 });
255 }
256 return resolved.then(
257 (resource) => this.settle(key, record, operation, SUPERSEDED, (): FileNavigationOutcome => {
258 this.commitNavigation(record, resource, command.params);
259 return { status: "opened", resource };
260 }),
261 // A resolution that failed after its dock went away is a cancellation:
262 // the row that asked is gone, and its failure has nowhere to be shown.
263 (error) => this.settle(key, record, operation, SUPERSEDED, (): FileNavigationOutcome =>
264 ({ status: "failed", error: asError(error) })),
265 );
266 }
267
268 /** Activate an open entry; it keeps the access context of the command that opened it. */
269 selectEntry(scope: FileNavigationScope, path: string): void {
270 const record = this.records.get(fileNavigationKey(scope));
271 if (!record) return;
272 this.supersede(record);
273 const entry = record?.snapshot.entries.find((candidate) => candidate.resource.path === path);
274 if (!record || !entry) return;
275 this.commitNavigation(record,entry.resource, {
276 action: entry.source ? "source" : "preview",
277 view: "files",
278 });
279 }
280
281 /**
282 * Select a path that carries no presentation of its own — a recent file or a
283 * restored session. The read goes through the current workspace access, so a
284 * path alone never re-grants the permissions of an earlier presentation.
285 */
286 selectPath(scope: FileNavigationScope, resource: FileResourceIdentityInput): FileNavigationOutcome | Promise<FileNavigationOutcome> | void {
287 const record = this.records.get(fileNavigationKey(scope));
288 if (!record) return;
289 if (resource.hostId === "local") {
290 return this.openIn(scope, {
291 ref: { source: "workspace", hostId: resource.hostId, tabId: scope.sessionTabId, path: resource.path },
292 params: { action: "preview", view: "files" },
293 });
294 }
295 // Remote tree paths come from ListRemoteDir and are already host coordinates;
296 // resolving them as derived artifacts would require a tool-call grant they do
297 // not carry.
298 this.supersede(record);
299 this.commitNavigation(record, workspaceResource(resource, scope.sessionTabId), { action: "preview", view: "files" });
300 return { status: "opened", resource: record.snapshot.selected!.resource };
301 }
302
303 /** Switch an open tab between its preview and its source, reusing the tab. */
304 setSourceMode(scope: FileNavigationScope, path: string, source: boolean): void {
305 const record = this.records.get(fileNavigationKey(scope));
306 if (!record) return;
307 this.supersede(record);
308 const entry = record?.snapshot.entries.find((candidate) => candidate.resource.path === path);
309 if (!record || !entry) return;
310 this.commitNavigation(record, entry.resource, { action: source ? "source" : "preview", view: "files" });
311 }
312
313 /** Close one preview tab, selecting the neighbour the previous tab list implies. */
314 closeEntry(scope: FileNavigationScope, path: string): void {
315 const record = this.records.get(fileNavigationKey(scope));
316 if (!record) return;
317 this.supersede(record);
318 const current = record.snapshot;
319 const entries = current.entries.filter((entry) => entry.resource.path !== path);
320 if (entries.length === current.entries.length) return;
321 const selected = current.selected?.resource.path === path ? entries[entries.length - 1] ?? null : current.selected;
322 this.commitState(record, { entries, selected });
323 }
324
325 /**
326 * Drop every preview tab of this dock, as a scoped file or change list does.
327 * The selection survives, so leaving the scope shows the file again without
328 * a new command; `clearSelection` is what closes the preview itself.
329 */
330 clearEntries(scope: FileNavigationScope): void {
331 const record = this.records.get(fileNavigationKey(scope));
332 if (!record) return;
333 this.supersede(record);
334 this.commitState(record, { entries: [] });
335 }
336
337 clearSelection(scope: FileNavigationScope): void {
338 const record = this.records.get(fileNavigationKey(scope));
339 if (!record) return;
340 this.supersede(record);
341 this.commitState(record, { selected: null });
342 }
343
344 /**
345 * Restore remembered paths for a dock no command has opened anything in yet.
346 * Restored entries carry workspace access only; a record a command already
347 * wrote to is left alone, so a restore never outranks a live navigation.
348 */
349 restore(scope: FileNavigationScope, state: FileNavigationRestore): void | Promise<void> {
350 const record = this.records.get(fileNavigationKey(scope));
351 if (!record) return;
352 const current = record.snapshot;
353 if (record.pending || current.entries.length > 0 || current.selected || !state.paths.length) return;
354 const resources = state.paths.map((path): ResolvedFileResource | Promise<ResolvedFileResource> | null => {
355 try {
356 return this.ports.resolve({
357 source: "workspace",
358 hostId: state.hostId,
359 tabId: scope.sessionTabId,
360 path,
361 });
362 } catch {
363 return null;
364 }
365 });
366 const commit = (resolved: readonly (ResolvedFileResource | null)[]): void => {
367 if (this.records.get(fileNavigationKey(scope)) !== record || record.snapshot !== current || record.pending) return;
368 const entries = resolved
369 .filter((resource): resource is ResolvedFileResource => resource !== null)
370 .reduce((all, resource) => upsertEntry(all, resource, false).entries, [] as readonly FilePreviewEntry[]);
371 if (!entries.length) return;
372 this.commitState(record, {
373 entries,
374 selected: state.selectedPath
375 ? entries.find((entry) => entry.resource.requestedPath === state.selectedPath) ?? null
376 : null,
377 });
378 };
379 if (!resources.some((resource) => resource !== null && isPromise(resource))) {
380 commit(resources as (ResolvedFileResource | null)[]);
381 return;
382 }
383 return Promise.all(resources.map((resource) => Promise.resolve(resource).catch(() => null))).then(commit);
384 }
385
386 dispose(): void {
387 this.disposed = true;
388 for (const [key, record] of Array.from(this.records)) this.drop(key, record);
389 this.records.clear();
390 for (const operation of Array.from(this.operations.values())) operation.abort();
391 this.operations.clear();
392 this.notify();
393 }
394
395 private ensure(scope: FileNavigationScope): Record {
396 const key = fileNavigationKey(scope);
397 const existing = this.records.get(key);
398 if (existing) return existing;
399 const lifetime = new AbortController();
400 const record: Record = {
401 lifetime,
402 pending: null,
403 snapshot: {
404 scope: null,
405 sessionTabId: scope.sessionTabId,
406 dockTabId: scope.dockTabId,
407 generation: this.generations.get(scope.dockTabId) ?? 0,
408 entries: [],
409 selected: null,
410 sourcePaths: [],
411 navigation: null,
412 revision: 0,
413 contentRevision: 0,
414 treeReveal: 0,
415 signal: lifetime.signal,
416 },
417 };
418 this.records.set(key, record);
419 return record;
420 }
421
422 /** Enter another resource space, keeping an operation that is moving the dock. */
423 private replaceResource(record: Record, key: FileNavigationScopeKey): void {
424 this.apply(record, (current) => ({
425 ...current,
426 scope: key,
427 entries: [],
428 selected: null,
429 sourcePaths: [],
430 revision: current.revision + 1,
431 contentRevision: current.contentRevision + 1,
432 }));
433 }
434
435 private reset(scope: FileNavigationScope, key: FileNavigationScopeKey): void {
436 const recordKey = fileNavigationKey(scope);
437 const previous = this.records.get(recordKey);
438 if (previous) this.drop(recordKey, previous);
439 const record = this.ensure(scope);
440 record.snapshot = { ...record.snapshot, scope: key };
441 this.notify();
442 }
443
444 /**
445 * The same resource space under another session: the previews stay exactly
446 * where the user left them, and every read is rebound to the current session
447 * with workspace credentials, so a tool call from the previous session can
448 * never authorize a read in this one.
449 */
450 private rebind(scope: FileNavigationScope, record: Record, key: FileNavigationScopeKey): void {
451 const access: FileAccessContext = { source: "workspace", tabId: scope.sessionTabId };
452 const downgrade = (entry: FilePreviewEntry): FilePreviewEntry =>
453 ({ resource: { ...entry.resource, access }, source: entry.source });
454 this.supersede(record);
455 this.apply(record, (current) => ({
456 ...current,
457 scope: key,
458 entries: current.entries.map(downgrade),
459 selected: current.selected ? downgrade(current.selected) : null,
460 revision: current.revision + 1,
461 contentRevision: current.contentRevision + 1,
462 }));
463 }
464
465 /**
466 * A synchronous command takes the dock over: whatever open was still
467 * resolving for it can no longer commit, so a click is never replaced by an
468 * earlier command's late result.
469 */
470 private supersede(record: Record): void {
471 record.pending?.controller.abort();
472 record.pending = null;
473 }
474
475 private begin(record: Record, resource: string | null): AbortController {
476 record.pending?.controller.abort();
477 const operation = new AbortController();
478 record.pending = { controller: operation, resource };
479 return operation;
480 }
481
482 /** Commit only while this operation still owns the record; otherwise it lost. */
483 private settle<T>(
484 key: string,
485 record: Record,
486 operation: AbortController,
487 lost: FileNavigationOutcome,
488 commit?: () => T,
489 ): T | FileNavigationOutcome {
490 if (this.records.get(key) !== record || record.pending?.controller !== operation || operation.signal.aborted) return lost;
491 record.pending = null;
492 return commit ? commit() : lost;
493 }
494
495 private commitNavigation(
496 record: Record,
497 resource: ResolvedFileResource,
498 params: FileNavigationParams,
499 ): void {
500 this.apply(record, (current) => {
501 const { entries, selected } = upsertEntry(current.entries, resource, params.action === "source");
502 const target = params.view === "files" ? selected : current.selected;
503 // Every explicit command is delivered as its own navigation revision: a
504 // repeat must still reveal the file when the dock moved on. Only the read
505 // inputs decide whether the preview has to load its content again.
506 this.navigationRevision += 1;
507 return {
508 ...current,
509 entries,
510 selected: target,
511 sourcePaths: sourcePathsOf(entries),
512 navigation: { revision: this.navigationRevision, resource, params },
513 revision: current.revision + 1,
514 contentRevision: current.contentRevision + (target !== current.selected ? 1 : 0),
515 treeReveal: params.action === "reveal-tree" ? current.treeReveal + 1 : current.treeReveal,
516 };
517 });
518 }
519
520 private commitState(
521 record: Record,
522 patch: { entries?: readonly FilePreviewEntry[]; selected?: FilePreviewEntry | null },
523 ): void {
524 this.apply(record, (current) => {
525 const entries = patch.entries ?? current.entries;
526 const selected = patch.selected !== undefined ? patch.selected : current.selected;
527 if (entries === current.entries && selected === current.selected) return null;
528 return {
529 ...current,
530 entries,
531 selected,
532 sourcePaths: sourcePathsOf(entries),
533 revision: current.revision + 1,
534 contentRevision: current.contentRevision + (selected !== current.selected ? 1 : 0),
535 };
536 });
537 }
538
539 private apply(record: Record, reduce: (current: FileNavigationSnapshot) => FileNavigationSnapshot | null): void {
540 const next = reduce(record.snapshot);
541 if (!next) return;
542 record.snapshot = next;
543 this.notify();
544 }
545
546 private drop(key: string, record: Record): void {
547 record.pending?.controller.abort();
548 record.lifetime.abort();
549 this.records.delete(key);
550 this.generations.set(record.snapshot.dockTabId, record.snapshot.generation + 1);
551 }
552
553 private notify(): void {
554 for (const listener of Array.from(this.listeners)) listener();
555 }
556 }
557
558 export type FileResourceIdentityInput = Readonly<{ hostId: string; path: string }>;
559
560 function workspaceResource(resource: FileResourceIdentityInput, sessionTabId: string): ResolvedFileResource {
561 const access: FileAccessContext = { source: "workspace", tabId: sessionTabId };
562 return {
563 hostId: resource.hostId,
564 path: resource.path,
565 identityPath: resource.path.replace(/\\/g, "/"),
566 requestedPath: resource.path,
567 access,
568 };
569 }
570
571 function sameResource(left: ResolvedFileResource, right: ResolvedFileResource): boolean {
572 return left.hostId === right.hostId
573 && left.identityPath === right.identityPath
574 && left.path === right.path
575 && sameAccessContext(left.access, right.access);
576 }
577
578 const sourcePathsOf = (entries: readonly FilePreviewEntry[]): readonly string[] =>
579 entries.filter((entry) => entry.source).map((entry) => entry.resource.path);
580
581 /**
582 * Move an entry to the most-recent position. An entry whose resource and mode
583 * are unchanged keeps its identity, and an unchanged list keeps its array
584 * reference, so a repeated command leaves every derived value — including the
585 * preview's read inputs — exactly as it was.
586 */
587 function upsertEntry(
588 entries: readonly FilePreviewEntry[],
589 resource: ResolvedFileResource,
590 source: boolean,
591 ): { entries: readonly FilePreviewEntry[]; selected: FilePreviewEntry } {
592 const existing = entries.find((candidate) => candidate.resource.identityPath === resource.identityPath);
593 const entry = existing && existing.source === source && sameResource(existing.resource, resource)
594 ? existing
595 : { resource, source };
596 const next = [...entries.filter((candidate) => candidate.resource.identityPath !== resource.identityPath), entry]
597 .slice(-FILE_PREVIEW_LIMIT);
598 const unchanged = next.length === entries.length && next.every((candidate, index) => candidate === entries[index]);
599 return { entries: unchanged ? entries : next, selected: entry };
600 }
601
601 lines TYPESCRIPT