@neuralis/brain-core 0.2.0 → 0.3.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.
- package/README.md +8 -3
- package/app/README.md +1 -1
- package/app/filesystem/README.md +3 -3
- package/app/filesystem/api/README.md +4 -4
- package/app/filesystem/api/index.ts +144 -139
- package/app/filesystem/domain/types.ts +12 -0
- package/app/filesystem/hooks/README.md +5 -5
- package/app/filesystem/hooks/useDiscoverableSources.ts +4 -2
- package/app/filesystem/hooks/useFilesystemSSE.ts +31 -23
- package/app/filesystem/hooks/useGitChangesInventory.ts +4 -2
- package/app/filesystem/host/README.md +3 -2
- package/app/filesystem/host/installFilesystemHostComponents.tsx +22 -9
- package/app/filesystem/store/README.md +12 -8
- package/app/filesystem/store/filesystemStore.ts +444 -163
- package/app/filesystem/store/gitSlice.ts +6 -6
- package/app/filesystem/store/listSlice.ts +9 -6
- package/app/filesystem/store/searchSlice.ts +3 -3
- package/app/filesystem/ui/FileEditor.tsx +90 -39
- package/app/filesystem/ui/FileTree.tsx +10 -9
- package/app/filesystem/ui/GitCommitChangesView.tsx +5 -3
- package/app/filesystem/ui/GitCommitGraph.tsx +6 -5
- package/app/filesystem/ui/GitControlPanel.tsx +14 -14
- package/app/filesystem/ui/GitDiffView.tsx +14 -6
- package/app/filesystem/ui/GitFileDiff.tsx +22 -15
- package/app/filesystem/ui/PendingChangesPanel.tsx +108 -44
- package/app/filesystem/ui/PendingReviewDialog.tsx +8 -5
- package/app/filesystem/ui/README.md +9 -9
- package/app/filesystem/ui/ReembedDialog.tsx +4 -2
- package/app/filesystem/ui/SourcesPanel.tsx +26 -28
- package/app/filesystem/ui/Toolbar.tsx +5 -4
- package/app/filesystem/ui/VectorHealthPanel.tsx +48 -17
- package/dist/app/host.js +46 -46
- package/dist/src/config/env.d.ts +4 -0
- package/dist/src/config/env.d.ts.map +1 -1
- package/dist/src/config/env.js +13 -0
- package/dist/src/config/env.js.map +1 -1
- package/dist/src/connectors/LocalDiskConnector.d.ts +9 -1
- package/dist/src/connectors/LocalDiskConnector.d.ts.map +1 -1
- package/dist/src/connectors/LocalDiskConnector.js +51 -2
- package/dist/src/connectors/LocalDiskConnector.js.map +1 -1
- package/dist/src/domain/healthTypes.d.ts +1 -7
- package/dist/src/domain/healthTypes.d.ts.map +1 -1
- package/dist/src/domain/pendingChange.d.ts +31 -0
- package/dist/src/domain/pendingChange.d.ts.map +1 -1
- package/dist/src/domain/pendingChange.js.map +1 -1
- package/dist/src/routes/health.js +2 -3
- package/dist/src/routes/health.js.map +1 -1
- package/dist/src/routes/pending.d.ts +7 -7
- package/dist/src/routes/pending.d.ts.map +1 -1
- package/dist/src/routes/pending.js +21 -21
- package/dist/src/routes/pending.js.map +1 -1
- package/dist/src/services/FilesystemService.d.ts +13 -61
- package/dist/src/services/FilesystemService.d.ts.map +1 -1
- package/dist/src/services/FilesystemService.js +47 -189
- package/dist/src/services/FilesystemService.js.map +1 -1
- package/dist/src/services/FilesystemUiService.d.ts +33 -1
- package/dist/src/services/FilesystemUiService.d.ts.map +1 -1
- package/dist/src/services/FilesystemUiService.js +50 -1
- package/dist/src/services/FilesystemUiService.js.map +1 -1
- package/dist/src/store/PendingChangeStore.d.ts +3 -0
- package/dist/src/store/PendingChangeStore.d.ts.map +1 -1
- package/dist/src/store/PendingChangeStore.js +84 -25
- package/dist/src/store/PendingChangeStore.js.map +1 -1
- package/dist/src/usecases/orphanedPending.d.ts +2 -2
- package/dist/src/usecases/orphanedPending.d.ts.map +1 -1
- package/dist/src/usecases/orphanedPending.js +5 -3
- package/dist/src/usecases/orphanedPending.js.map +1 -1
- package/dist/src/usecases/pendingListing.d.ts +34 -0
- package/dist/src/usecases/pendingListing.d.ts.map +1 -0
- package/dist/src/usecases/pendingListing.js +244 -0
- package/dist/src/usecases/pendingListing.js.map +1 -0
- package/dist/src/usecases/vectorMaintenance.d.ts +6 -9
- package/dist/src/usecases/vectorMaintenance.d.ts.map +1 -1
- package/dist/src/usecases/vectorMaintenance.js +4 -13
- package/dist/src/usecases/vectorMaintenance.js.map +1 -1
- package/dist/src/vector/InMemoryVectorIndex.d.ts.map +1 -1
- package/dist/src/vector/InMemoryVectorIndex.js +30 -9
- package/dist/src/vector/InMemoryVectorIndex.js.map +1 -1
- package/package.json +27 -5
- package/rules/brain-rules.md +1 -1
- package/skills/create-package/SKILL.md +3 -3
- package/skills/create-package/references/config-and-credentials.md +1 -1
- package/skills/create-package/references/manifest-and-discovery.md +1 -1
- package/skills/create-package/references/runtime-and-build.md +1 -1
- package/skills/create-package/references/skills-and-context.md +25 -12
- package/skills/create-package/references/surfaces-and-design.md +7 -5
- package/skills/create-package/references/test-and-ship.md +9 -16
- package/skills/create-package/scripts/check-package.sh +47 -1
- package/skills/create-package/scripts/verify-loaded.sh +18 -27
- package/skills/git/SKILL.md +27 -29
- package/skills/manage-files/SKILL.md +37 -32
- package/skills/manage-files/scripts/upload.sh +7 -9
- package/skills/manage-files/scripts/write.sh +2 -6
- package/skills/manage-sources/SKILL.md +93 -95
- package/skills/manage-sources/scripts/reembed.sh +2 -6
- package/skills/manage-uri-policy/SKILL.md +30 -58
- package/app/filesystem/hooks/useContainerWidth.ts +0 -39
- package/skills/git/scripts/git.sh +0 -48
- package/skills/manage-files/scripts/approve.sh +0 -9
- package/skills/manage-files/scripts/list.sh +0 -9
- package/skills/manage-files/scripts/pending.sh +0 -23
- package/skills/manage-files/scripts/raw.sh +0 -11
- package/skills/manage-files/scripts/read.sh +0 -9
- package/skills/manage-files/scripts/search.sh +0 -15
- package/skills/manage-sources/scripts/browse.sh +0 -12
- package/skills/manage-sources/scripts/cancel.sh +0 -14
- package/skills/manage-sources/scripts/create.sh +0 -13
- package/skills/manage-sources/scripts/delete.sh +0 -17
- package/skills/manage-sources/scripts/discoverable.sh +0 -6
- package/skills/manage-sources/scripts/get.sh +0 -7
- package/skills/manage-sources/scripts/kind-schema.sh +0 -7
- package/skills/manage-sources/scripts/kinds.sh +0 -6
- package/skills/manage-sources/scripts/list.sh +0 -6
- package/skills/manage-sources/scripts/purge-index.sh +0 -18
- package/skills/manage-sources/scripts/reindex.sh +0 -13
- package/skills/manage-sources/scripts/reset.sh +0 -11
- package/skills/manage-sources/scripts/set-enabled.sh +0 -17
- package/skills/manage-sources/scripts/sync-status.sh +0 -14
- package/skills/manage-sources/scripts/sync.sh +0 -13
- package/skills/manage-sources/scripts/update.sh +0 -18
- package/skills/manage-uri-policy/scripts/manifests.sh +0 -20
- package/skills/manage-uri-policy/scripts/set-uri.sh +0 -66
- package/skills/manage-uri-policy/scripts/show-uri.sh +0 -23
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createContext, createElement, useContext, useLayoutEffect, useMemo, type ReactNode } from 'react';
|
|
2
|
+
import { useStore } from 'zustand';
|
|
2
3
|
import { persist, createJSONStorage } from 'zustand/middleware';
|
|
4
|
+
import { createStore, type StoreApi as ZustandStoreApi } from 'zustand/vanilla';
|
|
3
5
|
import type { ClientStateResetScope } from '@neuralis/package-system/contracts';
|
|
4
|
-
import
|
|
6
|
+
import { createPackageApi, type PackageApi } from '@neuralis/package-system/client';
|
|
7
|
+
import type { EditorTabMeta, FileNode, FileSystemState, FSWriteResponse, PersistedFilesContext, PersistedFilesWindow, SidebarView, WorkspacePanelId } from '../domain/types';
|
|
5
8
|
export type { SidebarView, WorkspacePanelId } from '../domain/types';
|
|
6
9
|
import { countPendingChanges, readFiles, uploadFile, writeFile, writeRefusalMessage } from '../api';
|
|
7
10
|
import {
|
|
@@ -42,44 +45,6 @@ import { gitSliceInitial, createGitSlice, type GitSliceState, type GitSliceActio
|
|
|
42
45
|
*/
|
|
43
46
|
export const FILES_LRU_HARD_CAP = 50_000;
|
|
44
47
|
|
|
45
|
-
/**
|
|
46
|
-
* Bumped by `fenceInFlightWriters` — the ONE fence BOTH reset paths run before
|
|
47
|
-
* their wipe: `resetClientState` (the dock broom) and `loadList`'s agent/project
|
|
48
|
-
* switch. Every async writer that materialises a node or content AFTER an await
|
|
49
|
-
* (the `openFile` read, a reconcile pass's `set`, the optimistic folder in
|
|
50
|
-
* `createNode`, the editor tails `saveFileInPlace` / `markFileDeleted`) captures
|
|
51
|
-
* it BEFORE the await and writes only when it still matches — a read, listing
|
|
52
|
-
* or mutation answer that resolves after the reset must not put a tab, its
|
|
53
|
-
* folder chain, its content or its parent's dirty flag into a store that now
|
|
54
|
-
* belongs to another context. Module-level like the generation map: the reset
|
|
55
|
-
* is a store-wide event. Until 2026-09-04 only the broom fenced; the switch
|
|
56
|
-
* wiped without fencing, so a read a tab click had started under the previous
|
|
57
|
-
* agent could land its file — and `activeFile` — under the new one.
|
|
58
|
-
*
|
|
59
|
-
* Writers that need NO epoch (documented, not oversight): `moveFileToFolder` /
|
|
60
|
-
* `renameNode` / `pasteNodes` / `deleteNodes` / `upload*` are no-ops on an empty
|
|
61
|
-
* store (`replaceFilePath`, `prunePrefix`, `patchFileNode`, the `openFiles.map`
|
|
62
|
-
* all find nothing) or set a hint flag; a late `loadList` answer writes
|
|
63
|
-
* `brain://` + `sources`, which the remounted widget re-requests anyway; late
|
|
64
|
-
* `runSearch` / `loadGitStatus` / `loadGitBranches` answers are overwritten on
|
|
65
|
-
* the next use; `ensureFolderLoaded`'s superseded and catch branches write
|
|
66
|
-
* `hydrationState[uri]` only (`'idle'` / `'error'` for a folder row that is gone);
|
|
67
|
-
* the SSE handlers are synchronous and die with the widget; `applyNav` /
|
|
68
|
-
* `autoExpandToFile` write synchronously before any await.
|
|
69
|
-
*/
|
|
70
|
-
let resetEpoch = 0;
|
|
71
|
-
/** The newest `refreshPendingCount` call — an older answer never overwrites a newer one. */
|
|
72
|
-
let pendingCountSeq = 0;
|
|
73
|
-
/**
|
|
74
|
-
* Tabs whose rehydrate read is in flight — a second activation must not issue a
|
|
75
|
-
* second read. Keyed by uri, so the fence CLEARS it: the same uri under another
|
|
76
|
-
* context is another file, and a stale key would suppress the new context's read
|
|
77
|
-
* for good (the old answer is fenced out, the baseline never arrives).
|
|
78
|
-
*/
|
|
79
|
-
const rehydrateInFlight = new Set<string>();
|
|
80
|
-
|
|
81
|
-
const liveFolderLoadInFlight = new Map<string, Promise<void>>();
|
|
82
|
-
|
|
83
48
|
/**
|
|
84
49
|
* Per-source reconcile job. The guard used to DROP a request that arrived while a
|
|
85
50
|
* reconcile was running, which made every mutation tail unsafe: the drop silently
|
|
@@ -89,29 +54,78 @@ const liveFolderLoadInFlight = new Map<string, Promise<void>>();
|
|
|
89
54
|
* and the caller awaits the whole chain.
|
|
90
55
|
*/
|
|
91
56
|
type ReconcileJob = { rerun: boolean; wide: boolean; extra: Set<string>; promise: Promise<void> };
|
|
92
|
-
const reconcileJobs = new Map<string, ReconcileJob>();
|
|
93
57
|
|
|
94
58
|
/** Bound on coalesced reruns so a hot event stream can't spin the loop forever. */
|
|
95
59
|
const MAX_RECONCILE_PASSES = 3;
|
|
96
60
|
|
|
97
61
|
/**
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
62
|
+
* The asynchronous fences of ONE Files store — one set per window, created with
|
|
63
|
+
* it (`createFilesystemStore`). Two windows never share a fence: a reset or a
|
|
64
|
+
* context switch is that window's event, so it fences only that window's
|
|
65
|
+
* in-flight work, and a sibling window's listing can never supersede this one's.
|
|
66
|
+
*
|
|
67
|
+
* - `resetEpoch` — bumped by `fenceInFlightWriters`, the ONE fence BOTH reset
|
|
68
|
+
* paths run before their wipe: `resetClientState` (the dock broom) and
|
|
69
|
+
* `loadList`'s context switch. Every async writer that materialises a node or
|
|
70
|
+
* content AFTER an await (the `openFile` read, a reconcile pass's `set`, the
|
|
71
|
+
* optimistic folder in `createNode`, the editor tails `saveFileInPlace` /
|
|
72
|
+
* `markFileDeleted`) captures it BEFORE the await and writes only when it still
|
|
73
|
+
* matches — a read, listing or mutation answer that resolves after the reset
|
|
74
|
+
* must not put a tab, its folder chain, its content or its parent's dirty flag
|
|
75
|
+
* into a store that now belongs to another context. Writers that need NO epoch
|
|
76
|
+
* (documented, not oversight): `moveFileToFolder` / `renameNode` / `pasteNodes`
|
|
77
|
+
* / `deleteNodes` / `upload*` are no-ops on an empty store (`replaceFilePath`,
|
|
78
|
+
* `prunePrefix`, `patchFileNode`, the `openFiles.map` all find nothing) or set
|
|
79
|
+
* a hint flag; a late `loadList` answer writes `brain://` + `sources`, which the
|
|
80
|
+
* remounted widget re-requests anyway; late `runSearch` / `loadGitStatus` /
|
|
81
|
+
* `loadGitBranches` answers are overwritten on the next use;
|
|
82
|
+
* `ensureFolderLoaded`'s superseded and catch branches write
|
|
83
|
+
* `hydrationState[uri]` only; the SSE handlers are synchronous and die with the
|
|
84
|
+
* widget; `applyNav` / `autoExpandToFile` write synchronously before any await.
|
|
85
|
+
* - `pendingCountSeq` — the newest `refreshPendingCount` call; an older answer
|
|
86
|
+
* never overwrites a newer one.
|
|
87
|
+
* - `rehydrateInFlight` — tabs whose rehydrate read is in flight, so a second
|
|
88
|
+
* activation issues no second read. Keyed by uri, so the fence CLEARS it: the
|
|
89
|
+
* same uri under another context is another file, and a stale key would
|
|
90
|
+
* suppress the new context's read for good.
|
|
91
|
+
* - `liveFolderLoadInFlight` — the folder-listing dedup map.
|
|
92
|
+
* - `reconcileJobs` — the per-source coalescer (`ReconcileJob`).
|
|
93
|
+
* - `folderGeneration` — a monotonic list-generation token PER FOLDER (not per
|
|
94
|
+
* source). Two listings of the SAME folder can be in flight at once —
|
|
95
|
+
* `reconcileSourceFolders` lists its targets concurrently and
|
|
96
|
+
* `ensureFolderLoaded` can overlap it — so a slow response must not overwrite a
|
|
97
|
+
* newer one. Every list issue bumps its folder's counter; a response whose
|
|
98
|
+
* token is no longer current is discarded. A per-SOURCE token would not catch
|
|
99
|
+
* this: both listings share the source.
|
|
104
100
|
*/
|
|
105
|
-
|
|
101
|
+
type StoreFences = {
|
|
102
|
+
resetEpoch: number;
|
|
103
|
+
pendingCountSeq: number;
|
|
104
|
+
rehydrateInFlight: Set<string>;
|
|
105
|
+
liveFolderLoadInFlight: Map<string, Promise<void>>;
|
|
106
|
+
reconcileJobs: Map<string, ReconcileJob>;
|
|
107
|
+
folderGeneration: Map<string, number>;
|
|
108
|
+
};
|
|
106
109
|
|
|
107
|
-
function
|
|
108
|
-
|
|
109
|
-
|
|
110
|
+
function createStoreFences(): StoreFences {
|
|
111
|
+
return {
|
|
112
|
+
resetEpoch: 0,
|
|
113
|
+
pendingCountSeq: 0,
|
|
114
|
+
rehydrateInFlight: new Set<string>(),
|
|
115
|
+
liveFolderLoadInFlight: new Map<string, Promise<void>>(),
|
|
116
|
+
reconcileJobs: new Map<string, ReconcileJob>(),
|
|
117
|
+
folderGeneration: new Map<string, number>(),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function nextFolderGeneration(fences: StoreFences, folderKey: string): number {
|
|
122
|
+
const gen = (fences.folderGeneration.get(folderKey) ?? 0) + 1;
|
|
123
|
+
fences.folderGeneration.set(folderKey, gen);
|
|
110
124
|
return gen;
|
|
111
125
|
}
|
|
112
126
|
|
|
113
|
-
function isCurrentFolderGeneration(folderKey: string, gen: number): boolean {
|
|
114
|
-
return folderGeneration.get(folderKey) === gen;
|
|
127
|
+
function isCurrentFolderGeneration(fences: StoreFences, folderKey: string, gen: number): boolean {
|
|
128
|
+
return fences.folderGeneration.get(folderKey) === gen;
|
|
115
129
|
}
|
|
116
130
|
|
|
117
131
|
/** `pendingReviewPaths` transitions the shared mapper observed, applied by the caller. */
|
|
@@ -280,8 +294,8 @@ function withPendingDelta(
|
|
|
280
294
|
return out;
|
|
281
295
|
}
|
|
282
296
|
|
|
283
|
-
/**
|
|
284
|
-
type StoreApi = { get: () => any; set: (fn: (s: any) => any) => void };
|
|
297
|
+
/** The store handle the module-level reconcile engine needs: state, its window's fences and request client. */
|
|
298
|
+
type StoreApi = { get: () => any; set: (fn: (s: any) => any) => void; fences: StoreFences; client: PackageApi };
|
|
285
299
|
|
|
286
300
|
/**
|
|
287
301
|
* How many folder listings one reconcile pass has in flight. Each costs a server
|
|
@@ -399,7 +413,7 @@ async function runReconcilePass(
|
|
|
399
413
|
// Captured at pass START: the per-folder generation guard sits in the worker,
|
|
400
414
|
// so a listing set that landed BEFORE a broom would still be written by the
|
|
401
415
|
// `set()` below unless the pass itself is fenced.
|
|
402
|
-
const epoch = resetEpoch;
|
|
416
|
+
const epoch = api.fences.resetEpoch;
|
|
403
417
|
const state0 = api.get();
|
|
404
418
|
const lf = state0.loadedFolders || {};
|
|
405
419
|
const targetSet = new Set<string>();
|
|
@@ -442,12 +456,12 @@ async function runReconcilePass(
|
|
|
442
456
|
// walk plus an index pass. A tree-wide Refresh across several sources multiplies
|
|
443
457
|
// that again.
|
|
444
458
|
const listed = await mapWithLimit(targets, RECONCILE_LIST_CONCURRENCY, async (folderKey) => {
|
|
445
|
-
const gen = nextFolderGeneration(folderKey);
|
|
459
|
+
const gen = nextFolderGeneration(api.fences, folderKey);
|
|
446
460
|
const issuedAt = Date.now();
|
|
447
461
|
try {
|
|
448
|
-
const res = await resolveList(agentId, projectId ?? undefined, { uri: folderKey, recursive: false });
|
|
462
|
+
const res = await resolveList(agentId, projectId ?? undefined, { uri: folderKey, recursive: false }, api.client);
|
|
449
463
|
// A newer listing of the SAME folder superseded this one → drop it whole.
|
|
450
|
-
if (!isCurrentFolderGeneration(folderKey, gen)) return { folderKey, res: null, issuedAt, ok: false as const };
|
|
464
|
+
if (!isCurrentFolderGeneration(api.fences, folderKey, gen)) return { folderKey, res: null, issuedAt, ok: false as const };
|
|
451
465
|
return { folderKey, res, issuedAt, ok: true as const };
|
|
452
466
|
} catch {
|
|
453
467
|
return { folderKey, res: null, issuedAt, ok: false as const };
|
|
@@ -455,7 +469,7 @@ async function runReconcilePass(
|
|
|
455
469
|
});
|
|
456
470
|
|
|
457
471
|
api.set((state: any) => {
|
|
458
|
-
if (epoch !== resetEpoch) return state; // the broom emptied the store mid-pass → inert
|
|
472
|
+
if (epoch !== api.fences.resetEpoch) return state; // the broom emptied the store mid-pass → inert
|
|
459
473
|
const next = { ...state.files };
|
|
460
474
|
const ts = Date.now();
|
|
461
475
|
const pendingDelta: ListedPendingDelta = { add: new Set(), remove: new Set() };
|
|
@@ -612,7 +626,7 @@ async function runSourceReconcile(
|
|
|
612
626
|
const { agentId, projectId, source, targets } = params;
|
|
613
627
|
const wide = params.wide !== false;
|
|
614
628
|
if (!agentId || !source) return;
|
|
615
|
-
const existing = reconcileJobs.get(source);
|
|
629
|
+
const existing = api.fences.reconcileJobs.get(source);
|
|
616
630
|
if (existing) {
|
|
617
631
|
existing.rerun = true;
|
|
618
632
|
if (wide) existing.wide = true;
|
|
@@ -621,7 +635,7 @@ async function runSourceReconcile(
|
|
|
621
635
|
return;
|
|
622
636
|
}
|
|
623
637
|
const job: ReconcileJob = { rerun: false, wide, extra: new Set(targets ?? []), promise: Promise.resolve() };
|
|
624
|
-
reconcileJobs.set(source, job);
|
|
638
|
+
api.fences.reconcileJobs.set(source, job);
|
|
625
639
|
job.promise = (async () => {
|
|
626
640
|
try {
|
|
627
641
|
for (let pass = 0; pass < MAX_RECONCILE_PASSES; pass++) {
|
|
@@ -634,7 +648,7 @@ async function runSourceReconcile(
|
|
|
634
648
|
if (!job.rerun) break;
|
|
635
649
|
}
|
|
636
650
|
} finally {
|
|
637
|
-
reconcileJobs.delete(source);
|
|
651
|
+
api.fences.reconcileJobs.delete(source);
|
|
638
652
|
}
|
|
639
653
|
})();
|
|
640
654
|
await job.promise;
|
|
@@ -837,9 +851,11 @@ interface FilesystemStore extends FileSystemState, SearchSliceActions, ListSlice
|
|
|
837
851
|
refreshTree: (params: { agentId: string; projectId: string | null }) => Promise<void>;
|
|
838
852
|
/**
|
|
839
853
|
* The dock broom's reach into this store (host → kernel port
|
|
840
|
-
* `onClientStateReset` → here). Matches the scope
|
|
841
|
-
* `(activeProjectId, activeAgentId)` pair
|
|
842
|
-
* `
|
|
854
|
+
* `onClientStateReset` → `resetFilesClientState` → here). Matches the scope
|
|
855
|
+
* against the ACTIVE `(activeProjectId, activeAgentId)` pair; the `instance`
|
|
856
|
+
* and `widget` levels also need the `filesystem` widget type AND this store's
|
|
857
|
+
* own `windowId` — one window's clean or close never empties a sibling
|
|
858
|
+
* window, of the same agent included. A non-matching scope is a no-op. A match applies
|
|
843
859
|
* the ONE `contextResetPatch` (the same wipe an agent/project switch does),
|
|
844
860
|
* clears the persisted tab strip (`tabMeta` included — the allowlist is written
|
|
845
861
|
* EMPTY, so a persisted `activeFile` cannot re-run the read/list race on the
|
|
@@ -856,6 +872,18 @@ interface FilesystemStore extends FileSystemState, SearchSliceActions, ListSlice
|
|
|
856
872
|
* through `get()` — never a second copy.
|
|
857
873
|
*/
|
|
858
874
|
fenceInFlightWriters: () => void;
|
|
875
|
+
/**
|
|
876
|
+
* The host's id of the Files window this store belongs to (`null` for a store
|
|
877
|
+
* no window owns). Identity like `activeAgentId`: no reset touches it, and it
|
|
878
|
+
* is never an authority source — it only addresses an `instance`/`widget` reset.
|
|
879
|
+
*/
|
|
880
|
+
windowId: string | null;
|
|
881
|
+
/**
|
|
882
|
+
* The request client every API call of this store goes through — created with
|
|
883
|
+
* the store over the window's project and BOUND agent, so a request's headers
|
|
884
|
+
* never come from another mounted window (`api/index.ts`).
|
|
885
|
+
*/
|
|
886
|
+
client: PackageApi;
|
|
859
887
|
/** Dismiss the structural-operation banner (`organizeError`). */
|
|
860
888
|
clearOrganizeError: () => void;
|
|
861
889
|
/**
|
|
@@ -950,6 +978,7 @@ interface FilesystemStore extends FileSystemState, SearchSliceActions, ListSlice
|
|
|
950
978
|
pendingReviewPaths: Set<string>;
|
|
951
979
|
/** The caller's visible pending-review count (server `count_only`), shown on the Changes button. */
|
|
952
980
|
pendingCount: number;
|
|
981
|
+
pendingCountState: 'exact' | 'partial' | 'stale';
|
|
953
982
|
/** Re-read `pendingCount`; a newer call or another active context drops the answer. */
|
|
954
983
|
refreshPendingCount: (params: { agentId: string; projectId: string | null }) => Promise<void>;
|
|
955
984
|
|
|
@@ -1078,7 +1107,11 @@ function readStoredSidebarWidth(): number {
|
|
|
1078
1107
|
}
|
|
1079
1108
|
}
|
|
1080
1109
|
|
|
1081
|
-
/**
|
|
1110
|
+
/**
|
|
1111
|
+
* The localStorage payload (v2): prefs, the active triple, the per-agent context
|
|
1112
|
+
* projections, and — OPTIONAL, absent until a window writes one — the per-window
|
|
1113
|
+
* projections keyed by the host's window id (`PersistedFilesWindow`).
|
|
1114
|
+
*/
|
|
1082
1115
|
type PersistedFilesShape = {
|
|
1083
1116
|
previewEditorsEnabled: boolean;
|
|
1084
1117
|
diffViewMode: DiffViewMode;
|
|
@@ -1086,9 +1119,39 @@ type PersistedFilesShape = {
|
|
|
1086
1119
|
activeAgentId: string | null;
|
|
1087
1120
|
activeProjectId: string | null;
|
|
1088
1121
|
contexts: Record<string, PersistedFilesContext>;
|
|
1122
|
+
windows?: Record<string, PersistedFilesWindow>;
|
|
1089
1123
|
};
|
|
1090
1124
|
|
|
1091
|
-
|
|
1125
|
+
/** What a new store starts from: its window, its request client, the preferences and the seeded view. */
|
|
1126
|
+
export type FilesystemStoreInit = {
|
|
1127
|
+
/** The request client every API call of this store goes through (see `FilesystemStore.client`). */
|
|
1128
|
+
client: PackageApi;
|
|
1129
|
+
windowId?: string | null;
|
|
1130
|
+
userId?: string | null;
|
|
1131
|
+
projectId?: string | null;
|
|
1132
|
+
agentId?: string | null;
|
|
1133
|
+
previewEditorsEnabled?: boolean;
|
|
1134
|
+
diffViewMode?: DiffViewMode;
|
|
1135
|
+
/** The window's persisted view — its own projection (a new window has none and starts from the defaults). */
|
|
1136
|
+
projection?: Partial<PersistedFilesContext>;
|
|
1137
|
+
};
|
|
1138
|
+
|
|
1139
|
+
export type FilesystemStoreApi = ZustandStoreApi<FilesystemStore>;
|
|
1140
|
+
|
|
1141
|
+
/**
|
|
1142
|
+
* ONE Files store per window: a live view that persists nothing itself — the
|
|
1143
|
+
* ONE writer of `neuralis.filesystem.tabs` is `filesPersistence` below, which a
|
|
1144
|
+
* mounted window feeds through `mountFilesWindow`. Every asynchronous fence and
|
|
1145
|
+
* dedup map is created with the store (`StoreFences`, the list/search/git slice
|
|
1146
|
+
* closures), so two windows — of two agents, or of the same agent — never fence,
|
|
1147
|
+
* supersede or dedup each other.
|
|
1148
|
+
*/
|
|
1149
|
+
export function createFilesystemStore(init: FilesystemStoreInit): FilesystemStoreApi {
|
|
1150
|
+
const fences = createStoreFences();
|
|
1151
|
+
const client = init.client;
|
|
1152
|
+
return createStore<FilesystemStore>()((set, get) => ({
|
|
1153
|
+
windowId: init.windowId ?? null,
|
|
1154
|
+
client,
|
|
1092
1155
|
files: {},
|
|
1093
1156
|
folderBadges: {},
|
|
1094
1157
|
fileContentByPath: {},
|
|
@@ -1098,17 +1161,17 @@ export const useFilesystemStore = create<FilesystemStore>()(persist((set, get) =
|
|
|
1098
1161
|
openFiles: [],
|
|
1099
1162
|
activeFile: null,
|
|
1100
1163
|
tabMeta: {},
|
|
1101
|
-
activeUserId: null,
|
|
1164
|
+
activeUserId: init.userId ?? null,
|
|
1102
1165
|
contextStateByKey: {},
|
|
1103
|
-
previewEditorsEnabled: false,
|
|
1166
|
+
previewEditorsEnabled: init.previewEditorsEnabled ?? false,
|
|
1104
1167
|
// Nothing is pre-expanded — under lazy hydration an expanded entry is a FETCH,
|
|
1105
1168
|
// and every source branch (including `brain://`) starts collapsed.
|
|
1106
1169
|
expandedFolders: new Set<string>(),
|
|
1107
1170
|
sidebarOpen: true,
|
|
1108
1171
|
sidebarWidth: readStoredSidebarWidth(),
|
|
1109
1172
|
sidebarView: 'tree',
|
|
1110
|
-
activeAgentId: null,
|
|
1111
|
-
activeProjectId: null,
|
|
1173
|
+
activeAgentId: init.agentId ?? null,
|
|
1174
|
+
activeProjectId: init.projectId ?? null,
|
|
1112
1175
|
sources: [],
|
|
1113
1176
|
sourcesLoading: true,
|
|
1114
1177
|
activeSource: null,
|
|
@@ -1119,7 +1182,7 @@ export const useFilesystemStore = create<FilesystemStore>()(persist((set, get) =
|
|
|
1119
1182
|
activeWorkspaceTab: 'file',
|
|
1120
1183
|
pendingSourceSuggestion: null,
|
|
1121
1184
|
vectorHealthOpen: false,
|
|
1122
|
-
diffViewMode: 'auto',
|
|
1185
|
+
diffViewMode: init.diffViewMode ?? 'auto',
|
|
1123
1186
|
pendingPanelFilter: {},
|
|
1124
1187
|
// Nothing is hydrated at boot. `brain://` is EXPANDED by default (below) and the
|
|
1125
1188
|
// tree's hydration effect lists it — marking it "loaded" here would suppress that
|
|
@@ -1132,6 +1195,7 @@ export const useFilesystemStore = create<FilesystemStore>()(persist((set, get) =
|
|
|
1132
1195
|
snapshotError: undefined,
|
|
1133
1196
|
pendingReviewPaths: new Set<string>(),
|
|
1134
1197
|
pendingCount: 0,
|
|
1198
|
+
pendingCountState: 'stale',
|
|
1135
1199
|
|
|
1136
1200
|
...searchSliceInitial,
|
|
1137
1201
|
...createSearchSlice(set as any, get),
|
|
@@ -1269,28 +1333,28 @@ export const useFilesystemStore = create<FilesystemStore>()(persist((set, get) =
|
|
|
1269
1333
|
// Enumerated once, then hidden — no reconcile listed it off screen, so its rows
|
|
1270
1334
|
// may be stale. ONE authoritative listing of exactly this folder (absence
|
|
1271
1335
|
// applies), never a re-list of the whole visible tree.
|
|
1272
|
-
await runSourceReconcile({ get, set }, { agentId, projectId, source: sourceFromUri(uri), targets: [uri], wide: false });
|
|
1336
|
+
await runSourceReconcile({ get, set, fences, client }, { agentId, projectId, source: sourceFromUri(uri), targets: [uri], wide: false });
|
|
1273
1337
|
return;
|
|
1274
1338
|
}
|
|
1275
1339
|
|
|
1276
|
-
const existing = liveFolderLoadInFlight.get(uri);
|
|
1340
|
+
const existing = fences.liveFolderLoadInFlight.get(uri);
|
|
1277
1341
|
if (existing) { await existing; return; }
|
|
1278
1342
|
|
|
1279
1343
|
set((state: any) => ({ hydrationState: { ...(state.hydrationState || {}), [uri]: 'loading' as const } }));
|
|
1280
1344
|
|
|
1281
1345
|
const task = (async () => {
|
|
1282
|
-
const gen = nextFolderGeneration(uri);
|
|
1346
|
+
const gen = nextFolderGeneration(fences, uri);
|
|
1283
1347
|
const issuedAt = Date.now();
|
|
1284
1348
|
try {
|
|
1285
1349
|
// recursive:false → single `readdir` on the connector, ~50ms even on
|
|
1286
1350
|
// million-file subtrees. Replaces the previous full-tree snapshot call.
|
|
1287
|
-
const res = await resolveList(agentId, projectId ?? undefined, { uri, recursive: false });
|
|
1351
|
+
const res = await resolveList(agentId, projectId ?? undefined, { uri, recursive: false }, client);
|
|
1288
1352
|
// Superseded by a newer listing of the same folder (a concurrent reconcile
|
|
1289
1353
|
// or a second expand) → drop this response instead of overwriting. Restore
|
|
1290
1354
|
// the fetch state on the way out: returning bare left `loading` pinned
|
|
1291
1355
|
// forever, and the refresh control is `disabled={loading}` — the one control
|
|
1292
1356
|
// that could recover would have been disabled by the state it must clear.
|
|
1293
|
-
if (!isCurrentFolderGeneration(uri, gen)) {
|
|
1357
|
+
if (!isCurrentFolderGeneration(fences, uri, gen)) {
|
|
1294
1358
|
set((state: any) => ({
|
|
1295
1359
|
hydrationState: {
|
|
1296
1360
|
...(state.hydrationState || {}),
|
|
@@ -1327,8 +1391,8 @@ export const useFilesystemStore = create<FilesystemStore>()(persist((set, get) =
|
|
|
1327
1391
|
}
|
|
1328
1392
|
})();
|
|
1329
1393
|
|
|
1330
|
-
liveFolderLoadInFlight.set(uri, task);
|
|
1331
|
-
try { await task; } finally { liveFolderLoadInFlight.delete(uri); }
|
|
1394
|
+
fences.liveFolderLoadInFlight.set(uri, task);
|
|
1395
|
+
try { await task; } finally { fences.liveFolderLoadInFlight.delete(uri); }
|
|
1332
1396
|
},
|
|
1333
1397
|
|
|
1334
1398
|
clearOrganizeError: () => set(() => ({ organizeError: null })),
|
|
@@ -1345,31 +1409,32 @@ export const useFilesystemStore = create<FilesystemStore>()(persist((set, get) =
|
|
|
1345
1409
|
}),
|
|
1346
1410
|
|
|
1347
1411
|
async reconcileSourceFolders({ agentId, projectId, source }) {
|
|
1348
|
-
await runSourceReconcile({ get, set }, { agentId, projectId, source });
|
|
1412
|
+
await runSourceReconcile({ get, set, fences, client }, { agentId, projectId, source });
|
|
1349
1413
|
},
|
|
1350
1414
|
|
|
1351
1415
|
fenceInFlightWriters() {
|
|
1352
|
-
resetEpoch += 1;
|
|
1353
|
-
for (const key of Array.from(folderGeneration.keys())) nextFolderGeneration(key);
|
|
1416
|
+
fences.resetEpoch += 1;
|
|
1417
|
+
for (const key of Array.from(fences.folderGeneration.keys())) nextFolderGeneration(fences, key);
|
|
1354
1418
|
// The dedup maps only; the in-flight tasks' own `finally` deletes a missing
|
|
1355
1419
|
// key as a no-op. `reconcileJobs` is deliberately NOT cleared: a running job's
|
|
1356
1420
|
// `finally` would then delete the NEXT job's entry and two passes would run
|
|
1357
1421
|
// concurrently — the job runs to completion and the epoch makes it inert.
|
|
1358
|
-
liveFolderLoadInFlight.clear();
|
|
1359
|
-
rehydrateInFlight.clear();
|
|
1422
|
+
fences.liveFolderLoadInFlight.clear();
|
|
1423
|
+
fences.rehydrateInFlight.clear();
|
|
1360
1424
|
},
|
|
1361
1425
|
|
|
1362
1426
|
resetClientState(scope) {
|
|
1363
1427
|
const s = get();
|
|
1364
1428
|
if (!s.activeProjectId || scope.projectId !== s.activeProjectId) return;
|
|
1365
1429
|
if (scope.level !== 'project' && scope.agentId !== s.activeAgentId) return;
|
|
1366
|
-
if (scope.level === 'widget'
|
|
1430
|
+
if ((scope.level === 'widget' || scope.level === 'instance')
|
|
1431
|
+
&& (scope.widgetType !== FILESYSTEM_WIDGET_TYPE || scope.instanceId !== s.windowId)) return;
|
|
1367
1432
|
// Fence FIRST, then wipe: a read/listing/reconcile that resolves after this
|
|
1368
1433
|
// point finds a stale epoch (or a stale folder generation) and writes nothing.
|
|
1369
1434
|
get().fenceInFlightWriters();
|
|
1370
1435
|
// The broom is the ONE writer that drops a stashed context — its own scope
|
|
1371
|
-
// only: the widget/agent levels drop this context, the project level
|
|
1372
|
-
// context of the project (this user's). Other projects are not its business.
|
|
1436
|
+
// only: the instance/widget/agent levels drop this context, the project level
|
|
1437
|
+
// every context of the project (this user's). Other projects are not its business.
|
|
1373
1438
|
const prefix = `${s.activeUserId ?? ''}::${s.activeProjectId}::`;
|
|
1374
1439
|
const own = contextKey(s.activeUserId, s.activeProjectId, s.activeAgentId ?? '');
|
|
1375
1440
|
const kept: Record<string, Record<string, unknown>> = {};
|
|
@@ -1408,7 +1473,7 @@ export const useFilesystemStore = create<FilesystemStore>()(persist((set, get) =
|
|
|
1408
1473
|
if (bySource.size === 0) return;
|
|
1409
1474
|
await Promise.all(
|
|
1410
1475
|
Array.from(bySource.entries()).map(([source, targets]) =>
|
|
1411
|
-
runSourceReconcile({ get, set }, { agentId, projectId, source, targets }),
|
|
1476
|
+
runSourceReconcile({ get, set, fences, client }, { agentId, projectId, source, targets }),
|
|
1412
1477
|
),
|
|
1413
1478
|
);
|
|
1414
1479
|
},
|
|
@@ -1430,7 +1495,7 @@ export const useFilesystemStore = create<FilesystemStore>()(persist((set, get) =
|
|
|
1430
1495
|
if (source) sources.add(source);
|
|
1431
1496
|
}
|
|
1432
1497
|
await Promise.all(
|
|
1433
|
-
Array.from(sources).map((source) => runSourceReconcile({ get, set }, { agentId, projectId, source })),
|
|
1498
|
+
Array.from(sources).map((source) => runSourceReconcile({ get, set, fences, client }, { agentId, projectId, source })),
|
|
1434
1499
|
);
|
|
1435
1500
|
},
|
|
1436
1501
|
|
|
@@ -1445,8 +1510,8 @@ export const useFilesystemStore = create<FilesystemStore>()(persist((set, get) =
|
|
|
1445
1510
|
const agentId = s.activeAgentId;
|
|
1446
1511
|
if (!uri || !agentId) return;
|
|
1447
1512
|
if (hasCompletedRead(s, uri)) return;
|
|
1448
|
-
if (rehydrateInFlight.has(uri)) return;
|
|
1449
|
-
rehydrateInFlight.add(uri);
|
|
1513
|
+
if (fences.rehydrateInFlight.has(uri)) return;
|
|
1514
|
+
fences.rehydrateInFlight.add(uri);
|
|
1450
1515
|
try {
|
|
1451
1516
|
// A tab from another agent's source reads through that agent, as the tree
|
|
1452
1517
|
// open did — never through a SENTINEL (`__system__` is the writer the
|
|
@@ -1459,7 +1524,7 @@ export const useFilesystemStore = create<FilesystemStore>()(persist((set, get) =
|
|
|
1459
1524
|
// rehydrate is not a user open.
|
|
1460
1525
|
await s.openFile({ agentId, projectId: s.activeProjectId, uri, readAgentId, revealPane: false });
|
|
1461
1526
|
} finally {
|
|
1462
|
-
rehydrateInFlight.delete(uri);
|
|
1527
|
+
fences.rehydrateInFlight.delete(uri);
|
|
1463
1528
|
}
|
|
1464
1529
|
},
|
|
1465
1530
|
|
|
@@ -1679,8 +1744,8 @@ export const useFilesystemStore = create<FilesystemStore>()(persist((set, get) =
|
|
|
1679
1744
|
// The server-side work completes whatever happens to the store; every store
|
|
1680
1745
|
// write after an await is fenced — a context reset mid-save must not re-key,
|
|
1681
1746
|
// fill, dirty-flag or re-open anything under the new context.
|
|
1682
|
-
const epoch = resetEpoch;
|
|
1683
|
-
const fenced = () => epoch !== resetEpoch;
|
|
1747
|
+
const epoch = fences.resetEpoch;
|
|
1748
|
+
const fenced = () => epoch !== fences.resetEpoch;
|
|
1684
1749
|
|
|
1685
1750
|
let effectiveUri = fromUri;
|
|
1686
1751
|
if (moved) {
|
|
@@ -1690,7 +1755,7 @@ export const useFilesystemStore = create<FilesystemStore>()(persist((set, get) =
|
|
|
1690
1755
|
id: node.id,
|
|
1691
1756
|
uri: fromUri,
|
|
1692
1757
|
move: targetUri,
|
|
1693
|
-
});
|
|
1758
|
+
}, client);
|
|
1694
1759
|
if (!res || res.ok !== true) throw new Error(writeRefusalMessage(res, 'Move failed'));
|
|
1695
1760
|
effectiveUri = targetUri;
|
|
1696
1761
|
if (fenced()) return { uri: effectiveUri, moved, contentChanged: false };
|
|
@@ -1704,7 +1769,7 @@ export const useFilesystemStore = create<FilesystemStore>()(persist((set, get) =
|
|
|
1704
1769
|
id: node.id,
|
|
1705
1770
|
uri: effectiveUri,
|
|
1706
1771
|
overwrite: { content },
|
|
1707
|
-
});
|
|
1772
|
+
}, client);
|
|
1708
1773
|
if (!res || res.ok !== true) throw new Error(writeRefusalMessage(res, 'Save failed'));
|
|
1709
1774
|
if (fenced()) return { uri: effectiveUri, moved, contentChanged };
|
|
1710
1775
|
// After a move the node lives under its NEW uri (`replaceFilePath` re-keys
|
|
@@ -1726,32 +1791,38 @@ export const useFilesystemStore = create<FilesystemStore>()(persist((set, get) =
|
|
|
1726
1791
|
|
|
1727
1792
|
async refreshPendingCount({ agentId, projectId }) {
|
|
1728
1793
|
if (!agentId) return;
|
|
1729
|
-
const mine = ++pendingCountSeq;
|
|
1794
|
+
const mine = ++fences.pendingCountSeq;
|
|
1730
1795
|
// A failed count keeps the last known number — the next event, focus or reconnect re-asks.
|
|
1731
|
-
let count:
|
|
1732
|
-
try { count = await countPendingChanges({ agentId, ...(projectId ? { projectId } : {}) }); } catch {
|
|
1796
|
+
let count: Awaited<ReturnType<typeof countPendingChanges>>;
|
|
1797
|
+
try { count = await countPendingChanges({ agentId, ...(projectId ? { projectId } : {}) }, client); } catch {
|
|
1798
|
+
if (mine === fences.pendingCountSeq && get().activeAgentId === agentId && get().activeProjectId === projectId) set({ pendingCountState: 'stale' });
|
|
1799
|
+
return;
|
|
1800
|
+
}
|
|
1733
1801
|
// No reset-epoch fence: the count is the context's own server answer, so one
|
|
1734
1802
|
// that resolves after a reset of the SAME context is still right; the context
|
|
1735
1803
|
// check below is what keeps another context's number out.
|
|
1736
|
-
if (mine !== pendingCountSeq) return;
|
|
1804
|
+
if (mine !== fences.pendingCountSeq) return;
|
|
1737
1805
|
const s = get();
|
|
1738
1806
|
if (s.activeAgentId !== agentId || s.activeProjectId !== projectId) return;
|
|
1739
|
-
|
|
1807
|
+
if (count.unavailable?.includes('journal') && count.unavailable.includes('vector')) {
|
|
1808
|
+
set({ pendingCountState: 'stale' }); return;
|
|
1809
|
+
}
|
|
1810
|
+
set(() => ({ pendingCount: count.count, pendingCountState: count.exact ? 'exact' : 'partial' }));
|
|
1740
1811
|
},
|
|
1741
1812
|
|
|
1742
1813
|
async markFileDeleted({ agentId, projectId, node }) {
|
|
1743
|
-
const epoch = resetEpoch;
|
|
1814
|
+
const epoch = fences.resetEpoch;
|
|
1744
1815
|
const res = await writeFile({
|
|
1745
1816
|
agentId,
|
|
1746
1817
|
...(projectId ? { projectId } : {}),
|
|
1747
1818
|
id: node.id,
|
|
1748
1819
|
uri: node.uri,
|
|
1749
1820
|
delete: true,
|
|
1750
|
-
});
|
|
1821
|
+
}, client);
|
|
1751
1822
|
if (!res || res.ok !== true) throw new Error(writeRefusalMessage(res, 'Delete failed'));
|
|
1752
1823
|
// The reconcile flags an unloaded source dirty — not a no-op on a store the
|
|
1753
1824
|
// reset just handed to another context.
|
|
1754
|
-
if (epoch !== resetEpoch) return;
|
|
1825
|
+
if (epoch !== fences.resetEpoch) return;
|
|
1755
1826
|
await get().reconcileFolders({ agentId, projectId, folders: [folderUri(node.uri)] });
|
|
1756
1827
|
},
|
|
1757
1828
|
|
|
@@ -1772,7 +1843,7 @@ export const useFilesystemStore = create<FilesystemStore>()(persist((set, get) =
|
|
|
1772
1843
|
id,
|
|
1773
1844
|
uri: fromUri,
|
|
1774
1845
|
move: destinationUri,
|
|
1775
|
-
});
|
|
1846
|
+
}, client);
|
|
1776
1847
|
} catch (e) {
|
|
1777
1848
|
console.error('[Filesystem] move failed', fromUri, e);
|
|
1778
1849
|
set(() => ({ organizeError: 'Move failed' }));
|
|
@@ -1801,7 +1872,7 @@ export const useFilesystemStore = create<FilesystemStore>()(persist((set, get) =
|
|
|
1801
1872
|
// of the old subtree would stay in the store forever. Prune by prefix (the
|
|
1802
1873
|
// pattern `renameNode` already proved) and force-relist the destination.
|
|
1803
1874
|
if (!retained) {
|
|
1804
|
-
prunePrefix({ get, set }, fromUri);
|
|
1875
|
+
prunePrefix({ get, set, fences, client }, fromUri);
|
|
1805
1876
|
}
|
|
1806
1877
|
await get().ensureFolderLoaded({ agentId, projectId, uri: toFolderUri, force: true });
|
|
1807
1878
|
} else {
|
|
@@ -1842,7 +1913,7 @@ export const useFilesystemStore = create<FilesystemStore>()(persist((set, get) =
|
|
|
1842
1913
|
id,
|
|
1843
1914
|
uri,
|
|
1844
1915
|
move: isFolder ? destinationUri : trimmed,
|
|
1845
|
-
});
|
|
1916
|
+
}, client);
|
|
1846
1917
|
} catch (e) {
|
|
1847
1918
|
console.error('[Filesystem] rename failed', uri, e);
|
|
1848
1919
|
set(() => ({ organizeError: 'Rename failed' }));
|
|
@@ -1861,7 +1932,7 @@ export const useFilesystemStore = create<FilesystemStore>()(persist((set, get) =
|
|
|
1861
1932
|
// tombstoned the old one. Prune the stale old subtree from the store and
|
|
1862
1933
|
// force-relist the parent so the renamed folder surfaces immediately (the
|
|
1863
1934
|
// non-recursive `refresh` alone would not re-list a nested folder's parent).
|
|
1864
|
-
prunePrefix({ get, set }, uri);
|
|
1935
|
+
prunePrefix({ get, set, fences, client }, uri);
|
|
1865
1936
|
await get().ensureFolderLoaded({ agentId, projectId, uri: parent, force: true });
|
|
1866
1937
|
} else {
|
|
1867
1938
|
get().replaceFilePath(uri, destinationUri);
|
|
@@ -1873,11 +1944,11 @@ export const useFilesystemStore = create<FilesystemStore>()(persist((set, get) =
|
|
|
1873
1944
|
const trimmed = (name ?? '').trim();
|
|
1874
1945
|
if (!agentId || !parentUri || !trimmed) return;
|
|
1875
1946
|
const targetUri = parentUri.endsWith('://') ? `${parentUri}${trimmed}` : `${parentUri}/${trimmed}`;
|
|
1876
|
-
const epoch = resetEpoch;
|
|
1947
|
+
const epoch = fences.resetEpoch;
|
|
1877
1948
|
try {
|
|
1878
1949
|
if (kind === 'folder') {
|
|
1879
|
-
const res = await writeFile({ agentId, ...(projectId ? { projectId } : {}), uri: targetUri, mkdir: true });
|
|
1880
|
-
if (res?.ok && epoch === resetEpoch) {
|
|
1950
|
+
const res = await writeFile({ agentId, ...(projectId ? { projectId } : {}), uri: targetUri, mkdir: true }, client);
|
|
1951
|
+
if (res?.ok && epoch === fences.resetEpoch) {
|
|
1881
1952
|
// Optimistic folder node — an EMPTY folder has no vector node, so only a
|
|
1882
1953
|
// connector-live re-list surfaces it; seed it now so it appears instantly.
|
|
1883
1954
|
set((state) => {
|
|
@@ -1908,7 +1979,7 @@ export const useFilesystemStore = create<FilesystemStore>()(persist((set, get) =
|
|
|
1908
1979
|
file: new File([''], trimmed),
|
|
1909
1980
|
uri: parentUri,
|
|
1910
1981
|
tags: [],
|
|
1911
|
-
});
|
|
1982
|
+
}, client);
|
|
1912
1983
|
}
|
|
1913
1984
|
} catch (e) {
|
|
1914
1985
|
console.error('[Filesystem] create failed', kind, targetUri, e);
|
|
@@ -1931,11 +2002,11 @@ export const useFilesystemStore = create<FilesystemStore>()(persist((set, get) =
|
|
|
1931
2002
|
touched.add(folderUri(src));
|
|
1932
2003
|
try {
|
|
1933
2004
|
if (op === 'copy') {
|
|
1934
|
-
const res = await writeFile({ agentId, ...(projectId ? { projectId } : {}), uri: src, copy: dest });
|
|
2005
|
+
const res = await writeFile({ agentId, ...(projectId ? { projectId } : {}), uri: src, copy: dest }, client);
|
|
1935
2006
|
if (res && res.ok === false) rejected.push(base);
|
|
1936
2007
|
} else {
|
|
1937
2008
|
// 200-with-`ok:false` is a REJECTED move — collect it, never treat it as done.
|
|
1938
|
-
const res = await writeFile({ agentId, ...(projectId ? { projectId } : {}), uri: src, move: dest });
|
|
2009
|
+
const res = await writeFile({ agentId, ...(projectId ? { projectId } : {}), uri: src, move: dest }, client);
|
|
1939
2010
|
if (res && res.ok === false) rejected.push(base);
|
|
1940
2011
|
else if (res?.warning === 'moved_with_source_retained') rejected.push(`${base} (original retained)`);
|
|
1941
2012
|
else if (get().files[src]?.type === 'folder') {
|
|
@@ -1944,7 +2015,7 @@ export const useFilesystemStore = create<FilesystemStore>()(persist((set, get) =
|
|
|
1944
2015
|
// inspects direct children — so the old prefix has to go here or its
|
|
1945
2016
|
// interior lingers. Cut/paste reaches this path too, and only the drag
|
|
1946
2017
|
// path used to handle it.
|
|
1947
|
-
prunePrefix({ get, set }, src);
|
|
2018
|
+
prunePrefix({ get, set, fences, client }, src);
|
|
1948
2019
|
}
|
|
1949
2020
|
}
|
|
1950
2021
|
} catch (e) {
|
|
@@ -1974,7 +2045,7 @@ export const useFilesystemStore = create<FilesystemStore>()(persist((set, get) =
|
|
|
1974
2045
|
id: t.id,
|
|
1975
2046
|
uri: t.uri,
|
|
1976
2047
|
delete: true,
|
|
1977
|
-
});
|
|
2048
|
+
}, client);
|
|
1978
2049
|
if (res && res.ok === false) {
|
|
1979
2050
|
// 200-with-`ok:false`. A FOLDER delete can still have removed N files
|
|
1980
2051
|
// before failing — `affected`/`failed` carry that partial outcome, and
|
|
@@ -1990,7 +2061,7 @@ export const useFilesystemStore = create<FilesystemStore>()(persist((set, get) =
|
|
|
1990
2061
|
if (isFolder) {
|
|
1991
2062
|
// A folder delete removes the whole subtree server-side; prune by prefix so
|
|
1992
2063
|
// the interior cannot linger (the reconcile only inspects direct children).
|
|
1993
|
-
prunePrefix({ get, set }, t.uri);
|
|
2064
|
+
prunePrefix({ get, set, fences, client }, t.uri);
|
|
1994
2065
|
} else {
|
|
1995
2066
|
// Optimistic: strike the node (revertible tombstone). The targeted
|
|
1996
2067
|
// reconcile below converges to server truth — a tracked node KEEPS the
|
|
@@ -2074,16 +2145,16 @@ export const useFilesystemStore = create<FilesystemStore>()(persist((set, get) =
|
|
|
2074
2145
|
(typeof st.fileContentByPath[uri] === 'string' && (st.fileContentByPath[uri]?.length || 0) > 0);
|
|
2075
2146
|
if (!force && hasContent) return;
|
|
2076
2147
|
|
|
2077
|
-
const epoch = resetEpoch;
|
|
2148
|
+
const epoch = fences.resetEpoch;
|
|
2078
2149
|
try {
|
|
2079
2150
|
const res = await readFiles({
|
|
2080
2151
|
agentId: readAgentId || agentId,
|
|
2081
2152
|
...(projectId ? { projectId } : {}),
|
|
2082
2153
|
files: [uri],
|
|
2083
|
-
});
|
|
2154
|
+
}, client);
|
|
2084
2155
|
// The broom ran while the read was in flight: the tab is gone, the store is
|
|
2085
2156
|
// empty, and this answer must not put either back.
|
|
2086
|
-
if (epoch !== resetEpoch) return;
|
|
2157
|
+
if (epoch !== fences.resetEpoch) return;
|
|
2087
2158
|
const files = extractReadFilesPayload(res);
|
|
2088
2159
|
if (files.length > 0) {
|
|
2089
2160
|
const f = files[0];
|
|
@@ -2193,7 +2264,7 @@ export const useFilesystemStore = create<FilesystemStore>()(persist((set, get) =
|
|
|
2193
2264
|
} catch (e) {
|
|
2194
2265
|
const message = e instanceof Error ? e.message : 'Read failed';
|
|
2195
2266
|
console.error('[Filesystem] read failed', e);
|
|
2196
|
-
if (epoch !== resetEpoch) return;
|
|
2267
|
+
if (epoch !== fences.resetEpoch) return;
|
|
2197
2268
|
set((state) => ({
|
|
2198
2269
|
files: {
|
|
2199
2270
|
...state.files,
|
|
@@ -2253,7 +2324,7 @@ export const useFilesystemStore = create<FilesystemStore>()(persist((set, get) =
|
|
|
2253
2324
|
const targetUri = uri || 'brain://docs';
|
|
2254
2325
|
set(() => ({ uploading: true }));
|
|
2255
2326
|
try {
|
|
2256
|
-
await uploadFile({ agentId, ...(projectId ? { projectId } : {}), file, uri: targetUri, tags: ['upload'] });
|
|
2327
|
+
await uploadFile({ agentId, ...(projectId ? { projectId } : {}), file, uri: targetUri, tags: ['upload'] }, client);
|
|
2257
2328
|
await get().reconcileFolders({ agentId, projectId, folders: [targetUri] });
|
|
2258
2329
|
} finally {
|
|
2259
2330
|
set(() => ({ uploading: false }));
|
|
@@ -2268,25 +2339,43 @@ export const useFilesystemStore = create<FilesystemStore>()(persist((set, get) =
|
|
|
2268
2339
|
set(() => ({ uploading: true }));
|
|
2269
2340
|
try {
|
|
2270
2341
|
for (const file of list) {
|
|
2271
|
-
await uploadFile({ agentId, ...(projectId ? { projectId } : {}), file, uri: targetUri, tags: ['upload'] });
|
|
2342
|
+
await uploadFile({ agentId, ...(projectId ? { projectId } : {}), file, uri: targetUri, tags: ['upload'] }, client);
|
|
2272
2343
|
}
|
|
2273
2344
|
await get().reconcileFolders({ agentId, projectId, folders: [targetUri] });
|
|
2274
2345
|
} finally {
|
|
2275
2346
|
set(() => ({ uploading: false }));
|
|
2276
2347
|
}
|
|
2277
2348
|
},
|
|
2349
|
+
// The window's persisted view, lifted into the flat fields — after every
|
|
2350
|
+
// default above, so a seeded tab strip is what the first render shows.
|
|
2351
|
+
...(init.projection ? sliceFromPersisted(init.projection) : {}),
|
|
2352
|
+
}));
|
|
2353
|
+
}
|
|
2354
|
+
|
|
2355
|
+
/**
|
|
2356
|
+
* The ONE writer of `neuralis.filesystem.tabs` (v2). Window stores persist
|
|
2357
|
+
* nothing; a mounted window feeds its projection here (`mountFilesWindow`), so
|
|
2358
|
+
* two windows writing the same key can never overwrite each other's view — the
|
|
2359
|
+
* whole record is written from this one in-memory owner.
|
|
2360
|
+
*
|
|
2361
|
+
* What it holds: the preferences, the legacy per-agent `contexts` (the record an
|
|
2362
|
+
* older build wrote — never read into a window, pruned by the broom, otherwise
|
|
2363
|
+
* left as it is: never rewritten, re-keyed or converted), and the optional
|
|
2364
|
+
* `windows` map — one `PersistedFilesWindow` per window, carrying the
|
|
2365
|
+
* user/project/agent it was written for. A record without `windows` reads
|
|
2366
|
+
* without one and is not written back until a window really changes.
|
|
2367
|
+
*/
|
|
2368
|
+
export const filesPersistence = createStore<PersistedFilesShape>()(persist((): PersistedFilesShape => ({
|
|
2369
|
+
previewEditorsEnabled: false,
|
|
2370
|
+
diffViewMode: 'auto',
|
|
2371
|
+
activeUserId: null,
|
|
2372
|
+
activeAgentId: null,
|
|
2373
|
+
activeProjectId: null,
|
|
2374
|
+
contexts: {},
|
|
2278
2375
|
}), {
|
|
2279
|
-
//
|
|
2280
|
-
//
|
|
2281
|
-
//
|
|
2282
|
-
// projection is taken off the flat fields on every write, the others off
|
|
2283
|
-
// `contextStateByKey`. NEVER the `files`/content dicts or a `Set` as such —
|
|
2284
|
-
// `pendingReviewPaths` is derived from `files` and stays in memory.
|
|
2285
|
-
// `sidebarWidth` keeps its own hand-persist. On rehydrate (`merge`) the active
|
|
2286
|
-
// context's projection lands in the flat fields so `loadList`'s same-context
|
|
2287
|
-
// guard keeps them on a page refresh, and every other context waits in the
|
|
2288
|
-
// space for its switch-back. v2 (2026-09-04): the flat tab trio became the
|
|
2289
|
-
// context space; `migrate` lifts a v1 payload's trio into the active context.
|
|
2376
|
+
// v2 (2026-09-04): the flat tab trio became the context space; `migrate` lifts
|
|
2377
|
+
// a v1 payload's trio into the active context. The optional `windows` map is
|
|
2378
|
+
// additive — no version bump, an absent map stays absent.
|
|
2290
2379
|
name: 'neuralis.filesystem.tabs',
|
|
2291
2380
|
version: 2,
|
|
2292
2381
|
migrate: (persistedState, version) => {
|
|
@@ -2322,44 +2411,236 @@ export const useFilesystemStore = create<FilesystemStore>()(persist((set, get) =
|
|
|
2322
2411
|
merge: (persistedState, currentState) => {
|
|
2323
2412
|
const p = persistedState as Partial<PersistedFilesShape> | undefined;
|
|
2324
2413
|
if (!p) return currentState;
|
|
2325
|
-
const contexts = p.contexts ?? {};
|
|
2326
|
-
const activeKey = p.activeAgentId ? contextKey(p.activeUserId ?? null, p.activeProjectId ?? null, p.activeAgentId) : null;
|
|
2327
|
-
const space: Record<string, Record<string, unknown>> = {};
|
|
2328
|
-
for (const [key, projection] of Object.entries(contexts)) {
|
|
2329
|
-
if (key !== activeKey) space[key] = sliceFromPersisted(projection);
|
|
2330
|
-
}
|
|
2331
|
-
const active = activeKey && contexts[activeKey] ? sliceFromPersisted(contexts[activeKey]) : {};
|
|
2332
2414
|
return {
|
|
2333
|
-
...currentState,
|
|
2334
2415
|
previewEditorsEnabled: p.previewEditorsEnabled ?? currentState.previewEditorsEnabled,
|
|
2335
2416
|
diffViewMode: p.diffViewMode ?? currentState.diffViewMode,
|
|
2336
2417
|
activeUserId: p.activeUserId ?? null,
|
|
2337
2418
|
activeAgentId: p.activeAgentId ?? null,
|
|
2338
2419
|
activeProjectId: p.activeProjectId ?? null,
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
}
|
|
2420
|
+
contexts: p.contexts ?? {},
|
|
2421
|
+
...(p.windows ? { windows: p.windows } : {}),
|
|
2422
|
+
};
|
|
2342
2423
|
},
|
|
2343
2424
|
storage: createJSONStorage(() =>
|
|
2344
2425
|
typeof window !== 'undefined' && window.localStorage
|
|
2345
2426
|
? window.localStorage
|
|
2346
2427
|
: { getItem: () => null, setItem: () => undefined, removeItem: () => undefined },
|
|
2347
2428
|
),
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
activeAgentId: state.activeAgentId,
|
|
2361
|
-
activeProjectId: state.activeProjectId,
|
|
2362
|
-
contexts,
|
|
2363
|
-
};
|
|
2364
|
-
},
|
|
2429
|
+
// NEVER the `files`/content dicts or a `Set` as such — every projection is a
|
|
2430
|
+
// `persistedContext` allowlist (Sets as arrays). `sidebarWidth` keeps its own
|
|
2431
|
+
// hand-persist.
|
|
2432
|
+
partialize: (state): PersistedFilesShape => ({
|
|
2433
|
+
previewEditorsEnabled: state.previewEditorsEnabled,
|
|
2434
|
+
diffViewMode: state.diffViewMode,
|
|
2435
|
+
activeUserId: state.activeUserId,
|
|
2436
|
+
activeAgentId: state.activeAgentId,
|
|
2437
|
+
activeProjectId: state.activeProjectId,
|
|
2438
|
+
contexts: state.contexts,
|
|
2439
|
+
...(state.windows ? { windows: state.windows } : {}),
|
|
2440
|
+
}),
|
|
2365
2441
|
}));
|
|
2442
|
+
|
|
2443
|
+
/** The live identity of one Files window: the host's window id, the signed-in user, the project and the BOUND agent. */
|
|
2444
|
+
export type FilesWindowIdentity = { windowId: string; userId: string; projectId: string; agentId: string };
|
|
2445
|
+
|
|
2446
|
+
function sameWindowIdentity(entry: PersistedFilesWindow, id: FilesWindowIdentity): boolean {
|
|
2447
|
+
return entry.userId === id.userId && entry.projectId === id.projectId && entry.agentId === id.agentId;
|
|
2448
|
+
}
|
|
2449
|
+
|
|
2450
|
+
/** A window projection without its identity fields — the shape `sliceFromPersisted` reads. */
|
|
2451
|
+
function viewOf(entry: PersistedFilesWindow): PersistedFilesContext {
|
|
2452
|
+
const { userId: _u, projectId: _p, agentId: _a, ...view } = entry;
|
|
2453
|
+
return view;
|
|
2454
|
+
}
|
|
2455
|
+
|
|
2456
|
+
/**
|
|
2457
|
+
* A new window store. Its view comes from the window's OWN projection when the
|
|
2458
|
+
* record holds one written for this LIVE identity; a projection written for
|
|
2459
|
+
* another user, project or agent is ignored, and a window with no projection
|
|
2460
|
+
* of its own starts from the defaults. The older per-agent `contexts` entries
|
|
2461
|
+
* are never read into a window (no ID translation from an agent key to a
|
|
2462
|
+
* window id): an upgrade resets the open tabs once.
|
|
2463
|
+
*/
|
|
2464
|
+
export function createFilesWindowStore(id: FilesWindowIdentity): FilesystemStoreApi {
|
|
2465
|
+
const record = filesPersistence.getState();
|
|
2466
|
+
const own = record.windows?.[id.windowId];
|
|
2467
|
+
const projection = own && sameWindowIdentity(own, id) ? viewOf(own) : undefined;
|
|
2468
|
+
return createFilesystemStore({
|
|
2469
|
+
client: createPackageApi({ slug: 'brain-core', getProjectId: () => id.projectId, getAgentId: () => id.agentId }),
|
|
2470
|
+
windowId: id.windowId,
|
|
2471
|
+
userId: id.userId,
|
|
2472
|
+
projectId: id.projectId,
|
|
2473
|
+
agentId: id.agentId,
|
|
2474
|
+
previewEditorsEnabled: record.previewEditorsEnabled,
|
|
2475
|
+
diffViewMode: record.diffViewMode,
|
|
2476
|
+
...(projection ? { projection } : {}),
|
|
2477
|
+
});
|
|
2478
|
+
}
|
|
2479
|
+
|
|
2480
|
+
/** The fields `persistedContext` reads — a store update that changed none of them changed no projection. */
|
|
2481
|
+
const PROJECTION_INPUTS = [
|
|
2482
|
+
'openFiles', 'activeFile', 'tabMeta', 'expandedFolders',
|
|
2483
|
+
'sidebarView', 'activeWorkspaceTab', 'workspacePanels', 'activeFolderPath',
|
|
2484
|
+
] as const;
|
|
2485
|
+
|
|
2486
|
+
/**
|
|
2487
|
+
* Feed ONE window's view into the persistence owner while it is mounted. Per
|
|
2488
|
+
* store update: O(1) — eight reference compares; only an update that touched a
|
|
2489
|
+
* projection input serializes the projection, and only a projection that
|
|
2490
|
+
* differs from the last one written reaches the owner (which serializes the
|
|
2491
|
+
* whole allowlisted record once). Returns the unsubscribe.
|
|
2492
|
+
*/
|
|
2493
|
+
function bindWindowPersistence(store: FilesystemStoreApi, id: FilesWindowIdentity): () => void {
|
|
2494
|
+
let last = JSON.stringify(persistedContext(store.getState() as unknown as Record<string, unknown>));
|
|
2495
|
+
return store.subscribe((state, prev) => {
|
|
2496
|
+
if (state.previewEditorsEnabled !== prev.previewEditorsEnabled || state.diffViewMode !== prev.diffViewMode) {
|
|
2497
|
+
const owner = filesPersistence.getState();
|
|
2498
|
+
if (owner.previewEditorsEnabled !== state.previewEditorsEnabled || owner.diffViewMode !== state.diffViewMode) {
|
|
2499
|
+
filesPersistence.setState({ previewEditorsEnabled: state.previewEditorsEnabled, diffViewMode: state.diffViewMode });
|
|
2500
|
+
}
|
|
2501
|
+
}
|
|
2502
|
+
if (!PROJECTION_INPUTS.some((key) => state[key] !== prev[key])) return;
|
|
2503
|
+
// The view belongs to the window's own identity; a store that left it writes nothing.
|
|
2504
|
+
if (state.activeUserId !== id.userId || state.activeProjectId !== id.projectId || state.activeAgentId !== id.agentId) return;
|
|
2505
|
+
const view = persistedContext(state as unknown as Record<string, unknown>);
|
|
2506
|
+
const json = JSON.stringify(view);
|
|
2507
|
+
if (json === last) return;
|
|
2508
|
+
last = json;
|
|
2509
|
+
filesPersistence.setState((owner) => ({
|
|
2510
|
+
windows: {
|
|
2511
|
+
...(owner.windows ?? {}),
|
|
2512
|
+
[id.windowId]: { userId: id.userId, projectId: id.projectId, agentId: id.agentId, ...view },
|
|
2513
|
+
},
|
|
2514
|
+
}));
|
|
2515
|
+
});
|
|
2516
|
+
}
|
|
2517
|
+
|
|
2518
|
+
type LiveFilesWindow = { identity: FilesWindowIdentity; store: FilesystemStoreApi };
|
|
2519
|
+
|
|
2520
|
+
/** Every mounted Files window — hidden and minimized ones included (they stay mounted). */
|
|
2521
|
+
const liveFilesWindows = new Set<LiveFilesWindow>();
|
|
2522
|
+
|
|
2523
|
+
/** The signed-in user, from the workspace provider — which user's records a broom prunes. */
|
|
2524
|
+
let liveFilesUserId: string | null = null;
|
|
2525
|
+
|
|
2526
|
+
export function setFilesLiveUser(userId: string | null): void {
|
|
2527
|
+
liveFilesUserId = userId;
|
|
2528
|
+
}
|
|
2529
|
+
|
|
2530
|
+
/**
|
|
2531
|
+
* Register a window store while it is mounted: the reset fan-out reaches it and
|
|
2532
|
+
* its view feeds the persistence owner. The disposer drops the subscription and
|
|
2533
|
+
* fences the store's in-flight readers — the persisted view stays (closing or
|
|
2534
|
+
* hiding a window is not a reset).
|
|
2535
|
+
*/
|
|
2536
|
+
export function mountFilesWindow(store: FilesystemStoreApi, identity: FilesWindowIdentity): () => void {
|
|
2537
|
+
const entry: LiveFilesWindow = { identity, store };
|
|
2538
|
+
liveFilesWindows.add(entry);
|
|
2539
|
+
const unbind = bindWindowPersistence(store, identity);
|
|
2540
|
+
return () => {
|
|
2541
|
+
unbind();
|
|
2542
|
+
liveFilesWindows.delete(entry);
|
|
2543
|
+
store.getState().fenceInFlightWriters();
|
|
2544
|
+
};
|
|
2545
|
+
}
|
|
2546
|
+
|
|
2547
|
+
function scopeReachesWindow(scope: ClientStateResetScope, id: FilesWindowIdentity): boolean {
|
|
2548
|
+
if (scope.projectId !== id.projectId) return false;
|
|
2549
|
+
if (scope.level === 'project') return true;
|
|
2550
|
+
if (scope.agentId !== id.agentId) return false;
|
|
2551
|
+
return scope.level === 'agent' || scope.instanceId === id.windowId;
|
|
2552
|
+
}
|
|
2553
|
+
|
|
2554
|
+
/**
|
|
2555
|
+
* The persisted half of a broom, applied to the ONE record: `instance` drops that
|
|
2556
|
+
* window's projection; `widget` drops it AND the legacy (user, project, agent)
|
|
2557
|
+
* context — the type-clean meaning that level always had; `agent` / `project`
|
|
2558
|
+
* drop the legacy contexts and the window projections of their scope. Only the
|
|
2559
|
+
* signed-in user's records; another user's, project's or agent's stay.
|
|
2560
|
+
*/
|
|
2561
|
+
function resetPersistedFiles(scope: ClientStateResetScope, userId: string | null): void {
|
|
2562
|
+
if ((scope.level === 'instance' || scope.level === 'widget') && scope.widgetType !== FILESYSTEM_WIDGET_TYPE) return;
|
|
2563
|
+
const record = filesPersistence.getState();
|
|
2564
|
+
const dropWindow = (windowId: string, entry: PersistedFilesWindow): boolean => {
|
|
2565
|
+
if (entry.projectId !== scope.projectId) return false;
|
|
2566
|
+
if (scope.level === 'instance' || scope.level === 'widget') return windowId === scope.instanceId && entry.agentId === scope.agentId;
|
|
2567
|
+
if (userId === null || entry.userId !== userId) return false;
|
|
2568
|
+
return scope.level === 'project' || entry.agentId === scope.agentId;
|
|
2569
|
+
};
|
|
2570
|
+
const dropContext = (key: string): boolean => {
|
|
2571
|
+
if (scope.level === 'instance') return false;
|
|
2572
|
+
if (scope.level === 'project') return key.startsWith(`${userId ?? ''}::${scope.projectId}::`);
|
|
2573
|
+
return key === contextKey(userId, scope.projectId, scope.agentId);
|
|
2574
|
+
};
|
|
2575
|
+
let windows = record.windows;
|
|
2576
|
+
for (const [windowId, entry] of Object.entries(record.windows ?? {})) {
|
|
2577
|
+
if (!dropWindow(windowId, entry)) continue;
|
|
2578
|
+
if (windows === record.windows) windows = { ...record.windows };
|
|
2579
|
+
delete windows![windowId];
|
|
2580
|
+
}
|
|
2581
|
+
let contexts = record.contexts;
|
|
2582
|
+
for (const key of Object.keys(record.contexts)) {
|
|
2583
|
+
if (!dropContext(key)) continue;
|
|
2584
|
+
if (contexts === record.contexts) contexts = { ...record.contexts };
|
|
2585
|
+
delete contexts[key];
|
|
2586
|
+
}
|
|
2587
|
+
if (windows === record.windows && contexts === record.contexts) return;
|
|
2588
|
+
filesPersistence.setState({ contexts, ...(windows ? { windows } : {}) });
|
|
2589
|
+
}
|
|
2590
|
+
|
|
2591
|
+
/**
|
|
2592
|
+
* The ONE handler behind the install-level `port.onClientStateReset`
|
|
2593
|
+
* subscription: the matching live window stores first (each wipe re-feeds its
|
|
2594
|
+
* own, now empty, view), then the persisted record — so an `instance` / `widget`
|
|
2595
|
+
* reset leaves that window's projection absent and a sibling's untouched.
|
|
2596
|
+
* O(mounted Files windows) per broom event.
|
|
2597
|
+
*/
|
|
2598
|
+
export function resetFilesClientState(scope: ClientStateResetScope): void {
|
|
2599
|
+
for (const live of liveFilesWindows) {
|
|
2600
|
+
if (scopeReachesWindow(scope, live.identity)) live.store.getState().resetClientState(scope);
|
|
2601
|
+
}
|
|
2602
|
+
resetPersistedFiles(scope, liveFilesUserId);
|
|
2603
|
+
}
|
|
2604
|
+
|
|
2605
|
+
/**
|
|
2606
|
+
* The store of the Files window being rendered: created once per LIVE identity
|
|
2607
|
+
* (window, user, project, bound agent) — a different user or project is a
|
|
2608
|
+
* different store, never a re-used one — and mounted for the window's lifetime.
|
|
2609
|
+
* `null` until the identity is known: nothing persisted is read, and nothing
|
|
2610
|
+
* paints, before the live user and project are.
|
|
2611
|
+
*/
|
|
2612
|
+
export function useFilesWindowStore(
|
|
2613
|
+
windowId: string | null | undefined,
|
|
2614
|
+
userId: string | null | undefined,
|
|
2615
|
+
projectId: string | null | undefined,
|
|
2616
|
+
agentId: string | null | undefined,
|
|
2617
|
+
): FilesystemStoreApi | null {
|
|
2618
|
+
const live = useMemo(() => {
|
|
2619
|
+
if (!windowId || !userId || !projectId || !agentId) return null;
|
|
2620
|
+
const identity: FilesWindowIdentity = { windowId, userId, projectId, agentId };
|
|
2621
|
+
return { identity, store: createFilesWindowStore(identity) };
|
|
2622
|
+
}, [windowId, userId, projectId, agentId]);
|
|
2623
|
+
// Layout phase: registered (and its persistence bound) before any child's
|
|
2624
|
+
// passive effect issues the first request; disposed before the next paint.
|
|
2625
|
+
useLayoutEffect(() => (live ? mountFilesWindow(live.store, live.identity) : undefined), [live]);
|
|
2626
|
+
return live?.store ?? null;
|
|
2627
|
+
}
|
|
2628
|
+
|
|
2629
|
+
const FilesystemStoreContext = createContext<FilesystemStoreApi | null>(null);
|
|
2630
|
+
|
|
2631
|
+
/** Puts a window's store under its Files tree; every `useFilesystemStore` below reads THIS window's store. */
|
|
2632
|
+
export function FilesystemStoreProvider({ store, children }: { store: FilesystemStoreApi; children?: ReactNode }) {
|
|
2633
|
+
return createElement(FilesystemStoreContext.Provider, { value: store }, children);
|
|
2634
|
+
}
|
|
2635
|
+
|
|
2636
|
+
/** The window's store itself, for imperative reads/writes outside a selector (event handlers, the SSE hook). */
|
|
2637
|
+
export function useFilesystemStoreApi(): FilesystemStoreApi {
|
|
2638
|
+
const store = useContext(FilesystemStoreContext);
|
|
2639
|
+
if (!store) throw new Error('useFilesystemStore must be used inside a Files window (FilesystemStoreProvider)');
|
|
2640
|
+
return store;
|
|
2641
|
+
}
|
|
2642
|
+
|
|
2643
|
+
/** Select from the store of the Files window this component renders in. */
|
|
2644
|
+
export function useFilesystemStore<T>(selector: (state: FilesystemStore) => T): T {
|
|
2645
|
+
return useStore(useFilesystemStoreApi(), selector);
|
|
2646
|
+
}
|