@neuralis/brain-core 0.2.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/README.md +1 -1
- package/app/filesystem/README.md +3 -3
- package/app/filesystem/api/README.md +3 -3
- package/app/filesystem/api/index.ts +127 -122
- 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 +11 -7
- package/app/filesystem/store/filesystemStore.ts +434 -161
- package/app/filesystem/store/gitSlice.ts +6 -6
- package/app/filesystem/store/listSlice.ts +8 -6
- package/app/filesystem/store/searchSlice.ts +3 -3
- package/app/filesystem/ui/FileEditor.tsx +48 -35
- 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 -13
- package/app/filesystem/ui/GitDiffView.tsx +2 -1
- package/app/filesystem/ui/GitFileDiff.tsx +7 -6
- package/app/filesystem/ui/PendingChangesPanel.tsx +16 -13
- package/app/filesystem/ui/PendingReviewDialog.tsx +8 -5
- package/app/filesystem/ui/ReembedDialog.tsx +4 -2
- package/app/filesystem/ui/SourcesPanel.tsx +26 -26
- package/app/filesystem/ui/VectorHealthPanel.tsx +6 -4
- package/dist/app/host.js +46 -46
- package/package.json +4 -4
- package/skills/create-package/references/manifest-and-discovery.md +1 -1
- package/skills/create-package/references/surfaces-and-design.md +7 -5
- package/skills/create-package/scripts/check-package.sh +47 -1
- package/app/filesystem/hooks/useContainerWidth.ts +0 -39
|
@@ -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
|
/**
|
|
@@ -1078,7 +1106,11 @@ function readStoredSidebarWidth(): number {
|
|
|
1078
1106
|
}
|
|
1079
1107
|
}
|
|
1080
1108
|
|
|
1081
|
-
/**
|
|
1109
|
+
/**
|
|
1110
|
+
* The localStorage payload (v2): prefs, the active triple, the per-agent context
|
|
1111
|
+
* projections, and — OPTIONAL, absent until a window writes one — the per-window
|
|
1112
|
+
* projections keyed by the host's window id (`PersistedFilesWindow`).
|
|
1113
|
+
*/
|
|
1082
1114
|
type PersistedFilesShape = {
|
|
1083
1115
|
previewEditorsEnabled: boolean;
|
|
1084
1116
|
diffViewMode: DiffViewMode;
|
|
@@ -1086,9 +1118,39 @@ type PersistedFilesShape = {
|
|
|
1086
1118
|
activeAgentId: string | null;
|
|
1087
1119
|
activeProjectId: string | null;
|
|
1088
1120
|
contexts: Record<string, PersistedFilesContext>;
|
|
1121
|
+
windows?: Record<string, PersistedFilesWindow>;
|
|
1089
1122
|
};
|
|
1090
1123
|
|
|
1091
|
-
|
|
1124
|
+
/** What a new store starts from: its window, its request client, the preferences and the seeded view. */
|
|
1125
|
+
export type FilesystemStoreInit = {
|
|
1126
|
+
/** The request client every API call of this store goes through (see `FilesystemStore.client`). */
|
|
1127
|
+
client: PackageApi;
|
|
1128
|
+
windowId?: string | null;
|
|
1129
|
+
userId?: string | null;
|
|
1130
|
+
projectId?: string | null;
|
|
1131
|
+
agentId?: string | null;
|
|
1132
|
+
previewEditorsEnabled?: boolean;
|
|
1133
|
+
diffViewMode?: DiffViewMode;
|
|
1134
|
+
/** The window's persisted view — its own projection (a new window has none and starts from the defaults). */
|
|
1135
|
+
projection?: Partial<PersistedFilesContext>;
|
|
1136
|
+
};
|
|
1137
|
+
|
|
1138
|
+
export type FilesystemStoreApi = ZustandStoreApi<FilesystemStore>;
|
|
1139
|
+
|
|
1140
|
+
/**
|
|
1141
|
+
* ONE Files store per window: a live view that persists nothing itself — the
|
|
1142
|
+
* ONE writer of `neuralis.filesystem.tabs` is `filesPersistence` below, which a
|
|
1143
|
+
* mounted window feeds through `mountFilesWindow`. Every asynchronous fence and
|
|
1144
|
+
* dedup map is created with the store (`StoreFences`, the list/search/git slice
|
|
1145
|
+
* closures), so two windows — of two agents, or of the same agent — never fence,
|
|
1146
|
+
* supersede or dedup each other.
|
|
1147
|
+
*/
|
|
1148
|
+
export function createFilesystemStore(init: FilesystemStoreInit): FilesystemStoreApi {
|
|
1149
|
+
const fences = createStoreFences();
|
|
1150
|
+
const client = init.client;
|
|
1151
|
+
return createStore<FilesystemStore>()((set, get) => ({
|
|
1152
|
+
windowId: init.windowId ?? null,
|
|
1153
|
+
client,
|
|
1092
1154
|
files: {},
|
|
1093
1155
|
folderBadges: {},
|
|
1094
1156
|
fileContentByPath: {},
|
|
@@ -1098,17 +1160,17 @@ export const useFilesystemStore = create<FilesystemStore>()(persist((set, get) =
|
|
|
1098
1160
|
openFiles: [],
|
|
1099
1161
|
activeFile: null,
|
|
1100
1162
|
tabMeta: {},
|
|
1101
|
-
activeUserId: null,
|
|
1163
|
+
activeUserId: init.userId ?? null,
|
|
1102
1164
|
contextStateByKey: {},
|
|
1103
|
-
previewEditorsEnabled: false,
|
|
1165
|
+
previewEditorsEnabled: init.previewEditorsEnabled ?? false,
|
|
1104
1166
|
// Nothing is pre-expanded — under lazy hydration an expanded entry is a FETCH,
|
|
1105
1167
|
// and every source branch (including `brain://`) starts collapsed.
|
|
1106
1168
|
expandedFolders: new Set<string>(),
|
|
1107
1169
|
sidebarOpen: true,
|
|
1108
1170
|
sidebarWidth: readStoredSidebarWidth(),
|
|
1109
1171
|
sidebarView: 'tree',
|
|
1110
|
-
activeAgentId: null,
|
|
1111
|
-
activeProjectId: null,
|
|
1172
|
+
activeAgentId: init.agentId ?? null,
|
|
1173
|
+
activeProjectId: init.projectId ?? null,
|
|
1112
1174
|
sources: [],
|
|
1113
1175
|
sourcesLoading: true,
|
|
1114
1176
|
activeSource: null,
|
|
@@ -1119,7 +1181,7 @@ export const useFilesystemStore = create<FilesystemStore>()(persist((set, get) =
|
|
|
1119
1181
|
activeWorkspaceTab: 'file',
|
|
1120
1182
|
pendingSourceSuggestion: null,
|
|
1121
1183
|
vectorHealthOpen: false,
|
|
1122
|
-
diffViewMode: 'auto',
|
|
1184
|
+
diffViewMode: init.diffViewMode ?? 'auto',
|
|
1123
1185
|
pendingPanelFilter: {},
|
|
1124
1186
|
// Nothing is hydrated at boot. `brain://` is EXPANDED by default (below) and the
|
|
1125
1187
|
// tree's hydration effect lists it — marking it "loaded" here would suppress that
|
|
@@ -1269,28 +1331,28 @@ export const useFilesystemStore = create<FilesystemStore>()(persist((set, get) =
|
|
|
1269
1331
|
// Enumerated once, then hidden — no reconcile listed it off screen, so its rows
|
|
1270
1332
|
// may be stale. ONE authoritative listing of exactly this folder (absence
|
|
1271
1333
|
// applies), never a re-list of the whole visible tree.
|
|
1272
|
-
await runSourceReconcile({ get, set }, { agentId, projectId, source: sourceFromUri(uri), targets: [uri], wide: false });
|
|
1334
|
+
await runSourceReconcile({ get, set, fences, client }, { agentId, projectId, source: sourceFromUri(uri), targets: [uri], wide: false });
|
|
1273
1335
|
return;
|
|
1274
1336
|
}
|
|
1275
1337
|
|
|
1276
|
-
const existing = liveFolderLoadInFlight.get(uri);
|
|
1338
|
+
const existing = fences.liveFolderLoadInFlight.get(uri);
|
|
1277
1339
|
if (existing) { await existing; return; }
|
|
1278
1340
|
|
|
1279
1341
|
set((state: any) => ({ hydrationState: { ...(state.hydrationState || {}), [uri]: 'loading' as const } }));
|
|
1280
1342
|
|
|
1281
1343
|
const task = (async () => {
|
|
1282
|
-
const gen = nextFolderGeneration(uri);
|
|
1344
|
+
const gen = nextFolderGeneration(fences, uri);
|
|
1283
1345
|
const issuedAt = Date.now();
|
|
1284
1346
|
try {
|
|
1285
1347
|
// recursive:false → single `readdir` on the connector, ~50ms even on
|
|
1286
1348
|
// million-file subtrees. Replaces the previous full-tree snapshot call.
|
|
1287
|
-
const res = await resolveList(agentId, projectId ?? undefined, { uri, recursive: false });
|
|
1349
|
+
const res = await resolveList(agentId, projectId ?? undefined, { uri, recursive: false }, client);
|
|
1288
1350
|
// Superseded by a newer listing of the same folder (a concurrent reconcile
|
|
1289
1351
|
// or a second expand) → drop this response instead of overwriting. Restore
|
|
1290
1352
|
// the fetch state on the way out: returning bare left `loading` pinned
|
|
1291
1353
|
// forever, and the refresh control is `disabled={loading}` — the one control
|
|
1292
1354
|
// that could recover would have been disabled by the state it must clear.
|
|
1293
|
-
if (!isCurrentFolderGeneration(uri, gen)) {
|
|
1355
|
+
if (!isCurrentFolderGeneration(fences, uri, gen)) {
|
|
1294
1356
|
set((state: any) => ({
|
|
1295
1357
|
hydrationState: {
|
|
1296
1358
|
...(state.hydrationState || {}),
|
|
@@ -1327,8 +1389,8 @@ export const useFilesystemStore = create<FilesystemStore>()(persist((set, get) =
|
|
|
1327
1389
|
}
|
|
1328
1390
|
})();
|
|
1329
1391
|
|
|
1330
|
-
liveFolderLoadInFlight.set(uri, task);
|
|
1331
|
-
try { await task; } finally { liveFolderLoadInFlight.delete(uri); }
|
|
1392
|
+
fences.liveFolderLoadInFlight.set(uri, task);
|
|
1393
|
+
try { await task; } finally { fences.liveFolderLoadInFlight.delete(uri); }
|
|
1332
1394
|
},
|
|
1333
1395
|
|
|
1334
1396
|
clearOrganizeError: () => set(() => ({ organizeError: null })),
|
|
@@ -1345,31 +1407,32 @@ export const useFilesystemStore = create<FilesystemStore>()(persist((set, get) =
|
|
|
1345
1407
|
}),
|
|
1346
1408
|
|
|
1347
1409
|
async reconcileSourceFolders({ agentId, projectId, source }) {
|
|
1348
|
-
await runSourceReconcile({ get, set }, { agentId, projectId, source });
|
|
1410
|
+
await runSourceReconcile({ get, set, fences, client }, { agentId, projectId, source });
|
|
1349
1411
|
},
|
|
1350
1412
|
|
|
1351
1413
|
fenceInFlightWriters() {
|
|
1352
|
-
resetEpoch += 1;
|
|
1353
|
-
for (const key of Array.from(folderGeneration.keys())) nextFolderGeneration(key);
|
|
1414
|
+
fences.resetEpoch += 1;
|
|
1415
|
+
for (const key of Array.from(fences.folderGeneration.keys())) nextFolderGeneration(fences, key);
|
|
1354
1416
|
// The dedup maps only; the in-flight tasks' own `finally` deletes a missing
|
|
1355
1417
|
// key as a no-op. `reconcileJobs` is deliberately NOT cleared: a running job's
|
|
1356
1418
|
// `finally` would then delete the NEXT job's entry and two passes would run
|
|
1357
1419
|
// concurrently — the job runs to completion and the epoch makes it inert.
|
|
1358
|
-
liveFolderLoadInFlight.clear();
|
|
1359
|
-
rehydrateInFlight.clear();
|
|
1420
|
+
fences.liveFolderLoadInFlight.clear();
|
|
1421
|
+
fences.rehydrateInFlight.clear();
|
|
1360
1422
|
},
|
|
1361
1423
|
|
|
1362
1424
|
resetClientState(scope) {
|
|
1363
1425
|
const s = get();
|
|
1364
1426
|
if (!s.activeProjectId || scope.projectId !== s.activeProjectId) return;
|
|
1365
1427
|
if (scope.level !== 'project' && scope.agentId !== s.activeAgentId) return;
|
|
1366
|
-
if (scope.level === 'widget'
|
|
1428
|
+
if ((scope.level === 'widget' || scope.level === 'instance')
|
|
1429
|
+
&& (scope.widgetType !== FILESYSTEM_WIDGET_TYPE || scope.instanceId !== s.windowId)) return;
|
|
1367
1430
|
// Fence FIRST, then wipe: a read/listing/reconcile that resolves after this
|
|
1368
1431
|
// point finds a stale epoch (or a stale folder generation) and writes nothing.
|
|
1369
1432
|
get().fenceInFlightWriters();
|
|
1370
1433
|
// 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.
|
|
1434
|
+
// only: the instance/widget/agent levels drop this context, the project level
|
|
1435
|
+
// every context of the project (this user's). Other projects are not its business.
|
|
1373
1436
|
const prefix = `${s.activeUserId ?? ''}::${s.activeProjectId}::`;
|
|
1374
1437
|
const own = contextKey(s.activeUserId, s.activeProjectId, s.activeAgentId ?? '');
|
|
1375
1438
|
const kept: Record<string, Record<string, unknown>> = {};
|
|
@@ -1408,7 +1471,7 @@ export const useFilesystemStore = create<FilesystemStore>()(persist((set, get) =
|
|
|
1408
1471
|
if (bySource.size === 0) return;
|
|
1409
1472
|
await Promise.all(
|
|
1410
1473
|
Array.from(bySource.entries()).map(([source, targets]) =>
|
|
1411
|
-
runSourceReconcile({ get, set }, { agentId, projectId, source, targets }),
|
|
1474
|
+
runSourceReconcile({ get, set, fences, client }, { agentId, projectId, source, targets }),
|
|
1412
1475
|
),
|
|
1413
1476
|
);
|
|
1414
1477
|
},
|
|
@@ -1430,7 +1493,7 @@ export const useFilesystemStore = create<FilesystemStore>()(persist((set, get) =
|
|
|
1430
1493
|
if (source) sources.add(source);
|
|
1431
1494
|
}
|
|
1432
1495
|
await Promise.all(
|
|
1433
|
-
Array.from(sources).map((source) => runSourceReconcile({ get, set }, { agentId, projectId, source })),
|
|
1496
|
+
Array.from(sources).map((source) => runSourceReconcile({ get, set, fences, client }, { agentId, projectId, source })),
|
|
1434
1497
|
);
|
|
1435
1498
|
},
|
|
1436
1499
|
|
|
@@ -1445,8 +1508,8 @@ export const useFilesystemStore = create<FilesystemStore>()(persist((set, get) =
|
|
|
1445
1508
|
const agentId = s.activeAgentId;
|
|
1446
1509
|
if (!uri || !agentId) return;
|
|
1447
1510
|
if (hasCompletedRead(s, uri)) return;
|
|
1448
|
-
if (rehydrateInFlight.has(uri)) return;
|
|
1449
|
-
rehydrateInFlight.add(uri);
|
|
1511
|
+
if (fences.rehydrateInFlight.has(uri)) return;
|
|
1512
|
+
fences.rehydrateInFlight.add(uri);
|
|
1450
1513
|
try {
|
|
1451
1514
|
// A tab from another agent's source reads through that agent, as the tree
|
|
1452
1515
|
// open did — never through a SENTINEL (`__system__` is the writer the
|
|
@@ -1459,7 +1522,7 @@ export const useFilesystemStore = create<FilesystemStore>()(persist((set, get) =
|
|
|
1459
1522
|
// rehydrate is not a user open.
|
|
1460
1523
|
await s.openFile({ agentId, projectId: s.activeProjectId, uri, readAgentId, revealPane: false });
|
|
1461
1524
|
} finally {
|
|
1462
|
-
rehydrateInFlight.delete(uri);
|
|
1525
|
+
fences.rehydrateInFlight.delete(uri);
|
|
1463
1526
|
}
|
|
1464
1527
|
},
|
|
1465
1528
|
|
|
@@ -1679,8 +1742,8 @@ export const useFilesystemStore = create<FilesystemStore>()(persist((set, get) =
|
|
|
1679
1742
|
// The server-side work completes whatever happens to the store; every store
|
|
1680
1743
|
// write after an await is fenced — a context reset mid-save must not re-key,
|
|
1681
1744
|
// fill, dirty-flag or re-open anything under the new context.
|
|
1682
|
-
const epoch = resetEpoch;
|
|
1683
|
-
const fenced = () => epoch !== resetEpoch;
|
|
1745
|
+
const epoch = fences.resetEpoch;
|
|
1746
|
+
const fenced = () => epoch !== fences.resetEpoch;
|
|
1684
1747
|
|
|
1685
1748
|
let effectiveUri = fromUri;
|
|
1686
1749
|
if (moved) {
|
|
@@ -1690,7 +1753,7 @@ export const useFilesystemStore = create<FilesystemStore>()(persist((set, get) =
|
|
|
1690
1753
|
id: node.id,
|
|
1691
1754
|
uri: fromUri,
|
|
1692
1755
|
move: targetUri,
|
|
1693
|
-
});
|
|
1756
|
+
}, client);
|
|
1694
1757
|
if (!res || res.ok !== true) throw new Error(writeRefusalMessage(res, 'Move failed'));
|
|
1695
1758
|
effectiveUri = targetUri;
|
|
1696
1759
|
if (fenced()) return { uri: effectiveUri, moved, contentChanged: false };
|
|
@@ -1704,7 +1767,7 @@ export const useFilesystemStore = create<FilesystemStore>()(persist((set, get) =
|
|
|
1704
1767
|
id: node.id,
|
|
1705
1768
|
uri: effectiveUri,
|
|
1706
1769
|
overwrite: { content },
|
|
1707
|
-
});
|
|
1770
|
+
}, client);
|
|
1708
1771
|
if (!res || res.ok !== true) throw new Error(writeRefusalMessage(res, 'Save failed'));
|
|
1709
1772
|
if (fenced()) return { uri: effectiveUri, moved, contentChanged };
|
|
1710
1773
|
// After a move the node lives under its NEW uri (`replaceFilePath` re-keys
|
|
@@ -1726,32 +1789,32 @@ export const useFilesystemStore = create<FilesystemStore>()(persist((set, get) =
|
|
|
1726
1789
|
|
|
1727
1790
|
async refreshPendingCount({ agentId, projectId }) {
|
|
1728
1791
|
if (!agentId) return;
|
|
1729
|
-
const mine = ++pendingCountSeq;
|
|
1792
|
+
const mine = ++fences.pendingCountSeq;
|
|
1730
1793
|
// A failed count keeps the last known number — the next event, focus or reconnect re-asks.
|
|
1731
1794
|
let count: number;
|
|
1732
|
-
try { count = await countPendingChanges({ agentId, ...(projectId ? { projectId } : {}) }); } catch { return; }
|
|
1795
|
+
try { count = await countPendingChanges({ agentId, ...(projectId ? { projectId } : {}) }, client); } catch { return; }
|
|
1733
1796
|
// No reset-epoch fence: the count is the context's own server answer, so one
|
|
1734
1797
|
// that resolves after a reset of the SAME context is still right; the context
|
|
1735
1798
|
// check below is what keeps another context's number out.
|
|
1736
|
-
if (mine !== pendingCountSeq) return;
|
|
1799
|
+
if (mine !== fences.pendingCountSeq) return;
|
|
1737
1800
|
const s = get();
|
|
1738
1801
|
if (s.activeAgentId !== agentId || s.activeProjectId !== projectId) return;
|
|
1739
1802
|
set(() => ({ pendingCount: count }));
|
|
1740
1803
|
},
|
|
1741
1804
|
|
|
1742
1805
|
async markFileDeleted({ agentId, projectId, node }) {
|
|
1743
|
-
const epoch = resetEpoch;
|
|
1806
|
+
const epoch = fences.resetEpoch;
|
|
1744
1807
|
const res = await writeFile({
|
|
1745
1808
|
agentId,
|
|
1746
1809
|
...(projectId ? { projectId } : {}),
|
|
1747
1810
|
id: node.id,
|
|
1748
1811
|
uri: node.uri,
|
|
1749
1812
|
delete: true,
|
|
1750
|
-
});
|
|
1813
|
+
}, client);
|
|
1751
1814
|
if (!res || res.ok !== true) throw new Error(writeRefusalMessage(res, 'Delete failed'));
|
|
1752
1815
|
// The reconcile flags an unloaded source dirty — not a no-op on a store the
|
|
1753
1816
|
// reset just handed to another context.
|
|
1754
|
-
if (epoch !== resetEpoch) return;
|
|
1817
|
+
if (epoch !== fences.resetEpoch) return;
|
|
1755
1818
|
await get().reconcileFolders({ agentId, projectId, folders: [folderUri(node.uri)] });
|
|
1756
1819
|
},
|
|
1757
1820
|
|
|
@@ -1772,7 +1835,7 @@ export const useFilesystemStore = create<FilesystemStore>()(persist((set, get) =
|
|
|
1772
1835
|
id,
|
|
1773
1836
|
uri: fromUri,
|
|
1774
1837
|
move: destinationUri,
|
|
1775
|
-
});
|
|
1838
|
+
}, client);
|
|
1776
1839
|
} catch (e) {
|
|
1777
1840
|
console.error('[Filesystem] move failed', fromUri, e);
|
|
1778
1841
|
set(() => ({ organizeError: 'Move failed' }));
|
|
@@ -1801,7 +1864,7 @@ export const useFilesystemStore = create<FilesystemStore>()(persist((set, get) =
|
|
|
1801
1864
|
// of the old subtree would stay in the store forever. Prune by prefix (the
|
|
1802
1865
|
// pattern `renameNode` already proved) and force-relist the destination.
|
|
1803
1866
|
if (!retained) {
|
|
1804
|
-
prunePrefix({ get, set }, fromUri);
|
|
1867
|
+
prunePrefix({ get, set, fences, client }, fromUri);
|
|
1805
1868
|
}
|
|
1806
1869
|
await get().ensureFolderLoaded({ agentId, projectId, uri: toFolderUri, force: true });
|
|
1807
1870
|
} else {
|
|
@@ -1842,7 +1905,7 @@ export const useFilesystemStore = create<FilesystemStore>()(persist((set, get) =
|
|
|
1842
1905
|
id,
|
|
1843
1906
|
uri,
|
|
1844
1907
|
move: isFolder ? destinationUri : trimmed,
|
|
1845
|
-
});
|
|
1908
|
+
}, client);
|
|
1846
1909
|
} catch (e) {
|
|
1847
1910
|
console.error('[Filesystem] rename failed', uri, e);
|
|
1848
1911
|
set(() => ({ organizeError: 'Rename failed' }));
|
|
@@ -1861,7 +1924,7 @@ export const useFilesystemStore = create<FilesystemStore>()(persist((set, get) =
|
|
|
1861
1924
|
// tombstoned the old one. Prune the stale old subtree from the store and
|
|
1862
1925
|
// force-relist the parent so the renamed folder surfaces immediately (the
|
|
1863
1926
|
// non-recursive `refresh` alone would not re-list a nested folder's parent).
|
|
1864
|
-
prunePrefix({ get, set }, uri);
|
|
1927
|
+
prunePrefix({ get, set, fences, client }, uri);
|
|
1865
1928
|
await get().ensureFolderLoaded({ agentId, projectId, uri: parent, force: true });
|
|
1866
1929
|
} else {
|
|
1867
1930
|
get().replaceFilePath(uri, destinationUri);
|
|
@@ -1873,11 +1936,11 @@ export const useFilesystemStore = create<FilesystemStore>()(persist((set, get) =
|
|
|
1873
1936
|
const trimmed = (name ?? '').trim();
|
|
1874
1937
|
if (!agentId || !parentUri || !trimmed) return;
|
|
1875
1938
|
const targetUri = parentUri.endsWith('://') ? `${parentUri}${trimmed}` : `${parentUri}/${trimmed}`;
|
|
1876
|
-
const epoch = resetEpoch;
|
|
1939
|
+
const epoch = fences.resetEpoch;
|
|
1877
1940
|
try {
|
|
1878
1941
|
if (kind === 'folder') {
|
|
1879
|
-
const res = await writeFile({ agentId, ...(projectId ? { projectId } : {}), uri: targetUri, mkdir: true });
|
|
1880
|
-
if (res?.ok && epoch === resetEpoch) {
|
|
1942
|
+
const res = await writeFile({ agentId, ...(projectId ? { projectId } : {}), uri: targetUri, mkdir: true }, client);
|
|
1943
|
+
if (res?.ok && epoch === fences.resetEpoch) {
|
|
1881
1944
|
// Optimistic folder node — an EMPTY folder has no vector node, so only a
|
|
1882
1945
|
// connector-live re-list surfaces it; seed it now so it appears instantly.
|
|
1883
1946
|
set((state) => {
|
|
@@ -1908,7 +1971,7 @@ export const useFilesystemStore = create<FilesystemStore>()(persist((set, get) =
|
|
|
1908
1971
|
file: new File([''], trimmed),
|
|
1909
1972
|
uri: parentUri,
|
|
1910
1973
|
tags: [],
|
|
1911
|
-
});
|
|
1974
|
+
}, client);
|
|
1912
1975
|
}
|
|
1913
1976
|
} catch (e) {
|
|
1914
1977
|
console.error('[Filesystem] create failed', kind, targetUri, e);
|
|
@@ -1931,11 +1994,11 @@ export const useFilesystemStore = create<FilesystemStore>()(persist((set, get) =
|
|
|
1931
1994
|
touched.add(folderUri(src));
|
|
1932
1995
|
try {
|
|
1933
1996
|
if (op === 'copy') {
|
|
1934
|
-
const res = await writeFile({ agentId, ...(projectId ? { projectId } : {}), uri: src, copy: dest });
|
|
1997
|
+
const res = await writeFile({ agentId, ...(projectId ? { projectId } : {}), uri: src, copy: dest }, client);
|
|
1935
1998
|
if (res && res.ok === false) rejected.push(base);
|
|
1936
1999
|
} else {
|
|
1937
2000
|
// 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 });
|
|
2001
|
+
const res = await writeFile({ agentId, ...(projectId ? { projectId } : {}), uri: src, move: dest }, client);
|
|
1939
2002
|
if (res && res.ok === false) rejected.push(base);
|
|
1940
2003
|
else if (res?.warning === 'moved_with_source_retained') rejected.push(`${base} (original retained)`);
|
|
1941
2004
|
else if (get().files[src]?.type === 'folder') {
|
|
@@ -1944,7 +2007,7 @@ export const useFilesystemStore = create<FilesystemStore>()(persist((set, get) =
|
|
|
1944
2007
|
// inspects direct children — so the old prefix has to go here or its
|
|
1945
2008
|
// interior lingers. Cut/paste reaches this path too, and only the drag
|
|
1946
2009
|
// path used to handle it.
|
|
1947
|
-
prunePrefix({ get, set }, src);
|
|
2010
|
+
prunePrefix({ get, set, fences, client }, src);
|
|
1948
2011
|
}
|
|
1949
2012
|
}
|
|
1950
2013
|
} catch (e) {
|
|
@@ -1974,7 +2037,7 @@ export const useFilesystemStore = create<FilesystemStore>()(persist((set, get) =
|
|
|
1974
2037
|
id: t.id,
|
|
1975
2038
|
uri: t.uri,
|
|
1976
2039
|
delete: true,
|
|
1977
|
-
});
|
|
2040
|
+
}, client);
|
|
1978
2041
|
if (res && res.ok === false) {
|
|
1979
2042
|
// 200-with-`ok:false`. A FOLDER delete can still have removed N files
|
|
1980
2043
|
// before failing — `affected`/`failed` carry that partial outcome, and
|
|
@@ -1990,7 +2053,7 @@ export const useFilesystemStore = create<FilesystemStore>()(persist((set, get) =
|
|
|
1990
2053
|
if (isFolder) {
|
|
1991
2054
|
// A folder delete removes the whole subtree server-side; prune by prefix so
|
|
1992
2055
|
// the interior cannot linger (the reconcile only inspects direct children).
|
|
1993
|
-
prunePrefix({ get, set }, t.uri);
|
|
2056
|
+
prunePrefix({ get, set, fences, client }, t.uri);
|
|
1994
2057
|
} else {
|
|
1995
2058
|
// Optimistic: strike the node (revertible tombstone). The targeted
|
|
1996
2059
|
// reconcile below converges to server truth — a tracked node KEEPS the
|
|
@@ -2074,16 +2137,16 @@ export const useFilesystemStore = create<FilesystemStore>()(persist((set, get) =
|
|
|
2074
2137
|
(typeof st.fileContentByPath[uri] === 'string' && (st.fileContentByPath[uri]?.length || 0) > 0);
|
|
2075
2138
|
if (!force && hasContent) return;
|
|
2076
2139
|
|
|
2077
|
-
const epoch = resetEpoch;
|
|
2140
|
+
const epoch = fences.resetEpoch;
|
|
2078
2141
|
try {
|
|
2079
2142
|
const res = await readFiles({
|
|
2080
2143
|
agentId: readAgentId || agentId,
|
|
2081
2144
|
...(projectId ? { projectId } : {}),
|
|
2082
2145
|
files: [uri],
|
|
2083
|
-
});
|
|
2146
|
+
}, client);
|
|
2084
2147
|
// The broom ran while the read was in flight: the tab is gone, the store is
|
|
2085
2148
|
// empty, and this answer must not put either back.
|
|
2086
|
-
if (epoch !== resetEpoch) return;
|
|
2149
|
+
if (epoch !== fences.resetEpoch) return;
|
|
2087
2150
|
const files = extractReadFilesPayload(res);
|
|
2088
2151
|
if (files.length > 0) {
|
|
2089
2152
|
const f = files[0];
|
|
@@ -2193,7 +2256,7 @@ export const useFilesystemStore = create<FilesystemStore>()(persist((set, get) =
|
|
|
2193
2256
|
} catch (e) {
|
|
2194
2257
|
const message = e instanceof Error ? e.message : 'Read failed';
|
|
2195
2258
|
console.error('[Filesystem] read failed', e);
|
|
2196
|
-
if (epoch !== resetEpoch) return;
|
|
2259
|
+
if (epoch !== fences.resetEpoch) return;
|
|
2197
2260
|
set((state) => ({
|
|
2198
2261
|
files: {
|
|
2199
2262
|
...state.files,
|
|
@@ -2253,7 +2316,7 @@ export const useFilesystemStore = create<FilesystemStore>()(persist((set, get) =
|
|
|
2253
2316
|
const targetUri = uri || 'brain://docs';
|
|
2254
2317
|
set(() => ({ uploading: true }));
|
|
2255
2318
|
try {
|
|
2256
|
-
await uploadFile({ agentId, ...(projectId ? { projectId } : {}), file, uri: targetUri, tags: ['upload'] });
|
|
2319
|
+
await uploadFile({ agentId, ...(projectId ? { projectId } : {}), file, uri: targetUri, tags: ['upload'] }, client);
|
|
2257
2320
|
await get().reconcileFolders({ agentId, projectId, folders: [targetUri] });
|
|
2258
2321
|
} finally {
|
|
2259
2322
|
set(() => ({ uploading: false }));
|
|
@@ -2268,25 +2331,43 @@ export const useFilesystemStore = create<FilesystemStore>()(persist((set, get) =
|
|
|
2268
2331
|
set(() => ({ uploading: true }));
|
|
2269
2332
|
try {
|
|
2270
2333
|
for (const file of list) {
|
|
2271
|
-
await uploadFile({ agentId, ...(projectId ? { projectId } : {}), file, uri: targetUri, tags: ['upload'] });
|
|
2334
|
+
await uploadFile({ agentId, ...(projectId ? { projectId } : {}), file, uri: targetUri, tags: ['upload'] }, client);
|
|
2272
2335
|
}
|
|
2273
2336
|
await get().reconcileFolders({ agentId, projectId, folders: [targetUri] });
|
|
2274
2337
|
} finally {
|
|
2275
2338
|
set(() => ({ uploading: false }));
|
|
2276
2339
|
}
|
|
2277
2340
|
},
|
|
2341
|
+
// The window's persisted view, lifted into the flat fields — after every
|
|
2342
|
+
// default above, so a seeded tab strip is what the first render shows.
|
|
2343
|
+
...(init.projection ? sliceFromPersisted(init.projection) : {}),
|
|
2344
|
+
}));
|
|
2345
|
+
}
|
|
2346
|
+
|
|
2347
|
+
/**
|
|
2348
|
+
* The ONE writer of `neuralis.filesystem.tabs` (v2). Window stores persist
|
|
2349
|
+
* nothing; a mounted window feeds its projection here (`mountFilesWindow`), so
|
|
2350
|
+
* two windows writing the same key can never overwrite each other's view — the
|
|
2351
|
+
* whole record is written from this one in-memory owner.
|
|
2352
|
+
*
|
|
2353
|
+
* What it holds: the preferences, the legacy per-agent `contexts` (the record an
|
|
2354
|
+
* older build wrote — never read into a window, pruned by the broom, otherwise
|
|
2355
|
+
* left as it is: never rewritten, re-keyed or converted), and the optional
|
|
2356
|
+
* `windows` map — one `PersistedFilesWindow` per window, carrying the
|
|
2357
|
+
* user/project/agent it was written for. A record without `windows` reads
|
|
2358
|
+
* without one and is not written back until a window really changes.
|
|
2359
|
+
*/
|
|
2360
|
+
export const filesPersistence = createStore<PersistedFilesShape>()(persist((): PersistedFilesShape => ({
|
|
2361
|
+
previewEditorsEnabled: false,
|
|
2362
|
+
diffViewMode: 'auto',
|
|
2363
|
+
activeUserId: null,
|
|
2364
|
+
activeAgentId: null,
|
|
2365
|
+
activeProjectId: null,
|
|
2366
|
+
contexts: {},
|
|
2278
2367
|
}), {
|
|
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.
|
|
2368
|
+
// v2 (2026-09-04): the flat tab trio became the context space; `migrate` lifts
|
|
2369
|
+
// a v1 payload's trio into the active context. The optional `windows` map is
|
|
2370
|
+
// additive — no version bump, an absent map stays absent.
|
|
2290
2371
|
name: 'neuralis.filesystem.tabs',
|
|
2291
2372
|
version: 2,
|
|
2292
2373
|
migrate: (persistedState, version) => {
|
|
@@ -2322,44 +2403,236 @@ export const useFilesystemStore = create<FilesystemStore>()(persist((set, get) =
|
|
|
2322
2403
|
merge: (persistedState, currentState) => {
|
|
2323
2404
|
const p = persistedState as Partial<PersistedFilesShape> | undefined;
|
|
2324
2405
|
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
2406
|
return {
|
|
2333
|
-
...currentState,
|
|
2334
2407
|
previewEditorsEnabled: p.previewEditorsEnabled ?? currentState.previewEditorsEnabled,
|
|
2335
2408
|
diffViewMode: p.diffViewMode ?? currentState.diffViewMode,
|
|
2336
2409
|
activeUserId: p.activeUserId ?? null,
|
|
2337
2410
|
activeAgentId: p.activeAgentId ?? null,
|
|
2338
2411
|
activeProjectId: p.activeProjectId ?? null,
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
}
|
|
2412
|
+
contexts: p.contexts ?? {},
|
|
2413
|
+
...(p.windows ? { windows: p.windows } : {}),
|
|
2414
|
+
};
|
|
2342
2415
|
},
|
|
2343
2416
|
storage: createJSONStorage(() =>
|
|
2344
2417
|
typeof window !== 'undefined' && window.localStorage
|
|
2345
2418
|
? window.localStorage
|
|
2346
2419
|
: { getItem: () => null, setItem: () => undefined, removeItem: () => undefined },
|
|
2347
2420
|
),
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
activeAgentId: state.activeAgentId,
|
|
2361
|
-
activeProjectId: state.activeProjectId,
|
|
2362
|
-
contexts,
|
|
2363
|
-
};
|
|
2364
|
-
},
|
|
2421
|
+
// NEVER the `files`/content dicts or a `Set` as such — every projection is a
|
|
2422
|
+
// `persistedContext` allowlist (Sets as arrays). `sidebarWidth` keeps its own
|
|
2423
|
+
// hand-persist.
|
|
2424
|
+
partialize: (state): PersistedFilesShape => ({
|
|
2425
|
+
previewEditorsEnabled: state.previewEditorsEnabled,
|
|
2426
|
+
diffViewMode: state.diffViewMode,
|
|
2427
|
+
activeUserId: state.activeUserId,
|
|
2428
|
+
activeAgentId: state.activeAgentId,
|
|
2429
|
+
activeProjectId: state.activeProjectId,
|
|
2430
|
+
contexts: state.contexts,
|
|
2431
|
+
...(state.windows ? { windows: state.windows } : {}),
|
|
2432
|
+
}),
|
|
2365
2433
|
}));
|
|
2434
|
+
|
|
2435
|
+
/** The live identity of one Files window: the host's window id, the signed-in user, the project and the BOUND agent. */
|
|
2436
|
+
export type FilesWindowIdentity = { windowId: string; userId: string; projectId: string; agentId: string };
|
|
2437
|
+
|
|
2438
|
+
function sameWindowIdentity(entry: PersistedFilesWindow, id: FilesWindowIdentity): boolean {
|
|
2439
|
+
return entry.userId === id.userId && entry.projectId === id.projectId && entry.agentId === id.agentId;
|
|
2440
|
+
}
|
|
2441
|
+
|
|
2442
|
+
/** A window projection without its identity fields — the shape `sliceFromPersisted` reads. */
|
|
2443
|
+
function viewOf(entry: PersistedFilesWindow): PersistedFilesContext {
|
|
2444
|
+
const { userId: _u, projectId: _p, agentId: _a, ...view } = entry;
|
|
2445
|
+
return view;
|
|
2446
|
+
}
|
|
2447
|
+
|
|
2448
|
+
/**
|
|
2449
|
+
* A new window store. Its view comes from the window's OWN projection when the
|
|
2450
|
+
* record holds one written for this LIVE identity; a projection written for
|
|
2451
|
+
* another user, project or agent is ignored, and a window with no projection
|
|
2452
|
+
* of its own starts from the defaults. The older per-agent `contexts` entries
|
|
2453
|
+
* are never read into a window (no ID translation from an agent key to a
|
|
2454
|
+
* window id): an upgrade resets the open tabs once.
|
|
2455
|
+
*/
|
|
2456
|
+
export function createFilesWindowStore(id: FilesWindowIdentity): FilesystemStoreApi {
|
|
2457
|
+
const record = filesPersistence.getState();
|
|
2458
|
+
const own = record.windows?.[id.windowId];
|
|
2459
|
+
const projection = own && sameWindowIdentity(own, id) ? viewOf(own) : undefined;
|
|
2460
|
+
return createFilesystemStore({
|
|
2461
|
+
client: createPackageApi({ slug: 'brain-core', getProjectId: () => id.projectId, getAgentId: () => id.agentId }),
|
|
2462
|
+
windowId: id.windowId,
|
|
2463
|
+
userId: id.userId,
|
|
2464
|
+
projectId: id.projectId,
|
|
2465
|
+
agentId: id.agentId,
|
|
2466
|
+
previewEditorsEnabled: record.previewEditorsEnabled,
|
|
2467
|
+
diffViewMode: record.diffViewMode,
|
|
2468
|
+
...(projection ? { projection } : {}),
|
|
2469
|
+
});
|
|
2470
|
+
}
|
|
2471
|
+
|
|
2472
|
+
/** The fields `persistedContext` reads — a store update that changed none of them changed no projection. */
|
|
2473
|
+
const PROJECTION_INPUTS = [
|
|
2474
|
+
'openFiles', 'activeFile', 'tabMeta', 'expandedFolders',
|
|
2475
|
+
'sidebarView', 'activeWorkspaceTab', 'workspacePanels', 'activeFolderPath',
|
|
2476
|
+
] as const;
|
|
2477
|
+
|
|
2478
|
+
/**
|
|
2479
|
+
* Feed ONE window's view into the persistence owner while it is mounted. Per
|
|
2480
|
+
* store update: O(1) — eight reference compares; only an update that touched a
|
|
2481
|
+
* projection input serializes the projection, and only a projection that
|
|
2482
|
+
* differs from the last one written reaches the owner (which serializes the
|
|
2483
|
+
* whole allowlisted record once). Returns the unsubscribe.
|
|
2484
|
+
*/
|
|
2485
|
+
function bindWindowPersistence(store: FilesystemStoreApi, id: FilesWindowIdentity): () => void {
|
|
2486
|
+
let last = JSON.stringify(persistedContext(store.getState() as unknown as Record<string, unknown>));
|
|
2487
|
+
return store.subscribe((state, prev) => {
|
|
2488
|
+
if (state.previewEditorsEnabled !== prev.previewEditorsEnabled || state.diffViewMode !== prev.diffViewMode) {
|
|
2489
|
+
const owner = filesPersistence.getState();
|
|
2490
|
+
if (owner.previewEditorsEnabled !== state.previewEditorsEnabled || owner.diffViewMode !== state.diffViewMode) {
|
|
2491
|
+
filesPersistence.setState({ previewEditorsEnabled: state.previewEditorsEnabled, diffViewMode: state.diffViewMode });
|
|
2492
|
+
}
|
|
2493
|
+
}
|
|
2494
|
+
if (!PROJECTION_INPUTS.some((key) => state[key] !== prev[key])) return;
|
|
2495
|
+
// The view belongs to the window's own identity; a store that left it writes nothing.
|
|
2496
|
+
if (state.activeUserId !== id.userId || state.activeProjectId !== id.projectId || state.activeAgentId !== id.agentId) return;
|
|
2497
|
+
const view = persistedContext(state as unknown as Record<string, unknown>);
|
|
2498
|
+
const json = JSON.stringify(view);
|
|
2499
|
+
if (json === last) return;
|
|
2500
|
+
last = json;
|
|
2501
|
+
filesPersistence.setState((owner) => ({
|
|
2502
|
+
windows: {
|
|
2503
|
+
...(owner.windows ?? {}),
|
|
2504
|
+
[id.windowId]: { userId: id.userId, projectId: id.projectId, agentId: id.agentId, ...view },
|
|
2505
|
+
},
|
|
2506
|
+
}));
|
|
2507
|
+
});
|
|
2508
|
+
}
|
|
2509
|
+
|
|
2510
|
+
type LiveFilesWindow = { identity: FilesWindowIdentity; store: FilesystemStoreApi };
|
|
2511
|
+
|
|
2512
|
+
/** Every mounted Files window — hidden and minimized ones included (they stay mounted). */
|
|
2513
|
+
const liveFilesWindows = new Set<LiveFilesWindow>();
|
|
2514
|
+
|
|
2515
|
+
/** The signed-in user, from the workspace provider — which user's records a broom prunes. */
|
|
2516
|
+
let liveFilesUserId: string | null = null;
|
|
2517
|
+
|
|
2518
|
+
export function setFilesLiveUser(userId: string | null): void {
|
|
2519
|
+
liveFilesUserId = userId;
|
|
2520
|
+
}
|
|
2521
|
+
|
|
2522
|
+
/**
|
|
2523
|
+
* Register a window store while it is mounted: the reset fan-out reaches it and
|
|
2524
|
+
* its view feeds the persistence owner. The disposer drops the subscription and
|
|
2525
|
+
* fences the store's in-flight readers — the persisted view stays (closing or
|
|
2526
|
+
* hiding a window is not a reset).
|
|
2527
|
+
*/
|
|
2528
|
+
export function mountFilesWindow(store: FilesystemStoreApi, identity: FilesWindowIdentity): () => void {
|
|
2529
|
+
const entry: LiveFilesWindow = { identity, store };
|
|
2530
|
+
liveFilesWindows.add(entry);
|
|
2531
|
+
const unbind = bindWindowPersistence(store, identity);
|
|
2532
|
+
return () => {
|
|
2533
|
+
unbind();
|
|
2534
|
+
liveFilesWindows.delete(entry);
|
|
2535
|
+
store.getState().fenceInFlightWriters();
|
|
2536
|
+
};
|
|
2537
|
+
}
|
|
2538
|
+
|
|
2539
|
+
function scopeReachesWindow(scope: ClientStateResetScope, id: FilesWindowIdentity): boolean {
|
|
2540
|
+
if (scope.projectId !== id.projectId) return false;
|
|
2541
|
+
if (scope.level === 'project') return true;
|
|
2542
|
+
if (scope.agentId !== id.agentId) return false;
|
|
2543
|
+
return scope.level === 'agent' || scope.instanceId === id.windowId;
|
|
2544
|
+
}
|
|
2545
|
+
|
|
2546
|
+
/**
|
|
2547
|
+
* The persisted half of a broom, applied to the ONE record: `instance` drops that
|
|
2548
|
+
* window's projection; `widget` drops it AND the legacy (user, project, agent)
|
|
2549
|
+
* context — the type-clean meaning that level always had; `agent` / `project`
|
|
2550
|
+
* drop the legacy contexts and the window projections of their scope. Only the
|
|
2551
|
+
* signed-in user's records; another user's, project's or agent's stay.
|
|
2552
|
+
*/
|
|
2553
|
+
function resetPersistedFiles(scope: ClientStateResetScope, userId: string | null): void {
|
|
2554
|
+
if ((scope.level === 'instance' || scope.level === 'widget') && scope.widgetType !== FILESYSTEM_WIDGET_TYPE) return;
|
|
2555
|
+
const record = filesPersistence.getState();
|
|
2556
|
+
const dropWindow = (windowId: string, entry: PersistedFilesWindow): boolean => {
|
|
2557
|
+
if (entry.projectId !== scope.projectId) return false;
|
|
2558
|
+
if (scope.level === 'instance' || scope.level === 'widget') return windowId === scope.instanceId && entry.agentId === scope.agentId;
|
|
2559
|
+
if (userId === null || entry.userId !== userId) return false;
|
|
2560
|
+
return scope.level === 'project' || entry.agentId === scope.agentId;
|
|
2561
|
+
};
|
|
2562
|
+
const dropContext = (key: string): boolean => {
|
|
2563
|
+
if (scope.level === 'instance') return false;
|
|
2564
|
+
if (scope.level === 'project') return key.startsWith(`${userId ?? ''}::${scope.projectId}::`);
|
|
2565
|
+
return key === contextKey(userId, scope.projectId, scope.agentId);
|
|
2566
|
+
};
|
|
2567
|
+
let windows = record.windows;
|
|
2568
|
+
for (const [windowId, entry] of Object.entries(record.windows ?? {})) {
|
|
2569
|
+
if (!dropWindow(windowId, entry)) continue;
|
|
2570
|
+
if (windows === record.windows) windows = { ...record.windows };
|
|
2571
|
+
delete windows![windowId];
|
|
2572
|
+
}
|
|
2573
|
+
let contexts = record.contexts;
|
|
2574
|
+
for (const key of Object.keys(record.contexts)) {
|
|
2575
|
+
if (!dropContext(key)) continue;
|
|
2576
|
+
if (contexts === record.contexts) contexts = { ...record.contexts };
|
|
2577
|
+
delete contexts[key];
|
|
2578
|
+
}
|
|
2579
|
+
if (windows === record.windows && contexts === record.contexts) return;
|
|
2580
|
+
filesPersistence.setState({ contexts, ...(windows ? { windows } : {}) });
|
|
2581
|
+
}
|
|
2582
|
+
|
|
2583
|
+
/**
|
|
2584
|
+
* The ONE handler behind the install-level `port.onClientStateReset`
|
|
2585
|
+
* subscription: the matching live window stores first (each wipe re-feeds its
|
|
2586
|
+
* own, now empty, view), then the persisted record — so an `instance` / `widget`
|
|
2587
|
+
* reset leaves that window's projection absent and a sibling's untouched.
|
|
2588
|
+
* O(mounted Files windows) per broom event.
|
|
2589
|
+
*/
|
|
2590
|
+
export function resetFilesClientState(scope: ClientStateResetScope): void {
|
|
2591
|
+
for (const live of liveFilesWindows) {
|
|
2592
|
+
if (scopeReachesWindow(scope, live.identity)) live.store.getState().resetClientState(scope);
|
|
2593
|
+
}
|
|
2594
|
+
resetPersistedFiles(scope, liveFilesUserId);
|
|
2595
|
+
}
|
|
2596
|
+
|
|
2597
|
+
/**
|
|
2598
|
+
* The store of the Files window being rendered: created once per LIVE identity
|
|
2599
|
+
* (window, user, project, bound agent) — a different user or project is a
|
|
2600
|
+
* different store, never a re-used one — and mounted for the window's lifetime.
|
|
2601
|
+
* `null` until the identity is known: nothing persisted is read, and nothing
|
|
2602
|
+
* paints, before the live user and project are.
|
|
2603
|
+
*/
|
|
2604
|
+
export function useFilesWindowStore(
|
|
2605
|
+
windowId: string | null | undefined,
|
|
2606
|
+
userId: string | null | undefined,
|
|
2607
|
+
projectId: string | null | undefined,
|
|
2608
|
+
agentId: string | null | undefined,
|
|
2609
|
+
): FilesystemStoreApi | null {
|
|
2610
|
+
const live = useMemo(() => {
|
|
2611
|
+
if (!windowId || !userId || !projectId || !agentId) return null;
|
|
2612
|
+
const identity: FilesWindowIdentity = { windowId, userId, projectId, agentId };
|
|
2613
|
+
return { identity, store: createFilesWindowStore(identity) };
|
|
2614
|
+
}, [windowId, userId, projectId, agentId]);
|
|
2615
|
+
// Layout phase: registered (and its persistence bound) before any child's
|
|
2616
|
+
// passive effect issues the first request; disposed before the next paint.
|
|
2617
|
+
useLayoutEffect(() => (live ? mountFilesWindow(live.store, live.identity) : undefined), [live]);
|
|
2618
|
+
return live?.store ?? null;
|
|
2619
|
+
}
|
|
2620
|
+
|
|
2621
|
+
const FilesystemStoreContext = createContext<FilesystemStoreApi | null>(null);
|
|
2622
|
+
|
|
2623
|
+
/** Puts a window's store under its Files tree; every `useFilesystemStore` below reads THIS window's store. */
|
|
2624
|
+
export function FilesystemStoreProvider({ store, children }: { store: FilesystemStoreApi; children?: ReactNode }) {
|
|
2625
|
+
return createElement(FilesystemStoreContext.Provider, { value: store }, children);
|
|
2626
|
+
}
|
|
2627
|
+
|
|
2628
|
+
/** The window's store itself, for imperative reads/writes outside a selector (event handlers, the SSE hook). */
|
|
2629
|
+
export function useFilesystemStoreApi(): FilesystemStoreApi {
|
|
2630
|
+
const store = useContext(FilesystemStoreContext);
|
|
2631
|
+
if (!store) throw new Error('useFilesystemStore must be used inside a Files window (FilesystemStoreProvider)');
|
|
2632
|
+
return store;
|
|
2633
|
+
}
|
|
2634
|
+
|
|
2635
|
+
/** Select from the store of the Files window this component renders in. */
|
|
2636
|
+
export function useFilesystemStore<T>(selector: (state: FilesystemStore) => T): T {
|
|
2637
|
+
return useStore(useFilesystemStoreApi(), selector);
|
|
2638
|
+
}
|