@marimo-team/frontend 0.24.1-dev83 → 0.24.1-dev85

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 (105) hide show
  1. package/dist/assets/{CellStatus-GcHBZKLm.js → CellStatus-D40JXMpi.js} +1 -1
  2. package/dist/assets/{ConnectedDataExplorerComponent-BU1NZpRe.js → ConnectedDataExplorerComponent-Dkqk66Mv.js} +1 -1
  3. package/dist/assets/{JsonOutput-CUJlEdsN.js → JsonOutput-DeeAtdky.js} +1 -1
  4. package/dist/assets/{MarimoErrorOutput-DCAezqcJ.js → MarimoErrorOutput-Dh-877oV.js} +1 -1
  5. package/dist/assets/{RenderHTML-D9-75ltP.js → RenderHTML-C7JgAfHC.js} +1 -1
  6. package/dist/assets/{RunButton-plSsbubG.js → RunButton-C9d_w3qn.js} +1 -1
  7. package/dist/assets/{add-cell-with-ai-Cfby28hZ.js → add-cell-with-ai-zrX9HOPy.js} +1 -1
  8. package/dist/assets/{add-connection-dialog-By0i0Fq5.js → add-connection-dialog-OQxtKyCV.js} +1 -1
  9. package/dist/assets/{agent-panel-Cf482jaA.js → agent-panel-DF7_IEEH.js} +1 -1
  10. package/dist/assets/{ai-model-dropdown-DMfaGQ8o.js → ai-model-dropdown-DIItpu01.js} +1 -1
  11. package/dist/assets/{app-config-button-93unbEYx.js → app-config-button-CeDIYZUc.js} +1 -1
  12. package/dist/assets/{blob-yxfhyyvD.js → blob-iewSQmkZ.js} +1 -1
  13. package/dist/assets/{cell-editor-CZmqG6aB.js → cell-editor-BkHTcrF-.js} +1 -1
  14. package/dist/assets/{cell-link-hHrWcozB.js → cell-link-ZLZTXwR8.js} +1 -1
  15. package/dist/assets/{cells-yuYDzbAy.js → cells-DyE9dSiM.js} +1 -1
  16. package/dist/assets/{chat-display-CnhvADgL.js → chat-display-B9XOYJaA.js} +1 -1
  17. package/dist/assets/{chat-panel-CdE97AxM.js → chat-panel-Drx8ToF5.js} +1 -1
  18. package/dist/assets/{chat-ui-BD3rpULw.js → chat-ui-CJ1VFz1m.js} +1 -1
  19. package/dist/assets/{column-preview-BEmcYaKl.js → column-preview-Ls8UnJOt.js} +1 -1
  20. package/dist/assets/{command-palette-BGRRw0sn.js → command-palette-DWyHSatV.js} +1 -1
  21. package/dist/assets/{common-Bs2kaOs2.js → common-03vbDqrN.js} +1 -1
  22. package/dist/assets/{components-ib4asSo7.js → components-CBlGIIAX.js} +1 -1
  23. package/dist/assets/{components-ByyAzQxP.js → components-J_vBLfsO.js} +1 -1
  24. package/dist/assets/{config-DaNyNpZL.js → config-D4frVGQ4.js} +1 -1
  25. package/dist/assets/{config-Cl3eMWxb.js → config-ycxJQP2b.js} +1 -1
  26. package/dist/assets/{datasource-DeZYq5UY.js → datasource-B4VfwXg_.js} +1 -1
  27. package/dist/assets/{dependency-graph-panel-g10osAFc.js → dependency-graph-panel-B24NhlCS.js} +1 -1
  28. package/dist/assets/{documentation-panel-CZ1mKkDh.js → documentation-panel-C4y_ufGj.js} +1 -1
  29. package/dist/assets/{download-D1crMjD8.js → download-Kg8_rqQH.js} +1 -1
  30. package/dist/assets/{edit-page-Bk1sT7Wb.js → edit-page-DL_yo5rn.js} +4 -4
  31. package/dist/assets/{error-entries-B6XTLRyE.js → error-entries-Cy1y8FaI.js} +1 -1
  32. package/dist/assets/{error-panel-lexB3L6f.js → error-panel-D_pwhWfh.js} +1 -1
  33. package/dist/assets/file-explorer-panel-QoJu0RbE.js +1 -0
  34. package/dist/assets/{file-icons-Dx3v6lMu.js → file-icons-BN7fZ7-c.js} +1 -1
  35. package/dist/assets/file-name-input-qU-dl8Og.js +3 -0
  36. package/dist/assets/{floating-outline-3rQEbxxk.js → floating-outline-Coq72JrA.js} +1 -1
  37. package/dist/assets/{glide-data-editor-DZ2Minh8.js → glide-data-editor-DcY3nXI8.js} +1 -1
  38. package/dist/assets/{home-page-Dnz4tfZB.js → home-page-C6NwS9L9.js} +1 -1
  39. package/dist/assets/{index-BID9w87A.js → index-DMvKFHTV.js} +5 -5
  40. package/dist/assets/{kiosk-mode-CjT3sqy8.js → kiosk-mode-CzGt3mc2.js} +1 -1
  41. package/dist/assets/{layout-qHVpnnQT.js → layout-ByAlvlct.js} +3 -3
  42. package/dist/assets/{logs-panel-CSbUhhRo.js → logs-panel-zYelRd3A.js} +1 -1
  43. package/dist/assets/{markdown-renderer-BgUTGtM0.js → markdown-renderer-BqMslrdb.js} +1 -1
  44. package/dist/assets/{mermaid-Nzih1WTG.js → mermaid-CeseGGnS.js} +1 -1
  45. package/dist/assets/{name-cell-input-pEE4FGAL.js → name-cell-input-jrtswt5e.js} +1 -1
  46. package/dist/assets/{outline-panel-BgnjEQWg.js → outline-panel-B0P8XNnQ.js} +1 -1
  47. package/dist/assets/{packages-panel-BprfUOzZ.js → packages-panel-C08E4Wjm.js} +1 -1
  48. package/dist/assets/{pair-with-agent-modal-Gg8MZHWW.js → pair-with-agent-modal-BZBengJw.js} +1 -1
  49. package/dist/assets/{panel-accordion-state-vpqEc3Kd.js → panel-accordion-state-Cyy4afrx.js} +1 -1
  50. package/dist/assets/{panels-3h7Cf8xJ.js → panels-DRtPsJ1u.js} +1 -1
  51. package/dist/assets/{process-output-siRm_lza.js → process-output-BiA5MKpM.js} +1 -1
  52. package/dist/assets/{readonly-code-display-CLWV76FT.js → readonly-code-display-B45Jy4vH.js} +1 -1
  53. package/dist/assets/{readonly-python-code-Bor9b9pV.js → readonly-python-code-BQ5Ti6Hw.js} +1 -1
  54. package/dist/assets/{renderShortcut-CS4ccH3x.js → renderShortcut-DK_OucLT.js} +1 -1
  55. package/dist/assets/{reveal-component-DNPksvzT.js → reveal-component-Bna3jncc.js} +1 -1
  56. package/dist/assets/{run-page-C4aONLoP.js → run-page-CFC5C_It.js} +1 -1
  57. package/dist/assets/{scratchpad-panel-BQSoRQvQ.js → scratchpad-panel-m2LRBFI5.js} +1 -1
  58. package/dist/assets/{session-panel-DB5qEYqt.js → session-panel-BV8Whoy8.js} +1 -1
  59. package/dist/assets/{skeleton-BSxwEE0h.js → skeleton-CWFWp9eU.js} +1 -1
  60. package/dist/assets/{snippets-panel-B8Yi81tz.js → snippets-panel-DBzdOGwQ.js} +1 -1
  61. package/dist/assets/state-DPS9Q717.js +3 -0
  62. package/dist/assets/{state-O8w98G11.js → state-rP7TRCla.js} +1 -1
  63. package/dist/assets/{switch-Gl6cozkm.js → switch-CtH5Ct94.js} +1 -1
  64. package/dist/assets/{terminal-DOqqA9Ha.js → terminal-C73iz1fU.js} +1 -1
  65. package/dist/assets/{tracing-DuLkr7ts.js → tracing-BP1J14GT.js} +1 -1
  66. package/dist/assets/{tracing-panel-CeLKVG8M.js → tracing-panel-CQasWudc.js} +2 -2
  67. package/dist/assets/{tree-actions-Bh-Nnaub.js → tree-actions-CrB9REp3.js} +1 -1
  68. package/dist/assets/{useAddCell-_JRYhAFd.js → useAddCell-DHYPnl7W.js} +1 -1
  69. package/dist/assets/{useBoolean-Cu7Eado1.js → useBoolean-ZU5StAbr.js} +1 -1
  70. package/dist/assets/{useCellActionButton-Bi8Lnv3F.js → useCellActionButton-C7bEu9tJ.js} +1 -1
  71. package/dist/assets/{useDeleteCell-CBQiavzs.js → useDeleteCell-CIzxZKUw.js} +1 -1
  72. package/dist/assets/{useDependencyPanelTab-BOn3UJ07.js → useDependencyPanelTab-CLmRGbOD.js} +1 -1
  73. package/dist/assets/{useNotebookActions--pblpIWF.js → useNotebookActions-Q97EgZxR.js} +1 -1
  74. package/dist/assets/{useRunCells-BFQfLlUl.js → useRunCells-Csh5VYKu.js} +1 -1
  75. package/dist/assets/{useSplitCell-Ig9P6lAn.js → useSplitCell-BfT7UcKM.js} +1 -1
  76. package/dist/assets/{useTheme-D3zvxdF3.js → useTheme-CF-zG9YH.js} +1 -1
  77. package/dist/assets/{utils-CUZQNAWJ.js → utils-CWSZQEjr.js} +1 -1
  78. package/dist/assets/{vega-component-B0MZ_r-y.js → vega-component-Bel2PDO-.js} +1 -1
  79. package/dist/index.html +26 -26
  80. package/package.json +1 -1
  81. package/src/__mocks__/requests.ts +1 -0
  82. package/src/components/editor/chrome/panels/file-explorer-panel.tsx +3 -2
  83. package/src/components/editor/file-tree/__tests__/file-explorer-interactions.test.tsx +31 -18
  84. package/src/components/editor/file-tree/__tests__/file-explorer.test.ts +18 -12
  85. package/src/components/editor/file-tree/__tests__/requesting-tree.test.ts +364 -387
  86. package/src/components/editor/file-tree/file-explorer.tsx +137 -97
  87. package/src/components/editor/file-tree/file-name-input.tsx +5 -1
  88. package/src/components/editor/file-tree/file-viewer.tsx +2 -2
  89. package/src/components/editor/file-tree/requesting-tree.tsx +291 -171
  90. package/src/components/editor/file-tree/state.tsx +1 -0
  91. package/src/core/config/__tests__/config-schema.test.ts +15 -0
  92. package/src/core/config/config-schema.ts +15 -0
  93. package/src/core/islands/bridge.ts +1 -0
  94. package/src/core/network/__tests__/requests-network.test.ts +7 -0
  95. package/src/core/network/__tests__/requests-toasting.test.tsx +10 -0
  96. package/src/core/network/requests-lazy.ts +1 -0
  97. package/src/core/network/requests-network.ts +4 -0
  98. package/src/core/network/requests-static.ts +1 -0
  99. package/src/core/network/requests-toasting.tsx +1 -0
  100. package/src/core/network/types.ts +3 -0
  101. package/src/core/wasm/bridge.ts +9 -0
  102. package/src/core/wasm/worker/types.ts +2 -0
  103. package/dist/assets/file-explorer-panel-DHRH2J-3.js +0 -1
  104. package/dist/assets/file-name-input-DzL8D_7x.js +0 -3
  105. package/dist/assets/state-D1N9_41P.js +0 -3
@@ -5,15 +5,24 @@ import { toast } from "@/components/ui/use-toast";
5
5
  import type {
6
6
  EditRequests,
7
7
  FileInfo,
8
+ FileRoot,
8
9
  FileUpdateResponse,
9
10
  } from "@/core/network/types";
10
11
  import { prettyError } from "@/utils/errors";
11
12
  import { Functions } from "@/utils/functions";
12
13
  import { type FilePath, PathBuilder } from "@/utils/paths";
13
- import { resolvePaths } from "@/utils/pathUtils";
14
14
  import { mapWithConcurrency } from "@/utils/semaphore";
15
15
 
16
16
  const FILE_OP_CONCURRENCY = 5;
17
+ const WINDOWS_DRIVE_PATH = /^[A-Za-z]:[/\\]/;
18
+ const WINDOWS_UNC_PATH = /^\\\\/;
19
+
20
+ export type FileTreeNode = Omit<FileInfo, "children"> & {
21
+ children: FileTreeNode[];
22
+ isRoot: boolean;
23
+ rootPath: string;
24
+ isPrimaryRoot: boolean;
25
+ };
17
26
 
18
27
  /**
19
28
  * Normalized result of a file mutation: the server response when successful,
@@ -35,8 +44,9 @@ export function handleFileResponse(
35
44
  }
36
45
 
37
46
  export class RequestingTree {
38
- private delegate = new SimpleTree<FileInfo>([]);
39
- private callbacks: {
47
+ private delegate = new SimpleTree<FileTreeNode>([]);
48
+ private readonly callbacks: {
49
+ getRoots: EditRequests["getFileRoots"];
40
50
  listFiles: EditRequests["sendListFiles"];
41
51
  createFileOrFolder: EditRequests["sendCreateFileOrFolder"];
42
52
  deleteFileOrFolder: EditRequests["sendDeleteFileOrFolder"];
@@ -45,6 +55,7 @@ export class RequestingTree {
45
55
  };
46
56
 
47
57
  constructor(callbacks: {
58
+ getRoots: EditRequests["getFileRoots"];
48
59
  listFiles: EditRequests["sendListFiles"];
49
60
  createFileOrFolder: EditRequests["sendCreateFileOrFolder"];
50
61
  deleteFileOrFolder: EditRequests["sendDeleteFileOrFolder"];
@@ -54,18 +65,35 @@ export class RequestingTree {
54
65
  this.callbacks = callbacks;
55
66
  }
56
67
 
57
- private rootPath: FilePath = "" as FilePath;
58
- private onChange: (data: FileInfo[]) => void = Functions.NOOP;
68
+ private roots: FileRoot[] = [];
69
+ private onChange: (data: FileTreeNode[]) => void = Functions.NOOP;
59
70
  private path = new PathBuilder("/");
60
71
 
61
- initialize = async (onChange: (data: FileInfo[]) => void): Promise<void> => {
72
+ initialize = async (
73
+ onChange: (data: FileTreeNode[]) => void,
74
+ ): Promise<void> => {
62
75
  this.onChange = onChange;
63
76
  if (this.delegate.data.length === 0) {
64
77
  try {
65
- const data = await this.callbacks.listFiles({ path: this.rootPath });
66
- this.delegate = new SimpleTree(data.files);
67
- this.rootPath = data.root as FilePath;
68
- this.path = PathBuilder.guessDeliminator(data.root);
78
+ const { roots } = await this.callbacks.getRoots();
79
+ const primaryRoot = roots.find((root) => root.isPrimary);
80
+ if (!primaryRoot) {
81
+ throw new Error("File browser response is missing a primary root");
82
+ }
83
+ this.roots = roots;
84
+ this.path = PathBuilder.guessDeliminator(primaryRoot.path);
85
+ this.delegate = new SimpleTree(roots.map(toRootNode));
86
+ if (roots.length === 1) {
87
+ const data = await this.callbacks.listFiles({
88
+ path: primaryRoot.path,
89
+ });
90
+ this.delegate.update({
91
+ id: this.getPrimaryRootId(),
92
+ changes: {
93
+ children: annotateFiles(data.files, primaryRoot),
94
+ },
95
+ });
96
+ }
69
97
  } catch (error) {
70
98
  toast({
71
99
  title: "Failed",
@@ -74,119 +102,91 @@ export class RequestingTree {
74
102
  }
75
103
  }
76
104
 
77
- this.onChange(this.delegate.data);
105
+ this.emitChange();
78
106
  };
79
107
 
80
108
  async expand(id: string): Promise<boolean> {
81
109
  const node = this.delegate.find(id);
82
- if (!node) {
83
- return false;
84
- }
85
- if (!node.data.isDirectory) {
110
+ if (!node?.data.isDirectory) {
86
111
  return false;
87
112
  }
88
113
 
89
- // We may attempt to load empty directories multiple times
90
- // but that is fine
91
114
  if (node.children && node.children.length > 0) {
92
- // Already loaded
93
115
  return true;
94
116
  }
95
117
 
96
118
  const data = await this.callbacks.listFiles({ path: node.data.path });
97
- this.delegate.update({ id, changes: { children: data.files } });
98
- this.onChange(this.delegate.data);
119
+ this.delegate.update({
120
+ id,
121
+ changes: {
122
+ children: annotateFiles(data.files, this.getRootForNode(node.data)),
123
+ },
124
+ });
125
+ this.emitChange();
99
126
  return true;
100
127
  }
101
128
 
102
129
  async copy(id: string, newName: string): Promise<void> {
103
- const node = this.delegate.find(id);
130
+ const node = this.getMutableNode(id);
104
131
  if (!node) {
105
- toast({
106
- title: "Failed",
107
- description: `Node with id ${id} not found in the tree`,
108
- });
109
132
  return;
110
133
  }
111
- const { path, newPath } = resolvePaths({
112
- path: node.data.path,
113
- name: newName,
114
- root: this.rootPath,
115
- });
116
- const parentPath = this.path.dirname(path);
117
- const newFile = await this.callbacks
134
+ const path = node.data.path as FilePath;
135
+ const parentPath = this.getParentPath(node.data);
136
+ const newPath = joinPath(parentPath, newName);
137
+ const result = await this.callbacks
118
138
  .copyFileOrFolder({ path, newPath })
119
139
  .then(handleFileResponse);
120
- if (!newFile?.info) {
121
- return;
140
+ if (result) {
141
+ await this.refreshPath(parentPath);
122
142
  }
123
- this.delegate.create({
124
- parentId: node.parent?.id ?? null,
125
- index: 0,
126
- data: newFile.info,
127
- });
128
- this.onChange(this.delegate.data);
129
- // Refresh the parent folder
130
- await this.refreshAll([parentPath]);
131
143
  }
132
144
 
133
145
  async rename(id: string, name: string): Promise<void> {
134
- const node = this.delegate.find(id);
146
+ const node = this.getMutableNode(id);
135
147
  if (!node) {
136
- toast({
137
- title: "Failed",
138
- description: `Node with id ${id} not found in the tree`,
139
- });
140
148
  return;
141
149
  }
142
- const { path, newPath } = resolvePaths({
143
- path: node.data.path,
144
- name,
145
- root: this.rootPath,
146
- });
150
+ const path = node.data.path as FilePath;
151
+ const parentPath = this.getParentPath(node.data);
152
+ const newPath = joinPath(parentPath, name);
147
153
  const result = await this.callbacks
148
154
  .renameFileOrFolder({ path, newPath })
149
155
  .then(handleFileResponse);
150
- if (!result) {
151
- return;
156
+ if (result) {
157
+ await this.refreshPath(parentPath);
152
158
  }
153
-
154
- this.delegate.update({ id, changes: { name, path: newPath } });
155
- this.onChange(this.delegate.data);
156
- // Rename all of its children
157
- await this.refreshAll([newPath]);
158
159
  }
159
160
 
160
161
  async move(fromIds: string[], parentId: string | null): Promise<void> {
161
- const parentPath = parentId
162
- ? (this.delegate.find(parentId)?.data.path ?? parentId)
163
- : this.rootPath;
162
+ const targetParentId = parentId ?? this.getPrimaryRootId();
163
+ const parent = this.delegate.find(targetParentId);
164
+ if (!parent?.data.isDirectory) {
165
+ return;
166
+ }
164
167
 
168
+ const refreshPaths = new Set<string>();
165
169
  await mapWithConcurrency(fromIds, FILE_OP_CONCURRENCY, async (id) => {
166
- const node = this.delegate.find(id);
170
+ const node = this.getMutableNode(id, false);
167
171
  if (!node) {
168
172
  return;
169
173
  }
170
174
  const originalPath = node.data.path;
171
- const newPath = this.path.join(
172
- parentPath,
175
+ const sourceParentPath = this.getParentPath(node.data);
176
+ const newPath = joinPath(
177
+ parent.data.path,
173
178
  this.path.basename(originalPath as FilePath),
174
179
  );
175
180
  const result = await this.callbacks
176
181
  .renameFileOrFolder({ path: originalPath, newPath })
177
182
  .then(handleFileResponse);
178
- if (!result) {
179
- return;
183
+ if (result) {
184
+ refreshPaths.add(parent.data.path);
185
+ refreshPaths.add(sourceParentPath);
180
186
  }
181
-
182
- this.delegate.move({ id, parentId, index: 0 });
183
- this.delegate.update({ id, changes: { path: newPath } });
184
187
  });
185
188
 
186
- this.onChange(this.delegate.data);
187
-
188
- // Refresh the parent folder
189
- await this.refreshAll([parentPath]);
189
+ await this.refreshPaths([...refreshPaths]);
190
190
  }
191
191
 
192
192
  async createFile({
@@ -198,140 +198,260 @@ export class RequestingTree {
198
198
  parentId: string | null;
199
199
  type?: "file" | "notebook";
200
200
  }): Promise<void> {
201
- const parentPath = parentId
202
- ? (this.delegate.find(parentId)?.data.path ?? parentId)
203
- : this.rootPath;
204
- const newFile = await this.callbacks
205
- .createFileOrFolder({ path: parentPath, type: type, name: name })
206
- .then(handleFileResponse);
207
- if (!newFile?.info) {
201
+ const parent = this.getParentNode(parentId);
202
+ if (!parent) {
208
203
  return;
209
204
  }
210
- this.delegate.create({
211
- parentId,
212
- index: 0,
213
- data: newFile.info,
214
- });
215
- this.onChange(this.delegate.data);
216
- // Refresh the parent folder
217
- await this.refreshAll([parentPath]);
205
+ const result = await this.callbacks
206
+ .createFileOrFolder({
207
+ path: parent.data.path,
208
+ type,
209
+ name,
210
+ })
211
+ .then(handleFileResponse);
212
+ if (result) {
213
+ await this.refreshPath(parent.data.path as FilePath);
214
+ }
218
215
  }
219
216
 
220
217
  async createFolder(name: string, parentId: string | null): Promise<void> {
221
- const parentPath = parentId
222
- ? (this.delegate.find(parentId)?.data.path ?? parentId)
223
- : this.rootPath;
224
- const newFolder = await this.callbacks
225
- .createFileOrFolder({ path: parentPath, type: "directory", name: name })
226
- .then(handleFileResponse);
227
- if (!newFolder?.info) {
218
+ const parent = this.getParentNode(parentId);
219
+ if (!parent) {
228
220
  return;
229
221
  }
230
- this.delegate.create({
231
- parentId,
232
- index: 0,
233
- data: newFolder.info,
234
- });
235
- this.onChange(this.delegate.data);
236
- // Refresh the parent folder
237
- await this.refreshAll([parentPath]);
222
+ const result = await this.callbacks
223
+ .createFileOrFolder({
224
+ path: parent.data.path,
225
+ type: "directory",
226
+ name,
227
+ })
228
+ .then(handleFileResponse);
229
+ if (result) {
230
+ await this.refreshPath(parent.data.path as FilePath);
231
+ }
238
232
  }
239
233
 
240
234
  async delete(id: string): Promise<void> {
241
- const node = this.delegate.find(id);
235
+ const node = this.getMutableNode(id);
242
236
  if (!node) {
243
- toast({
244
- title: "Failed",
245
- description: `Node with id ${id} not found in the tree`,
246
- });
247
237
  return;
248
238
  }
249
-
239
+ const parentPath = this.getParentPath(node.data);
250
240
  const result = await this.callbacks
251
241
  .deleteFileOrFolder({ path: node.data.path })
252
242
  .then(handleFileResponse);
253
- if (!result) {
254
- return;
243
+ if (result) {
244
+ await this.refreshPath(parentPath);
255
245
  }
256
- this.delegate.drop({ id });
257
- this.onChange(this.delegate.data);
258
246
  }
259
247
 
260
248
  refreshAll = async (ids: string[]): Promise<void> => {
261
- // For each open folder, refresh
262
- const openFolders = [
263
- this.rootPath,
264
- ...ids.map((id) => this.delegate.find(id)?.data.path),
265
- ].filter(Boolean);
266
- // Request open folders with bounded concurrency; swallow per-folder errors.
267
- const data = await mapWithConcurrency(
268
- openFolders,
269
- FILE_OP_CONCURRENCY,
270
- (path) =>
271
- this.callbacks.listFiles({ path: path }).catch(() => ({ files: [] })),
249
+ const paths = [
250
+ ...this.roots.map((root) => root.path),
251
+ ...ids
252
+ .map((id) => this.delegate.find(id)?.data.path)
253
+ .filter((path): path is string => Boolean(path)),
254
+ ];
255
+ await this.refreshPaths(paths);
256
+ };
257
+
258
+ refreshPath = async (path: FilePath): Promise<void> => {
259
+ await this.refreshPaths([path]);
260
+ };
261
+
262
+ public getPrimaryRelativePath = (path: FilePath): FilePath | null => {
263
+ const primaryRoot = this.getPrimaryRoot();
264
+ if (!primaryRoot) {
265
+ return null;
266
+ }
267
+ return relativePath(path, primaryRoot.path);
268
+ };
269
+
270
+ public getPrimaryRootPath = (): FilePath => {
271
+ return (this.getPrimaryRoot()?.path ?? "") as FilePath;
272
+ };
273
+
274
+ public getPrimaryRootId = (): string => {
275
+ const path = this.getPrimaryRoot()?.path ?? "";
276
+ return fileTreeNodeId(path, path);
277
+ };
278
+
279
+ public isPrimaryNode = (node: FileTreeNode): boolean => {
280
+ return node.isPrimaryRoot;
281
+ };
282
+
283
+ public getDisplayPath = (path: FilePath): string => {
284
+ const root = this.getRootForPath(path);
285
+ if (!root) {
286
+ return path;
287
+ }
288
+ const relative = relativePath(path, root.path);
289
+ if (!relative) {
290
+ return root.name;
291
+ }
292
+ return root.isPrimary ? relative : `${root.name}/${relative}`;
293
+ };
294
+
295
+ public isRootPath = (path: FilePath): boolean => {
296
+ return this.roots.some((root) => pathsEqual(path, root.path));
297
+ };
298
+
299
+ private getPrimaryRoot(): FileRoot | undefined {
300
+ return this.roots.find((root) => root.isPrimary);
301
+ }
302
+
303
+ private getRootForNode(node: FileTreeNode): FileRoot {
304
+ return (
305
+ this.roots.find((root) => pathsEqual(root.path, node.rootPath)) ??
306
+ this.getPrimaryRoot() ?? {
307
+ path: node.rootPath,
308
+ name: node.rootPath,
309
+ isPrimary: node.isPrimaryRoot,
310
+ }
272
311
  );
312
+ }
273
313
 
274
- for (const [idx, openFolder] of openFolders.entries()) {
275
- const datum = data[idx];
276
- if (openFolder === this.rootPath) {
277
- this.delegate = new SimpleTree(datum.files);
278
- } else {
279
- this.delegate.update({
280
- id: openFolder,
281
- changes: { children: datum.files },
314
+ private getRootForPath(path: FilePath): FileRoot | undefined {
315
+ return this.roots
316
+ .filter((root) => relativePath(path, root.path) !== null)
317
+ .toSorted((left, right) => right.path.length - left.path.length)[0];
318
+ }
319
+
320
+ private getParentNode(parentId: string | null) {
321
+ const resolvedId = parentId ?? this.getPrimaryRootId();
322
+ const node = this.delegate.find(resolvedId);
323
+ return node?.data.isDirectory ? node : null;
324
+ }
325
+
326
+ private getParentPath(node: FileTreeNode): FilePath {
327
+ const parent = this.delegate.find(node.id)?.parent;
328
+ return (parent?.data.path ?? node.rootPath) as FilePath;
329
+ }
330
+
331
+ private getMutableNode(id: string, showError = true) {
332
+ const node = this.delegate.find(id);
333
+ if (!node || node.data.isRoot) {
334
+ if (showError) {
335
+ toast({
336
+ title: "Failed",
337
+ description: node
338
+ ? "File browser roots cannot be modified"
339
+ : `Node with id ${id} not found in the tree`,
282
340
  });
283
341
  }
342
+ return null;
284
343
  }
344
+ return node;
345
+ }
285
346
 
286
- this.onChange(this.delegate.data);
287
- };
288
-
289
- refreshPath = async (path: FilePath): Promise<void> => {
290
- const data = await this.callbacks.listFiles({ path }).catch(() => null);
291
- if (!data) {
347
+ private refreshPaths = async (paths: string[]): Promise<void> => {
348
+ const uniquePaths = [...new Set(paths)];
349
+ if (uniquePaths.length === 0) {
292
350
  return;
293
351
  }
352
+ const results = await mapWithConcurrency(
353
+ uniquePaths,
354
+ FILE_OP_CONCURRENCY,
355
+ (path) => this.callbacks.listFiles({ path }).catch(() => null),
356
+ );
294
357
 
295
- if (path === this.rootPath) {
296
- this.delegate = new SimpleTree(data.files);
297
- } else {
298
- const item = findFileByPath(this.delegate.data, path);
299
- if (!item?.isDirectory) {
300
- return;
358
+ for (const [index, path] of uniquePaths.entries()) {
359
+ const result = results[index];
360
+ if (!result) {
361
+ continue;
362
+ }
363
+ // The same absolute path may appear below multiple overlapping roots.
364
+ // Refresh every occurrence, while keeping each occurrence in its own ID
365
+ // namespace and preserving its root metadata.
366
+ for (const root of this.roots) {
367
+ const node = this.delegate.find(fileTreeNodeId(root.path, path));
368
+ if (!node?.data.isDirectory) {
369
+ continue;
370
+ }
371
+ this.delegate.update({
372
+ id: node.id,
373
+ changes: {
374
+ children: annotateFiles(result.files, root),
375
+ },
376
+ });
301
377
  }
302
- this.delegate.update({ id: item.id, changes: { children: data.files } });
303
378
  }
304
- this.onChange(this.delegate.data);
379
+ this.emitChange();
305
380
  };
306
381
 
307
- public relativeFromRoot = (path: FilePath): FilePath => {
308
- // Add a trailing delimiter to the root path if it doesn't have one
309
- const root = this.rootPath.endsWith(this.path.deliminator)
310
- ? this.rootPath
311
- : `${this.rootPath}${this.path.deliminator}`;
382
+ private emitChange(): void {
383
+ const data = this.delegate.data;
384
+ this.onChange(this.roots.length === 1 ? (data[0]?.children ?? []) : data);
385
+ }
386
+ }
312
387
 
313
- if (path.startsWith(root)) {
314
- return path.slice(root.length) as FilePath;
315
- }
316
- return path;
388
+ function toRootNode(root: FileRoot): FileTreeNode {
389
+ return {
390
+ id: fileTreeNodeId(root.path, root.path),
391
+ path: root.path,
392
+ name: root.name,
393
+ isDirectory: true,
394
+ isMarimoFile: false,
395
+ children: [],
396
+ isRoot: true,
397
+ rootPath: root.path,
398
+ isPrimaryRoot: root.isPrimary,
317
399
  };
400
+ }
318
401
 
319
- public getRootPath = (): FilePath => {
320
- return this.rootPath;
321
- };
402
+ function annotateFiles(files: FileInfo[], root: FileRoot): FileTreeNode[] {
403
+ return files.map((file) => ({
404
+ ...file,
405
+ id: fileTreeNodeId(root.path, file.path),
406
+ children: annotateFiles(file.children ?? [], root),
407
+ isRoot: false,
408
+ rootPath: root.path,
409
+ isPrimaryRoot: root.isPrimary,
410
+ }));
322
411
  }
323
412
 
324
- function findFileByPath(list: FileInfo[], path: string): FileInfo | undefined {
325
- for (const item of list) {
326
- if (item.path === path) {
327
- return item;
328
- }
329
- const child = item.children
330
- ? findFileByPath(item.children, path)
331
- : undefined;
332
- if (child) {
333
- return child;
334
- }
413
+ /** A stable, globally unique tree ID for a path as viewed from one root. */
414
+ export function fileTreeNodeId(rootPath: string, path: string): string {
415
+ return `${encodeURIComponent(rootPath)}:${encodeURIComponent(path)}`;
416
+ }
417
+
418
+ function relativePath(path: string, root: string): FilePath | null {
419
+ const windowsPath = isWindowsPath(root);
420
+ const normalizeCase = (value: string) =>
421
+ windowsPath ? value.toLowerCase() : value;
422
+ const comparedPath = normalizeCase(path);
423
+ const comparedRoot = normalizeCase(root);
424
+ if (comparedPath === comparedRoot) {
425
+ return "" as FilePath;
426
+ }
427
+
428
+ const delimiter = pathDelimiter(root);
429
+ const rootWithDelimiter = root.endsWith(delimiter)
430
+ ? root
431
+ : `${root}${delimiter}`;
432
+ const comparedPrefix = normalizeCase(rootWithDelimiter);
433
+ if (!comparedPath.startsWith(comparedPrefix)) {
434
+ return null;
435
+ }
436
+ return path.slice(rootWithDelimiter.length) as FilePath;
437
+ }
438
+
439
+ function pathsEqual(left: string, right: string): boolean {
440
+ return relativePath(left, right) === ("" as FilePath);
441
+ }
442
+
443
+ function joinPath(parent: string, name: string): FilePath {
444
+ const delimiter = pathDelimiter(parent);
445
+ return `${parent}${parent.endsWith(delimiter) ? "" : delimiter}${name}` as FilePath;
446
+ }
447
+
448
+ function isWindowsPath(path: string): boolean {
449
+ return WINDOWS_DRIVE_PATH.test(path) || WINDOWS_UNC_PATH.test(path);
450
+ }
451
+
452
+ function pathDelimiter(path: string): "/" | "\\" {
453
+ if (WINDOWS_UNC_PATH.test(path) || /^[A-Za-z]:\\/.test(path)) {
454
+ return "\\";
335
455
  }
336
- return undefined;
456
+ return "/";
337
457
  }
@@ -11,6 +11,7 @@ export const treeAtom = atom<RequestingTree>((get) => {
11
11
  const client = get(requestClientAtom);
12
12
  invariant(client, "no requestClientAtom set");
13
13
  return new RequestingTree({
14
+ getRoots: client.getFileRoots,
14
15
  listFiles: client.sendListFiles,
15
16
  createFileOrFolder: client.sendCreateFileOrFolder,
16
17
  deleteFileOrFolder: client.sendDeleteFileOrFolder,
@@ -77,6 +77,7 @@ test("default UserConfig - empty", () => {
77
77
  "theme": "light",
78
78
  },
79
79
  "experimental": {},
80
+ "file_browser": {},
80
81
  "formatting": {
81
82
  "line_length": 79,
82
83
  },
@@ -152,6 +153,7 @@ test("default UserConfig - one level", () => {
152
153
  "theme": "light",
153
154
  },
154
155
  "experimental": {},
156
+ "file_browser": {},
155
157
  "formatting": {
156
158
  "line_length": 79,
157
159
  },
@@ -212,6 +214,19 @@ test("default UserConfig with additional information", () => {
212
214
  );
213
215
  });
214
216
 
217
+ test("UserConfig with file browser folders", () => {
218
+ const config = UserConfigSchema.parse({
219
+ file_browser: {
220
+ folders: [{ path: "/data", name: "Data" }, { path: "/shared" }],
221
+ },
222
+ });
223
+
224
+ expect(config.file_browser?.folders).toEqual([
225
+ { path: "/data", name: "Data" },
226
+ { path: "/shared" },
227
+ ]);
228
+ });
229
+
215
230
  test("UserConfig with custom_providers", () => {
216
231
  const config = UserConfigSchema.parse({
217
232
  ai: {
@@ -214,6 +214,19 @@ export const UserConfigSchema = z
214
214
  sql_linter: z.boolean().optional(),
215
215
  })
216
216
  .prefault(() => ({})),
217
+ file_browser: z
218
+ .looseObject({
219
+ folders: z
220
+ .array(
221
+ z.object({
222
+ path: z.string(),
223
+ name: z.string().optional(),
224
+ }),
225
+ )
226
+ .optional(),
227
+ })
228
+ .optional()
229
+ .prefault({}),
217
230
  sharing: z
218
231
  .looseObject({
219
232
  html: z.boolean().optional(),
@@ -237,6 +250,7 @@ export const UserConfigSchema = z
237
250
  runtime: {},
238
251
  display: {},
239
252
  diagnostics: {},
253
+ file_browser: {},
240
254
  experimental: {},
241
255
  server: {},
242
256
  ai: {},
@@ -341,6 +355,7 @@ export function defaultUserConfig(): UserConfig {
341
355
  runtime: {},
342
356
  display: {},
343
357
  diagnostics: {},
358
+ file_browser: {},
344
359
  experimental: {},
345
360
  server: {},
346
361
  ai: {},