@nexus-ai-fs/tui 0.9.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. package/README.md +30 -0
  2. package/package.json +48 -0
  3. package/src/app.tsx +349 -0
  4. package/src/index.tsx +137 -0
  5. package/src/opentui-env.d.ts +61 -0
  6. package/src/panels/access/access-panel.tsx +597 -0
  7. package/src/panels/access/alert-list.tsx +77 -0
  8. package/src/panels/access/constraint-creator.tsx +128 -0
  9. package/src/panels/access/constraint-list.tsx +72 -0
  10. package/src/panels/access/credential-list.tsx +68 -0
  11. package/src/panels/access/delegation-chain-view.tsx +110 -0
  12. package/src/panels/access/delegation-completer.tsx +120 -0
  13. package/src/panels/access/delegation-creator.tsx +237 -0
  14. package/src/panels/access/delegation-list.tsx +74 -0
  15. package/src/panels/access/fraud-score-view.tsx +94 -0
  16. package/src/panels/access/manifest-creator.tsx +167 -0
  17. package/src/panels/access/manifest-list.tsx +105 -0
  18. package/src/panels/access/namespace-config-view.tsx +525 -0
  19. package/src/panels/access/permission-checker.tsx +231 -0
  20. package/src/panels/agents/agent-status-view.tsx +196 -0
  21. package/src/panels/agents/agents-panel.tsx +493 -0
  22. package/src/panels/agents/delegation-list.tsx +154 -0
  23. package/src/panels/agents/inbox-view.tsx +96 -0
  24. package/src/panels/agents/trajectories-tab.tsx +40 -0
  25. package/src/panels/api-console/api-console-panel.tsx +189 -0
  26. package/src/panels/api-console/codegen-viewer.tsx +36 -0
  27. package/src/panels/api-console/codegen.ts +112 -0
  28. package/src/panels/api-console/endpoint-list.tsx +57 -0
  29. package/src/panels/api-console/request-builder.tsx +69 -0
  30. package/src/panels/api-console/response-viewer.tsx +54 -0
  31. package/src/panels/connectors/available-tab.tsx +357 -0
  32. package/src/panels/connectors/connector-row.tsx +121 -0
  33. package/src/panels/connectors/connectors-panel.tsx +88 -0
  34. package/src/panels/connectors/error-parser.ts +116 -0
  35. package/src/panels/connectors/mounted-tab.tsx +179 -0
  36. package/src/panels/connectors/skills-tab.tsx +235 -0
  37. package/src/panels/connectors/template-generator.ts +211 -0
  38. package/src/panels/connectors/write-tab.tsx +514 -0
  39. package/src/panels/events/audit-tab.tsx +69 -0
  40. package/src/panels/events/audit-trail.tsx +75 -0
  41. package/src/panels/events/connector-detail.tsx +49 -0
  42. package/src/panels/events/connector-list.tsx +73 -0
  43. package/src/panels/events/connectors-tab.tsx +92 -0
  44. package/src/panels/events/event-replay.tsx +80 -0
  45. package/src/panels/events/events-panel.tsx +414 -0
  46. package/src/panels/events/events-tab.tsx +212 -0
  47. package/src/panels/events/lock-list.tsx +54 -0
  48. package/src/panels/events/locks-tab.tsx +103 -0
  49. package/src/panels/events/mcl-replay.tsx +77 -0
  50. package/src/panels/events/mcl-tab.tsx +83 -0
  51. package/src/panels/events/operations-tab-wrapper.tsx +62 -0
  52. package/src/panels/events/operations-tab.tsx +41 -0
  53. package/src/panels/events/replay-tab.tsx +76 -0
  54. package/src/panels/events/secrets-audit.tsx +64 -0
  55. package/src/panels/events/secrets-tab.tsx +75 -0
  56. package/src/panels/events/subscription-list.tsx +54 -0
  57. package/src/panels/events/subscriptions-tab.tsx +82 -0
  58. package/src/panels/files/file-aspects.tsx +93 -0
  59. package/src/panels/files/file-editor.tsx +160 -0
  60. package/src/panels/files/file-explorer-keybindings.ts +468 -0
  61. package/src/panels/files/file-explorer-panel.tsx +545 -0
  62. package/src/panels/files/file-lineage.tsx +163 -0
  63. package/src/panels/files/file-list-item.tsx +28 -0
  64. package/src/panels/files/file-metadata.tsx +62 -0
  65. package/src/panels/files/file-preview.tsx +108 -0
  66. package/src/panels/files/file-schema.tsx +89 -0
  67. package/src/panels/files/file-tree-node.tsx +44 -0
  68. package/src/panels/files/file-tree.tsx +169 -0
  69. package/src/panels/files/share-links-tab.tsx +33 -0
  70. package/src/panels/files/uploads-tab.tsx +45 -0
  71. package/src/panels/payments/approval-list.tsx +83 -0
  72. package/src/panels/payments/balance-card.tsx +43 -0
  73. package/src/panels/payments/budget-card.tsx +70 -0
  74. package/src/panels/payments/payments-panel.tsx +451 -0
  75. package/src/panels/payments/policy-list.tsx +64 -0
  76. package/src/panels/payments/reservation-list.tsx +78 -0
  77. package/src/panels/payments/transaction-list.tsx +103 -0
  78. package/src/panels/payments/transfer-form.tsx +109 -0
  79. package/src/panels/search/column-search.tsx +79 -0
  80. package/src/panels/search/knowledge-view.tsx +100 -0
  81. package/src/panels/search/memory-list.tsx +197 -0
  82. package/src/panels/search/playbook-list.tsx +77 -0
  83. package/src/panels/search/rlm-answer-view.tsx +105 -0
  84. package/src/panels/search/search-panel.tsx +405 -0
  85. package/src/panels/search/search-results.tsx +116 -0
  86. package/src/panels/stack/stack-panel.tsx +474 -0
  87. package/src/panels/versions/conflicts-tab.tsx +59 -0
  88. package/src/panels/versions/entry-detail.tsx +89 -0
  89. package/src/panels/versions/transaction-actions.tsx +34 -0
  90. package/src/panels/versions/transaction-list.tsx +90 -0
  91. package/src/panels/versions/versions-panel.tsx +276 -0
  92. package/src/panels/workflows/execution-list.tsx +102 -0
  93. package/src/panels/workflows/scheduler-view.tsx +135 -0
  94. package/src/panels/workflows/workflow-list.tsx +88 -0
  95. package/src/panels/workflows/workflows-panel.tsx +295 -0
  96. package/src/panels/zones/brick-detail.tsx +136 -0
  97. package/src/panels/zones/brick-list.tsx +56 -0
  98. package/src/panels/zones/cache-tab.tsx +118 -0
  99. package/src/panels/zones/drift-view.tsx +97 -0
  100. package/src/panels/zones/mcp-mounts-tab.tsx +38 -0
  101. package/src/panels/zones/memories-tab.tsx +37 -0
  102. package/src/panels/zones/reindex-status.tsx +84 -0
  103. package/src/panels/zones/workspaces-tab.tsx +37 -0
  104. package/src/panels/zones/zone-list.tsx +73 -0
  105. package/src/panels/zones/zones-panel.tsx +559 -0
  106. package/src/services/command-runner.ts +303 -0
  107. package/src/shared/accessibility-announcements.ts +44 -0
  108. package/src/shared/action-registry.ts +466 -0
  109. package/src/shared/brick-states.ts +91 -0
  110. package/src/shared/command-palette.ts +35 -0
  111. package/src/shared/components/announcement-bar.tsx +30 -0
  112. package/src/shared/components/app-confirm-dialog.tsx +29 -0
  113. package/src/shared/components/breadcrumb.tsx +21 -0
  114. package/src/shared/components/brick-gate.tsx +60 -0
  115. package/src/shared/components/command-output.tsx +95 -0
  116. package/src/shared/components/command-palette.tsx +97 -0
  117. package/src/shared/components/confirm-dialog.tsx +61 -0
  118. package/src/shared/components/diff-viewer.tsx +219 -0
  119. package/src/shared/components/empty-state.tsx +36 -0
  120. package/src/shared/components/error-bar.tsx +60 -0
  121. package/src/shared/components/error-boundary.tsx +53 -0
  122. package/src/shared/components/help-overlay.tsx +99 -0
  123. package/src/shared/components/identity-switcher.tsx +168 -0
  124. package/src/shared/components/loading-indicator.tsx +40 -0
  125. package/src/shared/components/pagination-bar.tsx +68 -0
  126. package/src/shared/components/pre-connection-screen.tsx +398 -0
  127. package/src/shared/components/scroll-indicator.tsx +46 -0
  128. package/src/shared/components/side-nav-utils.ts +68 -0
  129. package/src/shared/components/side-nav.tsx +287 -0
  130. package/src/shared/components/spinner.tsx +26 -0
  131. package/src/shared/components/status-bar.tsx +117 -0
  132. package/src/shared/components/styled-text.tsx +72 -0
  133. package/src/shared/components/sub-tab-bar-utils.ts +100 -0
  134. package/src/shared/components/sub-tab-bar.tsx +40 -0
  135. package/src/shared/components/tab-bar-utils.ts +36 -0
  136. package/src/shared/components/tab-bar.tsx +50 -0
  137. package/src/shared/components/text-input.tsx +73 -0
  138. package/src/shared/components/tooltip.tsx +53 -0
  139. package/src/shared/components/virtual-list.tsx +93 -0
  140. package/src/shared/components/welcome-screen.tsx +111 -0
  141. package/src/shared/hooks/use-api.ts +10 -0
  142. package/src/shared/hooks/use-brick-available.ts +42 -0
  143. package/src/shared/hooks/use-confirm.ts +66 -0
  144. package/src/shared/hooks/use-connection-state.ts +67 -0
  145. package/src/shared/hooks/use-copy.ts +31 -0
  146. package/src/shared/hooks/use-fresh-server.ts +62 -0
  147. package/src/shared/hooks/use-keyboard.ts +58 -0
  148. package/src/shared/hooks/use-list-navigation.ts +106 -0
  149. package/src/shared/hooks/use-swr.ts +117 -0
  150. package/src/shared/hooks/use-tab-fallback.ts +32 -0
  151. package/src/shared/hooks/use-text-input.ts +113 -0
  152. package/src/shared/hooks/use-visible-tabs.ts +61 -0
  153. package/src/shared/lib/circular-buffer.ts +82 -0
  154. package/src/shared/lib/clipboard.ts +14 -0
  155. package/src/shared/nav-items.ts +73 -0
  156. package/src/shared/navigation.ts +110 -0
  157. package/src/shared/status-breadcrumb.ts +74 -0
  158. package/src/shared/syntax-style.ts +3 -0
  159. package/src/shared/tab-visibility.ts +15 -0
  160. package/src/shared/text-style.ts +23 -0
  161. package/src/shared/theme.ts +179 -0
  162. package/src/shared/utils/format-size.ts +20 -0
  163. package/src/shared/utils/format-text.ts +10 -0
  164. package/src/shared/utils/format-time.ts +72 -0
  165. package/src/shared/utils/lru-cache.ts +75 -0
  166. package/src/stores/access-store-types.ts +154 -0
  167. package/src/stores/access-store.ts +674 -0
  168. package/src/stores/agents-store.ts +404 -0
  169. package/src/stores/announcement-store.ts +46 -0
  170. package/src/stores/api-console-store.ts +476 -0
  171. package/src/stores/connectors-store.ts +434 -0
  172. package/src/stores/create-api-action.ts +140 -0
  173. package/src/stores/delegation-store.ts +300 -0
  174. package/src/stores/error-store.ts +102 -0
  175. package/src/stores/events-store.ts +163 -0
  176. package/src/stores/files-store.ts +630 -0
  177. package/src/stores/first-run-store.ts +34 -0
  178. package/src/stores/global-store.ts +255 -0
  179. package/src/stores/infra-store.ts +461 -0
  180. package/src/stores/knowledge-store.ts +358 -0
  181. package/src/stores/lineage-store.ts +126 -0
  182. package/src/stores/mcp-store.ts +147 -0
  183. package/src/stores/payments-store.ts +545 -0
  184. package/src/stores/search-store-types.ts +155 -0
  185. package/src/stores/search-store.ts +656 -0
  186. package/src/stores/share-link-store.ts +151 -0
  187. package/src/stores/stack-store.ts +352 -0
  188. package/src/stores/ui-store.ts +161 -0
  189. package/src/stores/upload-store.ts +131 -0
  190. package/src/stores/versions-store.ts +355 -0
  191. package/src/stores/workflows-store.ts +402 -0
  192. package/src/stores/workspace-store.ts +185 -0
  193. package/src/stores/zones-store.ts +378 -0
@@ -0,0 +1,630 @@
1
+ /**
2
+ * File data store with SWR caching, tree state, and preview state.
3
+ *
4
+ * @see Issue #3102 — LRU cache (Decision 7A), AbortController (Decisions 3A/14A),
5
+ * sortFileItems helper (Decision 6A), cursor pagination + infinite scroll.
6
+ */
7
+
8
+ import { create } from "zustand";
9
+ import type { FetchClient } from "@nexus-ai-fs/api-client";
10
+ import { categorizeError } from "./create-api-action.js";
11
+ import { useErrorStore } from "./error-store.js";
12
+ import { useUiStore } from "./ui-store.js";
13
+ import { LruCache } from "../shared/utils/lru-cache.js";
14
+
15
+ // =============================================================================
16
+ // Types
17
+ // =============================================================================
18
+
19
+ export interface FileItem {
20
+ readonly name: string;
21
+ readonly path: string;
22
+ readonly isDirectory: boolean;
23
+ readonly size: number;
24
+ readonly modifiedAt: string | null;
25
+ readonly etag: string | null;
26
+ readonly mimeType: string | null;
27
+ readonly version: number | null;
28
+ readonly owner: string | null;
29
+ readonly permissions: string | null;
30
+ readonly zoneId: string | null;
31
+ }
32
+
33
+ export interface TreeNode {
34
+ readonly path: string;
35
+ readonly name: string;
36
+ readonly isDirectory: boolean;
37
+ readonly expanded: boolean;
38
+ readonly children: readonly string[];
39
+ readonly loading: boolean;
40
+ readonly depth: number;
41
+ readonly size: number;
42
+ /** Opaque cursor for fetching the next page of children. */
43
+ readonly nextCursor: string | null;
44
+ /** Whether more children are available beyond what's loaded. */
45
+ readonly hasMore: boolean;
46
+ /** Whether a "load more" fetch is currently in flight. */
47
+ readonly loadingMore: boolean;
48
+ }
49
+
50
+ /** Paginated list response from the server. */
51
+ interface PaginatedListResponse {
52
+ readonly items: readonly FileItem[];
53
+ readonly has_more: boolean;
54
+ readonly next_cursor: string | null;
55
+ }
56
+
57
+ // =============================================================================
58
+ // Helpers (Decision 6A: extract sort)
59
+ // =============================================================================
60
+
61
+ /** Sort file items: directories first, then alphabetical by name. */
62
+ function sortFileItems(items: readonly FileItem[]): FileItem[] {
63
+ return [...items].sort((a, b) => {
64
+ if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1;
65
+ return a.name.localeCompare(b.name);
66
+ });
67
+ }
68
+
69
+ // =============================================================================
70
+ // LRU cache for file listings (Decision 7A: reuse shared LruCache)
71
+ // =============================================================================
72
+
73
+ const CACHE_TTL_MS = 30_000;
74
+ const fileCache = new LruCache<readonly FileItem[]>(200);
75
+
76
+ // =============================================================================
77
+ // Per-path AbortController tracking (Decisions 3A + 14A)
78
+ // =============================================================================
79
+
80
+ const inFlightControllers = new Map<string, AbortController>();
81
+
82
+ function abortForPath(path: string): void {
83
+ inFlightControllers.get(path)?.abort();
84
+ inFlightControllers.delete(path);
85
+ }
86
+
87
+ function controllerForPath(path: string): AbortController {
88
+ abortForPath(path);
89
+ const controller = new AbortController();
90
+ inFlightControllers.set(path, controller);
91
+ return controller;
92
+ }
93
+
94
+ /** Abort all in-flight requests (called on panel unmount). */
95
+ function abortAllInFlight(): void {
96
+ for (const controller of inFlightControllers.values()) {
97
+ controller.abort();
98
+ }
99
+ inFlightControllers.clear();
100
+ }
101
+
102
+ // =============================================================================
103
+ // Store
104
+ // =============================================================================
105
+
106
+ export interface FilesState {
107
+ // File list cache (external LruCache — store only exposes current path data)
108
+ readonly currentPath: string;
109
+ readonly selectedIndex: number;
110
+
111
+ // Revision counter — bumped whenever fileCache changes, so selectors re-fire
112
+ readonly fileCacheRevision: number;
113
+
114
+ // Tree state
115
+ readonly treeNodes: ReadonlyMap<string, TreeNode>;
116
+ readonly focusPane: "tree" | "preview";
117
+
118
+ // Preview state
119
+ readonly previewPath: string | null;
120
+ readonly previewContent: string | null;
121
+ readonly previewLoading: boolean;
122
+
123
+ // Selection state
124
+ readonly selectedPaths: ReadonlySet<string>;
125
+ readonly visualModeAnchor: number | null;
126
+
127
+ // Clipboard state
128
+ readonly clipboard: { readonly paths: readonly string[]; readonly operation: "copy" | "cut" } | null;
129
+
130
+ // Paste progress
131
+ readonly pasteProgress: { readonly total: number; readonly completed: number; readonly failed: number } | null;
132
+
133
+ // Error
134
+ readonly error: string | null;
135
+
136
+ // Actions
137
+ readonly setCurrentPath: (path: string) => void;
138
+ readonly setSelectedIndex: (index: number) => void;
139
+ readonly setFocusPane: (pane: "tree" | "preview") => void;
140
+ readonly fetchFiles: (path: string, client: FetchClient) => Promise<void>;
141
+ readonly fetchPreview: (path: string, client: FetchClient) => Promise<void>;
142
+ readonly expandNode: (path: string, client: FetchClient) => Promise<void>;
143
+ readonly collapseNode: (path: string) => void;
144
+ readonly toggleNode: (path: string, client: FetchClient) => Promise<void>;
145
+ readonly loadMoreChildren: (path: string, client: FetchClient) => Promise<void>;
146
+ readonly invalidate: (path: string) => void;
147
+ readonly writeFile: (path: string, content: string, client: FetchClient) => Promise<void>;
148
+ readonly deleteFile: (path: string, client: FetchClient) => Promise<void>;
149
+ readonly mkdirFile: (path: string, client: FetchClient) => Promise<void>;
150
+ readonly renameFile: (oldPath: string, newPath: string, client: FetchClient) => Promise<void>;
151
+ readonly getCachedFiles: (path: string) => readonly FileItem[] | undefined;
152
+ readonly abortAllInFlight: () => void;
153
+ readonly clearCache: () => void;
154
+
155
+ // Selection actions
156
+ readonly toggleSelect: (path: string) => void;
157
+ readonly clearSelection: () => void;
158
+ readonly enterVisualMode: (anchorIndex: number) => void;
159
+ readonly exitVisualMode: () => void;
160
+
161
+ // Clipboard actions
162
+ readonly yankToClipboard: (paths: readonly string[]) => void;
163
+ readonly cutToClipboard: (paths: readonly string[]) => void;
164
+ readonly clearClipboard: () => void;
165
+
166
+ // Paste action (async with progress)
167
+ readonly pasteFiles: (destinationDir: string, client: FetchClient) => Promise<void>;
168
+ }
169
+
170
+ const SOURCE = "files";
171
+
172
+ // =============================================================================
173
+ // Derived helper (pure function)
174
+ // =============================================================================
175
+
176
+ /**
177
+ * Compute the effective selection: union of manually toggled selectedPaths
178
+ * and the visual-mode range (if active).
179
+ */
180
+ export function getEffectiveSelection(
181
+ selectedPaths: ReadonlySet<string>,
182
+ visualModeAnchor: number | null,
183
+ currentIndex: number,
184
+ visibleNodes: readonly string[],
185
+ ): Set<string> {
186
+ const result = new Set(selectedPaths);
187
+
188
+ if (visualModeAnchor !== null && visibleNodes.length > 0) {
189
+ const lo = Math.max(0, Math.min(visualModeAnchor, currentIndex));
190
+ const hi = Math.min(visibleNodes.length - 1, Math.max(visualModeAnchor, currentIndex));
191
+ for (let i = lo; i <= hi; i++) {
192
+ result.add(visibleNodes[i]!);
193
+ }
194
+ }
195
+
196
+ return result;
197
+ }
198
+
199
+ /** Expose fileCache for external access (e.g. getCachedFiles selector). */
200
+ export { fileCache as _fileCache };
201
+
202
+ export const useFilesStore = create<FilesState>((set, get) => ({
203
+ currentPath: "/",
204
+ selectedIndex: 0,
205
+ fileCacheRevision: 0,
206
+ treeNodes: new Map(),
207
+ focusPane: "tree",
208
+ previewPath: null,
209
+ previewContent: null,
210
+ previewLoading: false,
211
+ selectedPaths: new Set(),
212
+ visualModeAnchor: null,
213
+ clipboard: null,
214
+ pasteProgress: null,
215
+ error: null,
216
+
217
+ setCurrentPath: (path) => set({ currentPath: path, selectedIndex: 0 }),
218
+
219
+ setSelectedIndex: (index) => set({ selectedIndex: index }),
220
+
221
+ setFocusPane: (pane) => set({ focusPane: pane }),
222
+
223
+ getCachedFiles: (path) => {
224
+ const entry = fileCache.get(path);
225
+ if (!entry) return undefined;
226
+ if (Date.now() - entry.fetchedAt > CACHE_TTL_MS) return undefined;
227
+ return entry.data;
228
+ },
229
+
230
+ abortAllInFlight,
231
+
232
+ clearCache: () => {
233
+ fileCache.clear();
234
+ set({ fileCacheRevision: get().fileCacheRevision + 1, treeNodes: new Map() });
235
+ },
236
+
237
+ fetchFiles: async (path, client) => {
238
+ // Check SWR cache
239
+ const cached = fileCache.get(path);
240
+ if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
241
+ return;
242
+ }
243
+
244
+ const controller = controllerForPath(`list:${path}`);
245
+ try {
246
+ const response = await client.get<PaginatedListResponse>(
247
+ `/api/v2/files/list?path=${encodeURIComponent(path)}&limit=200`,
248
+ { signal: controller.signal },
249
+ );
250
+
251
+ const items = response.items ?? [];
252
+ const sorted = sortFileItems(items);
253
+ fileCache.set(path, { data: sorted, fetchedAt: Date.now() });
254
+ set({ fileCacheRevision: get().fileCacheRevision + 1, error: null });
255
+ useUiStore.getState().markDataUpdated("files");
256
+ } catch (err) {
257
+ if (err instanceof DOMException && err.name === "AbortError") return;
258
+ const message = err instanceof Error ? err.message : "Failed to fetch files";
259
+ set({ error: message });
260
+ useErrorStore.getState().pushError({ message, category: categorizeError(message), source: SOURCE });
261
+ } finally {
262
+ inFlightControllers.delete(`list:${path}`);
263
+ }
264
+ },
265
+
266
+ fetchPreview: async (path, client) => {
267
+ set({ previewPath: path, previewLoading: true, previewContent: null });
268
+
269
+ const controller = controllerForPath(`preview:${path}`);
270
+ try {
271
+ const response = await client.get<{ content: string }>(
272
+ `/api/v2/files/read?path=${encodeURIComponent(path)}`,
273
+ { signal: controller.signal },
274
+ );
275
+ set({ previewContent: response.content ?? "", previewLoading: false });
276
+ useUiStore.getState().markDataUpdated("files");
277
+ } catch (err) {
278
+ if (err instanceof DOMException && err.name === "AbortError") return;
279
+ const message = err instanceof Error ? err.message : "Failed to fetch preview";
280
+ set({
281
+ previewContent: null,
282
+ previewLoading: false,
283
+ error: message,
284
+ });
285
+ useErrorStore.getState().pushError({ message, category: categorizeError(message), source: SOURCE });
286
+ } finally {
287
+ inFlightControllers.delete(`preview:${path}`);
288
+ }
289
+ },
290
+
291
+ expandNode: async (path, client) => {
292
+ const nodes = get().treeNodes;
293
+ const existing = nodes.get(path);
294
+
295
+ if (existing?.expanded) return;
296
+
297
+ // Abort any in-flight expand for this path (Decision 14A)
298
+ const controller = controllerForPath(`expand:${path}`);
299
+
300
+ // Mark as loading
301
+ const loadingNodes = new Map(nodes);
302
+ loadingNodes.set(path, {
303
+ ...(existing ?? {
304
+ path, name: path.split("/").pop() ?? path, isDirectory: true,
305
+ children: [], depth: 0, size: 0, nextCursor: null, hasMore: false, loadingMore: false,
306
+ }),
307
+ expanded: true,
308
+ loading: true,
309
+ });
310
+ set({ treeNodes: loadingNodes });
311
+
312
+ try {
313
+ const response = await client.get<PaginatedListResponse>(
314
+ `/api/v2/files/list?path=${encodeURIComponent(path)}&limit=200`,
315
+ { signal: controller.signal },
316
+ );
317
+
318
+ const items = response.items ?? [];
319
+ const sorted = sortFileItems(items);
320
+
321
+ const parentDepth = existing?.depth ?? 0;
322
+ const updatedNodes = new Map(get().treeNodes);
323
+
324
+ // Update parent node with pagination state
325
+ updatedNodes.set(path, {
326
+ ...updatedNodes.get(path)!,
327
+ expanded: true,
328
+ loading: false,
329
+ children: sorted.map((item) => item.path),
330
+ nextCursor: response.next_cursor ?? null,
331
+ hasMore: response.has_more ?? false,
332
+ loadingMore: false,
333
+ });
334
+
335
+ // Add child nodes
336
+ for (const item of sorted) {
337
+ if (!updatedNodes.has(item.path)) {
338
+ updatedNodes.set(item.path, {
339
+ path: item.path,
340
+ name: item.name,
341
+ isDirectory: item.isDirectory,
342
+ expanded: false,
343
+ children: [],
344
+ loading: false,
345
+ depth: parentDepth + 1,
346
+ size: item.size,
347
+ nextCursor: null,
348
+ hasMore: false,
349
+ loadingMore: false,
350
+ });
351
+ }
352
+ }
353
+
354
+ // Also populate the file cache so metadata/aspects/schema panes
355
+ // can look up selectedItem via getCachedFiles().
356
+ fileCache.set(path, { data: sorted, fetchedAt: Date.now() });
357
+
358
+ set({ treeNodes: updatedNodes, fileCacheRevision: get().fileCacheRevision + 1, error: null });
359
+ useUiStore.getState().markDataUpdated("files");
360
+ } catch (err) {
361
+ if (err instanceof DOMException && err.name === "AbortError") return;
362
+ // Revert loading state
363
+ const revertNodes = new Map(get().treeNodes);
364
+ const node = revertNodes.get(path);
365
+ if (node) {
366
+ revertNodes.set(path, { ...node, loading: false, expanded: false });
367
+ }
368
+ const message = err instanceof Error ? err.message : "Failed to expand directory";
369
+ set({
370
+ treeNodes: revertNodes,
371
+ error: message,
372
+ });
373
+ useErrorStore.getState().pushError({ message, category: categorizeError(message), source: SOURCE });
374
+ } finally {
375
+ inFlightControllers.delete(`expand:${path}`);
376
+ }
377
+ },
378
+
379
+ loadMoreChildren: async (path, client) => {
380
+ const nodes = get().treeNodes;
381
+ const parentNode = nodes.get(path);
382
+
383
+ if (!parentNode || !parentNode.hasMore || !parentNode.nextCursor || parentNode.loadingMore) {
384
+ return;
385
+ }
386
+
387
+ const controller = controllerForPath(`more:${path}`);
388
+
389
+ // Mark as loading more
390
+ const loadingNodes = new Map(nodes);
391
+ loadingNodes.set(path, { ...parentNode, loadingMore: true });
392
+ set({ treeNodes: loadingNodes });
393
+
394
+ try {
395
+ const response = await client.get<PaginatedListResponse>(
396
+ `/api/v2/files/list?path=${encodeURIComponent(path)}&limit=200&cursor=${encodeURIComponent(parentNode.nextCursor)}`,
397
+ { signal: controller.signal },
398
+ );
399
+
400
+ const items = response.items ?? [];
401
+ const sorted = sortFileItems(items);
402
+
403
+ const updatedNodes = new Map(get().treeNodes);
404
+ const currentParent = updatedNodes.get(path);
405
+ if (!currentParent) return;
406
+
407
+ // Append new children to existing children
408
+ const newChildPaths = sorted.map((item) => item.path);
409
+ updatedNodes.set(path, {
410
+ ...currentParent,
411
+ children: [...currentParent.children, ...newChildPaths],
412
+ nextCursor: response.next_cursor ?? null,
413
+ hasMore: response.has_more ?? false,
414
+ loadingMore: false,
415
+ });
416
+
417
+ // Add new child nodes
418
+ for (const item of sorted) {
419
+ if (!updatedNodes.has(item.path)) {
420
+ updatedNodes.set(item.path, {
421
+ path: item.path,
422
+ name: item.name,
423
+ isDirectory: item.isDirectory,
424
+ expanded: false,
425
+ children: [],
426
+ loading: false,
427
+ depth: currentParent.depth + 1,
428
+ size: item.size,
429
+ nextCursor: null,
430
+ hasMore: false,
431
+ loadingMore: false,
432
+ });
433
+ }
434
+ }
435
+
436
+ set({ treeNodes: updatedNodes, error: null });
437
+ useUiStore.getState().markDataUpdated("files");
438
+ } catch (err) {
439
+ if (err instanceof DOMException && err.name === "AbortError") return;
440
+ // Revert loadingMore state
441
+ const revertNodes = new Map(get().treeNodes);
442
+ const node = revertNodes.get(path);
443
+ if (node) {
444
+ revertNodes.set(path, { ...node, loadingMore: false });
445
+ }
446
+ const message = err instanceof Error ? err.message : "Failed to load more files";
447
+ set({ treeNodes: revertNodes, error: message });
448
+ useErrorStore.getState().pushError({ message, category: categorizeError(message), source: SOURCE });
449
+ } finally {
450
+ inFlightControllers.delete(`more:${path}`);
451
+ }
452
+ },
453
+
454
+ collapseNode: (path) => {
455
+ // Abort any in-flight expand/loadMore for this path (Decision 14A)
456
+ abortForPath(`expand:${path}`);
457
+ abortForPath(`more:${path}`);
458
+
459
+ const nodes = new Map(get().treeNodes);
460
+ const node = nodes.get(path);
461
+ if (node) {
462
+ nodes.set(path, { ...node, expanded: false });
463
+ set({ treeNodes: nodes });
464
+ }
465
+ },
466
+
467
+ toggleNode: async (path, client) => {
468
+ const node = get().treeNodes.get(path);
469
+ if (node?.expanded) {
470
+ get().collapseNode(path);
471
+ } else {
472
+ await get().expandNode(path, client);
473
+ }
474
+ },
475
+
476
+ invalidate: (path) => {
477
+ fileCache.delete(path);
478
+ set({ fileCacheRevision: get().fileCacheRevision + 1 });
479
+ },
480
+
481
+ writeFile: async (path, content, client) => {
482
+ set({ error: null });
483
+ try {
484
+ await client.post("/api/v2/files/write", { path, content });
485
+ get().invalidate(path.split("/").slice(0, -1).join("/") || "/");
486
+ } catch (err) {
487
+ const message = err instanceof Error ? err.message : "Failed to write file";
488
+ set({ error: message });
489
+ useErrorStore.getState().pushError({ message, category: categorizeError(message), source: SOURCE });
490
+ }
491
+ },
492
+
493
+ deleteFile: async (path, client) => {
494
+ set({ error: null });
495
+ try {
496
+ // Pass active transaction ID if one exists
497
+ const { useVersionsStore } = await import("./versions-store.js");
498
+ const activeTxn = useVersionsStore.getState().selectedTransaction;
499
+ const txnParam = activeTxn?.status === "active" ? `&transaction_id=${activeTxn.transaction_id}` : "";
500
+ await client.delete(`/api/v2/files/delete?path=${encodeURIComponent(path)}${txnParam}`);
501
+ const parentPath = path.split("/").slice(0, -1).join("/") || "/";
502
+ get().invalidate(parentPath);
503
+ await get().fetchFiles(parentPath, client);
504
+ } catch (err) {
505
+ const message = err instanceof Error ? err.message : "Failed to delete file";
506
+ set({ error: message });
507
+ useErrorStore.getState().pushError({ message, category: categorizeError(message), source: SOURCE });
508
+ }
509
+ },
510
+
511
+ mkdirFile: async (path, client) => {
512
+ set({ error: null });
513
+ try {
514
+ await client.post("/api/v2/files/mkdir", { path });
515
+ const parentPath = path.split("/").slice(0, -1).join("/") || "/";
516
+ get().invalidate(parentPath);
517
+ await get().fetchFiles(parentPath, client);
518
+ } catch (err) {
519
+ const message = err instanceof Error ? err.message : "Failed to create directory";
520
+ set({ error: message });
521
+ useErrorStore.getState().pushError({ message, category: categorizeError(message), source: SOURCE });
522
+ }
523
+ },
524
+
525
+ renameFile: async (oldPath, newPath, client) => {
526
+ // Atomic rename via dedicated endpoint (Decision 8A) — O(1) metadata-only operation.
527
+ set({ error: null });
528
+ try {
529
+ await client.post("/api/v2/files/rename", { source: oldPath, destination: newPath });
530
+ const parentPath = oldPath.split("/").slice(0, -1).join("/") || "/";
531
+ get().invalidate(parentPath);
532
+ // Also invalidate destination parent if different
533
+ const destParent = newPath.split("/").slice(0, -1).join("/") || "/";
534
+ if (destParent !== parentPath) get().invalidate(destParent);
535
+ await get().fetchFiles(parentPath, client);
536
+ } catch (err) {
537
+ const message = err instanceof Error ? err.message : "Failed to rename file";
538
+ set({ error: message });
539
+ useErrorStore.getState().pushError({ message, category: categorizeError(message), source: SOURCE });
540
+ }
541
+ },
542
+
543
+ // Selection actions
544
+
545
+ toggleSelect: (path) => {
546
+ const next = new Set(get().selectedPaths);
547
+ if (next.has(path)) {
548
+ next.delete(path);
549
+ } else {
550
+ next.add(path);
551
+ }
552
+ set({ selectedPaths: next });
553
+ },
554
+
555
+ clearSelection: () => set({ selectedPaths: new Set(), visualModeAnchor: null }),
556
+
557
+ enterVisualMode: (anchorIndex) => set({ visualModeAnchor: anchorIndex }),
558
+
559
+ exitVisualMode: () => set({ visualModeAnchor: null }),
560
+
561
+ // Clipboard actions
562
+
563
+ yankToClipboard: (paths) => set({ clipboard: { paths: [...paths], operation: "copy" } }),
564
+
565
+ cutToClipboard: (paths) => set({ clipboard: { paths: [...paths], operation: "cut" } }),
566
+
567
+ clearClipboard: () => set({ clipboard: null }),
568
+
569
+ // Paste action with progress tracking
570
+
571
+ pasteFiles: async (destinationDir, client) => {
572
+ const { clipboard } = get();
573
+ if (!clipboard || clipboard.paths.length === 0) return;
574
+
575
+ const total = clipboard.paths.length;
576
+ set({ pasteProgress: { total, completed: 0, failed: 0 }, error: null });
577
+
578
+ const operation = clipboard.operation;
579
+ let completed = 0;
580
+ let failed = 0;
581
+
582
+ for (const srcPath of clipboard.paths) {
583
+ const fileName = srcPath.split("/").pop() ?? srcPath;
584
+ const destPath = destinationDir === "/" ? `/${fileName}` : `${destinationDir}/${fileName}`;
585
+ try {
586
+ if (operation === "copy") {
587
+ await client.post("/api/v2/files/copy", { source: srcPath, destination: destPath });
588
+ } else {
589
+ await client.post("/api/v2/files/rename", { source: srcPath, destination: destPath });
590
+ }
591
+ completed++;
592
+ } catch {
593
+ failed++;
594
+ }
595
+ set({ pasteProgress: { total, completed, failed } });
596
+ }
597
+
598
+ // Clear clipboard and progress, invalidate caches
599
+ set({ clipboard: null });
600
+ get().invalidate(destinationDir);
601
+
602
+ // Also invalidate source parents for cut operations
603
+ if (operation === "cut") {
604
+ const sourceParents = new Set(
605
+ clipboard.paths.map((p) => p.split("/").slice(0, -1).join("/") || "/"),
606
+ );
607
+ for (const parent of sourceParents) {
608
+ get().invalidate(parent);
609
+ }
610
+ }
611
+
612
+ await get().fetchFiles(destinationDir, client);
613
+
614
+ // Clear progress after a short delay so the user sees the completion state
615
+ const finalCompleted = completed;
616
+ const finalFailed = failed;
617
+ setTimeout(() => {
618
+ const p = get().pasteProgress;
619
+ if (p && p.completed === finalCompleted && p.failed === finalFailed) {
620
+ set({ pasteProgress: null });
621
+ }
622
+ }, 2000);
623
+
624
+ if (failed > 0) {
625
+ const message = `Paste: ${failed} of ${total} operations failed`;
626
+ set({ error: message });
627
+ useErrorStore.getState().pushError({ message, category: "server", source: SOURCE });
628
+ }
629
+ },
630
+ }));
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Zustand store tracking dismissed first-run tooltips.
3
+ *
4
+ * Persists in memory only (resets on restart) which is acceptable for
5
+ * lightweight "Tip: Press ? for help" style tooltips.
6
+ */
7
+
8
+ import { create } from "zustand";
9
+
10
+ export interface FirstRunState {
11
+ /** Set of tooltip keys that have been dismissed. */
12
+ readonly dismissed: ReadonlySet<string>;
13
+
14
+ /** Whether a tooltip for the given key should be shown. */
15
+ readonly shouldShow: (key: string) => boolean;
16
+
17
+ /** Mark a tooltip as dismissed. */
18
+ readonly dismiss: (key: string) => void;
19
+ }
20
+
21
+ export const useFirstRunStore = create<FirstRunState>((set, get) => ({
22
+ dismissed: new Set(),
23
+
24
+ shouldShow: (key) => !get().dismissed.has(key),
25
+
26
+ dismiss: (key) => {
27
+ set((state) => {
28
+ if (state.dismissed.has(key)) return state;
29
+ const next = new Set(state.dismissed);
30
+ next.add(key);
31
+ return { dismissed: next };
32
+ });
33
+ },
34
+ }));