| 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 |