@marimo-team/frontend 0.24.1-dev84 → 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-07LjLydm.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-D01f7IIt.js → agent-panel-DF7_IEEH.js} +1 -1
  10. package/dist/assets/{ai-model-dropdown-DPxSumqI.js → ai-model-dropdown-DIItpu01.js} +1 -1
  11. package/dist/assets/{app-config-button-DV7ZvBMx.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-Bl5DkKIR.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-BFCcdgia.js → chat-display-B9XOYJaA.js} +1 -1
  17. package/dist/assets/{chat-panel-CS9bLpq9.js → chat-panel-Drx8ToF5.js} +1 -1
  18. package/dist/assets/{chat-ui-lssAqY7E.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-DInNqneC.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-ZyGzkf7q.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-Ck-_oVJT.js → home-page-C6NwS9L9.js} +1 -1
  39. package/dist/assets/{index-g9ny2vs0.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-D0Q03BhB.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-BBP_xysn.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-fKt2XFRB.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-B55bWtCO.js → reveal-component-Bna3jncc.js} +1 -1
  56. package/dist/assets/{run-page-mD_6P_t5.js → run-page-CFC5C_It.js} +1 -1
  57. package/dist/assets/{scratchpad-panel-B_7IHnPD.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-TY77TiqZ.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-C1YIorGp.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
@@ -3,8 +3,9 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
3
3
  import { MockModules } from "@/__mocks__/common";
4
4
  import { toast } from "@/components/ui/use-toast";
5
5
  import type { FilePath } from "@/utils/paths";
6
- import { RequestingTree } from "../requesting-tree";
6
+ import { fileTreeNodeId, RequestingTree } from "../requesting-tree";
7
7
 
8
+ const getRoots = vi.fn();
8
9
  const sendListFiles = vi.fn();
9
10
  const sendCreateFileOrFolder = vi.fn();
10
11
  const sendDeleteFileOrFolder = vi.fn();
@@ -13,499 +14,475 @@ const sendRenameFileOrFolder = vi.fn();
13
14
 
14
15
  vi.mock("@/components/ui/use-toast", () => MockModules.toast());
15
16
 
17
+ const PRIMARY_FILES = [
18
+ {
19
+ id: "/root/file1",
20
+ name: "file1",
21
+ path: "/root/file1",
22
+ isDirectory: false,
23
+ isMarimoFile: false,
24
+ children: [],
25
+ },
26
+ {
27
+ id: "/root/folder1",
28
+ name: "folder1",
29
+ path: "/root/folder1",
30
+ isDirectory: true,
31
+ isMarimoFile: false,
32
+ children: [],
33
+ },
34
+ ];
35
+
36
+ const PRIMARY_ROOT_ID = fileTreeNodeId("/root", "/root");
37
+ const PRIMARY_FILE_ID = fileTreeNodeId("/root", "/root/file1");
38
+ const EXTERNAL_ROOT_ID = fileTreeNodeId("/external", "/external");
39
+
16
40
  describe("RequestingTree", () => {
17
- let requestingTree: RequestingTree;
18
- const mockOnChange = vi.fn();
41
+ let tree: RequestingTree;
42
+ const onChange = vi.fn();
19
43
 
20
44
  beforeEach(async () => {
21
- requestingTree = new RequestingTree({
45
+ getRoots.mockResolvedValue({
46
+ roots: [
47
+ { path: "/root", name: "workspace", isPrimary: true },
48
+ { path: "/external", name: "Shared", isPrimary: false },
49
+ ],
50
+ });
51
+ sendListFiles.mockImplementation(async ({ path }: { path: string }) => ({
52
+ files: path === "/root" ? PRIMARY_FILES : [],
53
+ root: path,
54
+ }));
55
+ sendCreateFileOrFolder.mockResolvedValue({ success: true });
56
+ sendDeleteFileOrFolder.mockResolvedValue({ success: true });
57
+ sendCopyFileOrFolder.mockResolvedValue({ success: true });
58
+ sendRenameFileOrFolder.mockResolvedValue({ success: true });
59
+
60
+ tree = new RequestingTree({
61
+ getRoots,
22
62
  listFiles: sendListFiles,
23
63
  createFileOrFolder: sendCreateFileOrFolder,
24
64
  deleteFileOrFolder: sendDeleteFileOrFolder,
25
65
  copyFileOrFolder: sendCopyFileOrFolder,
26
66
  renameFileOrFolder: sendRenameFileOrFolder,
27
67
  });
28
- sendListFiles.mockResolvedValue({
29
- files: [
30
- { id: "1.1", name: "file1", path: "/root/file1" },
31
- {
32
- id: "1.2",
33
- name: "folder1",
34
- isDirectory: true,
35
- path: "/root/folder1",
36
- },
37
- {
38
- id: "1.3",
39
- name: "folder2",
40
- isDirectory: true,
41
- path: "/root/folder2",
42
- },
43
- ],
44
- root: "/root",
45
- });
46
- await requestingTree.initialize(mockOnChange);
68
+ await tree.initialize(onChange);
47
69
  });
48
70
 
49
71
  afterEach(() => {
50
72
  vi.clearAllMocks();
51
73
  });
52
74
 
53
- test("initialize should load files and set rootPath", async () => {
54
- expect(sendListFiles).toHaveBeenCalledWith({ path: "" });
55
- expect(requestingTree.getRootPath()).toBe("/root");
56
- expect(mockOnChange).toHaveBeenCalledWith([
57
- { id: "1.1", name: "file1", path: "/root/file1" },
58
- { id: "1.2", name: "folder1", isDirectory: true, path: "/root/folder1" },
59
- { id: "1.3", name: "folder2", isDirectory: true, path: "/root/folder2" },
75
+ test("initializes with visible, unloaded root nodes", () => {
76
+ expect(getRoots).toHaveBeenCalledOnce();
77
+ expect(sendListFiles).not.toHaveBeenCalled();
78
+ expect(tree.getPrimaryRootPath()).toBe("/root");
79
+ expect(onChange).toHaveBeenLastCalledWith([
80
+ expect.objectContaining({
81
+ id: PRIMARY_ROOT_ID,
82
+ name: "workspace",
83
+ isRoot: true,
84
+ isPrimaryRoot: true,
85
+ children: [],
86
+ }),
87
+ expect.objectContaining({
88
+ id: EXTERNAL_ROOT_ID,
89
+ name: "Shared",
90
+ isRoot: true,
91
+ isPrimaryRoot: false,
92
+ children: [],
93
+ }),
60
94
  ]);
61
95
  });
62
96
 
63
- test("expand should load children for a directory", async () => {
64
- sendListFiles.mockResolvedValue({
65
- files: [{ id: "2", name: "file2", path: "/roo/folder1/file2" }],
97
+ test("flattens the primary root when there are no additional roots", async () => {
98
+ getRoots.mockResolvedValueOnce({
99
+ roots: [{ path: "/root", name: "workspace", isPrimary: true }],
100
+ });
101
+ const singleRootOnChange = vi.fn();
102
+ const singleRootTree = new RequestingTree({
103
+ getRoots,
104
+ listFiles: sendListFiles,
105
+ createFileOrFolder: sendCreateFileOrFolder,
106
+ deleteFileOrFolder: sendDeleteFileOrFolder,
107
+ copyFileOrFolder: sendCopyFileOrFolder,
108
+ renameFileOrFolder: sendRenameFileOrFolder,
66
109
  });
67
- const result = await requestingTree.expand("1.2");
68
- expect(result).toBe(true);
69
- expect(sendListFiles).toHaveBeenCalledWith({ path: "/root/folder1" });
70
- expect(mockOnChange).toHaveBeenCalled();
71
- const lastCall = mockOnChange.mock.calls.at(-1);
72
- expect(lastCall).toBeDefined();
73
- expect(lastCall![0]).toMatchInlineSnapshot(`
74
- [
75
- {
76
- "id": "1.1",
77
- "name": "file1",
78
- "path": "/root/file1",
79
- },
80
- {
81
- "children": [
82
- {
83
- "id": "2",
84
- "name": "file2",
85
- "path": "/roo/folder1/file2",
86
- },
87
- ],
88
- "id": "1.2",
89
- "isDirectory": true,
90
- "name": "folder1",
91
- "path": "/root/folder1",
92
- },
93
- {
94
- "id": "1.3",
95
- "isDirectory": true,
96
- "name": "folder2",
97
- "path": "/root/folder2",
98
- },
99
- ]
100
- `);
101
- });
102
110
 
103
- test("rename should change the name and path of a file", async () => {
104
- sendRenameFileOrFolder.mockResolvedValue({ success: true });
111
+ await singleRootTree.initialize(singleRootOnChange);
105
112
 
106
- await requestingTree.rename("1.1", "file2");
107
- expect(sendRenameFileOrFolder).toHaveBeenCalledWith({
108
- path: "/root/file1",
109
- newPath: "/root/file2",
113
+ expect(sendListFiles).toHaveBeenCalledWith({ path: "/root" });
114
+ expect(singleRootOnChange).toHaveBeenLastCalledWith([
115
+ expect.objectContaining({
116
+ id: PRIMARY_FILE_ID,
117
+ path: "/root/file1",
118
+ isRoot: false,
119
+ isPrimaryRoot: true,
120
+ }),
121
+ expect.objectContaining({
122
+ id: fileTreeNodeId("/root", "/root/folder1"),
123
+ path: "/root/folder1",
124
+ isRoot: false,
125
+ isPrimaryRoot: true,
126
+ }),
127
+ ]);
128
+
129
+ await singleRootTree.createFile({ name: "new.txt", parentId: null });
130
+ expect(sendCreateFileOrFolder).toHaveBeenCalledWith({
131
+ path: "/root",
132
+ type: "file",
133
+ name: "new.txt",
110
134
  });
111
- expect(mockOnChange).toHaveBeenCalled();
112
- const lastCall = mockOnChange.mock.calls.at(-1);
113
- expect(lastCall).toBeDefined();
114
- expect(lastCall![0]).toMatchInlineSnapshot(`
115
- [
116
- {
117
- "id": "1.1",
118
- "name": "file1",
119
- "path": "/root/file1",
120
- },
121
- {
122
- "id": "1.2",
123
- "isDirectory": true,
124
- "name": "folder1",
125
- "path": "/root/folder1",
126
- },
127
- {
128
- "id": "1.3",
129
- "isDirectory": true,
130
- "name": "folder2",
131
- "path": "/root/folder2",
132
- },
133
- ]
134
- `);
135
135
  });
136
136
 
137
- test("move should change the parent of a file", async () => {
138
- sendRenameFileOrFolder.mockResolvedValue({ success: true });
139
-
140
- await requestingTree.move(["1.1"], "1.2");
141
- expect(sendRenameFileOrFolder).toHaveBeenCalled();
142
- expect(mockOnChange).toHaveBeenCalled();
143
- const lastCall = mockOnChange.mock.calls.at(-1);
144
- expect(lastCall).toBeDefined();
145
- expect(lastCall![0]).toMatchInlineSnapshot(`
146
- [
147
- {
148
- "id": "1.1",
149
- "name": "file1",
150
- "path": "/root/file1",
151
- },
152
- {
153
- "children": [
154
- {
155
- "id": "1.1",
156
- "name": "file1",
157
- "path": "/root/folder1/file1",
158
- },
159
- ],
160
- "id": "1.2",
161
- "isDirectory": true,
162
- "name": "folder1",
163
- "path": "/root/folder1",
164
- },
137
+ test("expands each root lazily and annotates its descendants", async () => {
138
+ sendListFiles.mockResolvedValueOnce({
139
+ files: [
165
140
  {
166
- "id": "1.3",
167
- "isDirectory": true,
168
- "name": "folder2",
169
- "path": "/root/folder2",
141
+ id: "/external/data.csv",
142
+ path: "/external/data.csv",
143
+ name: "data.csv",
144
+ isDirectory: false,
145
+ isMarimoFile: false,
146
+ children: [],
170
147
  },
171
- ]
172
- `);
173
- });
174
-
175
- test("copy should duplicate a file", async () => {
176
- sendCopyFileOrFolder.mockResolvedValue({ success: true });
177
-
178
- await requestingTree.copy("1.1", "file1_copy");
179
- expect(sendCopyFileOrFolder).toHaveBeenCalledWith({
180
- path: "/root/file1",
181
- newPath: "/root/file1_copy",
148
+ ],
149
+ root: "/external",
182
150
  });
183
- expect(mockOnChange).toHaveBeenCalled();
184
- });
185
-
186
- test("delete should drop a file on success", async () => {
187
- sendDeleteFileOrFolder.mockResolvedValue({ success: true });
188
151
 
189
- await requestingTree.delete("1.1");
190
- expect(sendDeleteFileOrFolder).toHaveBeenCalledWith({
191
- path: "/root/file1",
192
- });
193
- const lastCall = mockOnChange.mock.calls.at(-1);
194
- expect(lastCall?.[0].map((f: { id: string }) => f.id)).toEqual([
195
- "1.2",
196
- "1.3",
197
- ]);
152
+ expect(await tree.expand(EXTERNAL_ROOT_ID)).toBe(true);
153
+ expect(sendListFiles).toHaveBeenCalledWith({ path: "/external" });
154
+ expect(onChange.mock.calls.at(-1)?.[0][1].children[0]).toEqual(
155
+ expect.objectContaining({
156
+ path: "/external/data.csv",
157
+ rootPath: "/external",
158
+ isPrimaryRoot: false,
159
+ isRoot: false,
160
+ }),
161
+ );
198
162
  });
199
163
 
200
- test("createFile should create a new file", async () => {
201
- sendCreateFileOrFolder.mockResolvedValue({ success: true });
202
-
203
- await requestingTree.createFile({ name: "file3", parentId: "1.2" });
164
+ test("uses the primary root for toolbar creation without a selection", async () => {
165
+ await tree.createFile({ name: "new.txt", parentId: null });
204
166
  expect(sendCreateFileOrFolder).toHaveBeenCalledWith({
205
- path: "/root/folder1",
167
+ path: "/root",
206
168
  type: "file",
207
- name: "file3",
169
+ name: "new.txt",
208
170
  });
209
- expect(mockOnChange).toHaveBeenCalled();
210
171
  });
211
172
 
212
- test("createFile should create a new notebook", async () => {
213
- sendCreateFileOrFolder.mockResolvedValue({ success: true });
214
-
215
- await requestingTree.createFile({
216
- name: "notebook1",
217
- parentId: "1.2",
218
- type: "notebook",
219
- });
173
+ test("creates and uploads into an explicitly selected root", async () => {
174
+ await tree.createFolder("exports", EXTERNAL_ROOT_ID);
220
175
  expect(sendCreateFileOrFolder).toHaveBeenCalledWith({
221
- path: "/root/folder1",
222
- type: "notebook",
223
- name: "notebook1",
176
+ path: "/external",
177
+ type: "directory",
178
+ name: "exports",
224
179
  });
225
- expect(mockOnChange).toHaveBeenCalled();
226
180
  });
227
181
 
228
- test("createFolder should create a new folder", async () => {
229
- sendCreateFileOrFolder.mockResolvedValue({ success: true });
230
-
231
- await requestingTree.createFolder("folder3", "1.2");
232
- expect(sendCreateFileOrFolder).toHaveBeenCalled();
233
- expect(mockOnChange).toHaveBeenCalled();
182
+ test("prevents mutations of structural root nodes", async () => {
183
+ await tree.rename(EXTERNAL_ROOT_ID, "renamed");
184
+ await tree.copy(EXTERNAL_ROOT_ID, "copy");
185
+ await tree.delete(EXTERNAL_ROOT_ID);
186
+
187
+ expect(sendRenameFileOrFolder).not.toHaveBeenCalled();
188
+ expect(sendCopyFileOrFolder).not.toHaveBeenCalled();
189
+ expect(sendDeleteFileOrFolder).not.toHaveBeenCalled();
190
+ expect(toast).toHaveBeenCalledWith(
191
+ expect.objectContaining({
192
+ description: "File browser roots cannot be modified",
193
+ }),
194
+ );
234
195
  });
235
196
 
236
- test("refreshAll should refresh data for all open folders", async () => {
237
- await requestingTree.refreshAll(["1.1"]);
238
- expect(sendListFiles).toHaveBeenCalled();
239
- expect(mockOnChange).toHaveBeenCalled();
240
- const lastCall = mockOnChange.mock.calls.at(-1);
241
- expect(lastCall).toBeDefined();
242
- expect(lastCall![0]).toMatchInlineSnapshot(`
243
- [
244
- {
245
- "id": "1.1",
246
- "name": "file1",
247
- "path": "/root/file1",
248
- },
249
- {
250
- "id": "1.2",
251
- "isDirectory": true,
252
- "name": "folder1",
253
- "path": "/root/folder1",
254
- },
255
- {
256
- "id": "1.3",
257
- "isDirectory": true,
258
- "name": "folder2",
259
- "path": "/root/folder2",
260
- },
261
- ]
262
- `);
263
- });
197
+ test("supports copying, renaming, and deleting files by absolute path", async () => {
198
+ await tree.expand(PRIMARY_ROOT_ID);
264
199
 
265
- test("refreshPath should refresh an uploaded file's destination", async () => {
266
- sendListFiles.mockImplementation(async ({ path }: { path: string }) => {
267
- if (path === "/root/folder1") {
268
- return {
269
- files: [
270
- {
271
- id: "uploaded",
272
- name: "uploaded.csv",
273
- path: "/root/folder1/uploaded.csv",
274
- },
275
- ],
276
- };
277
- }
278
- return {
279
- files: [
280
- { id: "1.1", name: "file1", path: "/root/file1" },
281
- {
282
- id: "1.2",
283
- name: "folder1",
284
- isDirectory: true,
285
- path: "/root/folder1",
286
- },
287
- {
288
- id: "1.3",
289
- name: "folder2",
290
- isDirectory: true,
291
- path: "/root/folder2",
292
- },
293
- ],
294
- };
200
+ await tree.copy(PRIMARY_FILE_ID, "file1_copy");
201
+ expect(sendCopyFileOrFolder).toHaveBeenCalledWith({
202
+ path: "/root/file1",
203
+ newPath: "/root/file1_copy",
295
204
  });
296
205
 
297
- await requestingTree.refreshPath("/root/folder1" as FilePath);
206
+ await tree.rename(PRIMARY_FILE_ID, "file2");
207
+ expect(sendRenameFileOrFolder).toHaveBeenCalledWith({
208
+ path: "/root/file1",
209
+ newPath: "/root/file2",
210
+ });
298
211
 
299
- expect(sendListFiles).toHaveBeenCalledWith({ path: "/root/folder1" });
300
- expect(mockOnChange.mock.calls.at(-1)?.[0]).toEqual([
301
- { id: "1.1", name: "file1", path: "/root/file1" },
302
- {
303
- id: "1.2",
304
- name: "folder1",
305
- isDirectory: true,
306
- path: "/root/folder1",
307
- children: [
308
- {
309
- id: "uploaded",
310
- name: "uploaded.csv",
311
- path: "/root/folder1/uploaded.csv",
312
- },
313
- ],
314
- },
315
- {
316
- id: "1.3",
317
- name: "folder2",
318
- isDirectory: true,
319
- path: "/root/folder2",
320
- },
321
- ]);
212
+ await tree.delete(PRIMARY_FILE_ID);
213
+ expect(sendDeleteFileOrFolder).toHaveBeenCalledWith({
214
+ path: "/root/file1",
215
+ });
322
216
  });
323
217
 
324
- describe("when API fails", () => {
325
- test("initialize should handle errors gracefully", async () => {
326
- requestingTree = new RequestingTree({
327
- listFiles: sendListFiles,
328
- createFileOrFolder: sendCreateFileOrFolder,
329
- deleteFileOrFolder: sendDeleteFileOrFolder,
330
- copyFileOrFolder: sendCopyFileOrFolder,
331
- renameFileOrFolder: sendRenameFileOrFolder,
332
- });
333
- sendListFiles.mockRejectedValue(new Error("Network error"));
334
- await requestingTree.initialize(mockOnChange);
335
- expect(toast).toHaveBeenCalledWith({
336
- title: "Failed",
337
- description: expect.any(String),
338
- });
218
+ describe("failed mutations", () => {
219
+ beforeEach(async () => {
220
+ await tree.expand(PRIMARY_ROOT_ID);
221
+ sendListFiles.mockClear();
222
+ onChange.mockClear();
339
223
  });
340
224
 
341
- test("rename should handle API failure", async () => {
342
- sendRenameFileOrFolder.mockResolvedValue({
225
+ test("does not update or refresh after a failed copy", async () => {
226
+ sendCopyFileOrFolder.mockResolvedValueOnce({
343
227
  success: false,
344
- message: "Error renaming",
228
+ message: "Permission denied",
345
229
  });
346
230
 
347
- await requestingTree.rename("1.1", "file2");
348
- expect(sendRenameFileOrFolder).toHaveBeenCalledWith({
349
- path: "/root/file1",
350
- newPath: "/root/file2",
351
- });
231
+ await tree.copy(PRIMARY_FILE_ID, "file1_copy");
232
+
233
+ expect(sendListFiles).not.toHaveBeenCalled();
234
+ expect(onChange).not.toHaveBeenCalled();
352
235
  expect(toast).toHaveBeenCalledWith({
353
236
  title: "Failed",
354
- description: "Error renaming",
237
+ description: "Permission denied",
355
238
  });
356
239
  });
357
240
 
358
- test("rename should NOT mutate the local tree on API failure", async () => {
359
- sendRenameFileOrFolder.mockResolvedValue({
241
+ test("does not update or refresh after a failed rename", async () => {
242
+ sendRenameFileOrFolder.mockResolvedValueOnce({
360
243
  success: false,
361
- message: "Error renaming",
244
+ message: "Permission denied",
362
245
  });
363
- const changesBefore = mockOnChange.mock.calls.length;
364
-
365
- await requestingTree.rename("1.1", "file2");
366
246
 
367
- // No further onChange calls should fire after the failed rename, so the
368
- // tree stays in sync with the backend.
369
- expect(mockOnChange.mock.calls.length).toBe(changesBefore);
370
- });
247
+ await tree.rename(PRIMARY_FILE_ID, "file2");
371
248
 
372
- test("delete should NOT drop the node on API failure", async () => {
373
- sendDeleteFileOrFolder.mockResolvedValue({
374
- success: false,
375
- message: "Error deleting",
376
- });
377
- const changesBefore = mockOnChange.mock.calls.length;
378
-
379
- await requestingTree.delete("1.1");
380
- expect(sendDeleteFileOrFolder).toHaveBeenCalledWith({
381
- path: "/root/file1",
382
- });
249
+ expect(sendListFiles).not.toHaveBeenCalled();
250
+ expect(onChange).not.toHaveBeenCalled();
383
251
  expect(toast).toHaveBeenCalledWith({
384
252
  title: "Failed",
385
- description: "Error deleting",
253
+ description: "Permission denied",
386
254
  });
387
- expect(mockOnChange.mock.calls.length).toBe(changesBefore);
388
255
  });
389
256
 
390
- test("move should NOT mutate the local tree when rename fails", async () => {
391
- sendRenameFileOrFolder.mockResolvedValue({
257
+ test("does not update or refresh after a failed delete", async () => {
258
+ sendDeleteFileOrFolder.mockResolvedValueOnce({
392
259
  success: false,
393
- message: "Error moving",
260
+ message: "Permission denied",
394
261
  });
395
262
 
396
- await requestingTree.move(["1.1"], "1.2");
263
+ await tree.delete(PRIMARY_FILE_ID);
397
264
 
265
+ expect(sendListFiles).not.toHaveBeenCalled();
266
+ expect(onChange).not.toHaveBeenCalled();
398
267
  expect(toast).toHaveBeenCalledWith({
399
268
  title: "Failed",
400
- description: "Error moving",
269
+ description: "Permission denied",
401
270
  });
402
- // The last emitted state should still have file1 at the top level, not
403
- // moved under folder1.
404
- const lastCall = mockOnChange.mock.calls.at(-1);
405
- expect(lastCall?.[0]).toEqual([
406
- { id: "1.1", name: "file1", path: "/root/file1" },
407
- {
408
- id: "1.2",
409
- name: "folder1",
410
- isDirectory: true,
411
- path: "/root/folder1",
412
- },
413
- {
414
- id: "1.3",
415
- name: "folder2",
416
- isDirectory: true,
417
- path: "/root/folder2",
418
- },
419
- ]);
420
271
  });
421
272
 
422
- test("copy should handle API failure", async () => {
423
- sendCopyFileOrFolder.mockResolvedValue({
273
+ test("does not update or refresh after a failed move", async () => {
274
+ sendRenameFileOrFolder.mockResolvedValueOnce({
424
275
  success: false,
425
- message: "Error duplicating",
276
+ message: "Permission denied",
426
277
  });
427
278
 
428
- await requestingTree.copy("1.1", "file1_copy");
429
- expect(sendCopyFileOrFolder).toHaveBeenCalledWith({
430
- path: "/root/file1",
431
- newPath: "/root/file1_copy",
432
- });
279
+ await tree.move([PRIMARY_FILE_ID], EXTERNAL_ROOT_ID);
280
+
281
+ expect(sendListFiles).not.toHaveBeenCalled();
282
+ expect(onChange).not.toHaveBeenCalled();
433
283
  expect(toast).toHaveBeenCalledWith({
434
284
  title: "Failed",
435
- description: "Error duplicating",
285
+ description: "Permission denied",
436
286
  });
437
287
  });
288
+ });
289
+
290
+ test("moves files across roots and refreshes both parents", async () => {
291
+ await tree.expand(PRIMARY_ROOT_ID);
292
+ sendListFiles.mockClear();
293
+
294
+ await tree.move([PRIMARY_FILE_ID], EXTERNAL_ROOT_ID);
438
295
 
439
- test("move should handle missing parent node gracefully", async () => {
440
- await requestingTree.move(["1.x"], "2");
441
- expect(sendRenameFileOrFolder).not.toHaveBeenCalled();
442
- expect(mockOnChange).toHaveBeenCalledTimes(3);
296
+ expect(sendRenameFileOrFolder).toHaveBeenCalledWith({
297
+ path: "/root/file1",
298
+ newPath: "/external/file1",
443
299
  });
300
+ expect(sendListFiles).toHaveBeenCalledWith({ path: "/root" });
301
+ expect(sendListFiles).toHaveBeenCalledWith({ path: "/external" });
302
+ });
303
+
304
+ test("refreshes configured roots when every node is collapsed", async () => {
305
+ await tree.refreshAll([]);
306
+ expect(sendListFiles).toHaveBeenCalledWith({ path: "/root" });
307
+ expect(sendListFiles).toHaveBeenCalledWith({ path: "/external" });
308
+ });
309
+
310
+ test("refreshes supplied open folders in addition to configured roots", async () => {
311
+ await tree.expand(PRIMARY_ROOT_ID);
312
+ sendListFiles.mockClear();
313
+
314
+ await tree.refreshAll([fileTreeNodeId("/root", "/root/folder1")]);
315
+
316
+ expect(sendListFiles).toHaveBeenCalledWith({ path: "/root" });
317
+ expect(sendListFiles).toHaveBeenCalledWith({ path: "/external" });
318
+ expect(sendListFiles).toHaveBeenCalledWith({ path: "/root/folder1" });
319
+ });
320
+
321
+ test("keeps existing children when a refresh fails", async () => {
322
+ await tree.expand(PRIMARY_ROOT_ID);
323
+ const beforeRefresh = onChange.mock.calls.at(-1)?.[0];
324
+ sendListFiles.mockRejectedValueOnce(new Error("Network error"));
325
+
326
+ await tree.refreshPath("/root" as FilePath);
327
+
328
+ expect(onChange.mock.calls.at(-1)?.[0]).toEqual(beforeRefresh);
329
+ });
444
330
 
445
- test("refreshAll should handle API errors without crashing", async () => {
446
- sendListFiles.mockRejectedValue(new Error("Network error"));
447
- await requestingTree.refreshAll(["1.2"]);
448
- expect(sendListFiles).toHaveBeenCalled();
449
- // Ensure onChange is still called to update UI even if data might not have changed
450
- expect(mockOnChange).toHaveBeenCalled();
331
+ test("namespaces duplicate paths under overlapping roots", async () => {
332
+ getRoots.mockResolvedValueOnce({
333
+ roots: [
334
+ { path: "/repo", name: "repo", isPrimary: true },
335
+ { path: "/repo/data", name: "Data", isPrimary: false },
336
+ ],
451
337
  });
338
+ sendListFiles.mockImplementation(async ({ path }: { path: string }) => ({
339
+ root: path,
340
+ files:
341
+ path === "/repo"
342
+ ? [
343
+ {
344
+ id: "/repo/data",
345
+ name: "data",
346
+ path: "/repo/data",
347
+ isDirectory: true,
348
+ isMarimoFile: false,
349
+ children: [],
350
+ },
351
+ ]
352
+ : [
353
+ {
354
+ id: "/repo/data/file.csv",
355
+ name: "file.csv",
356
+ path: "/repo/data/file.csv",
357
+ isDirectory: false,
358
+ isMarimoFile: false,
359
+ children: [],
360
+ },
361
+ ],
362
+ }));
363
+ const overlapOnChange = vi.fn();
364
+ const overlapTree = new RequestingTree({
365
+ getRoots,
366
+ listFiles: sendListFiles,
367
+ createFileOrFolder: sendCreateFileOrFolder,
368
+ deleteFileOrFolder: sendDeleteFileOrFolder,
369
+ copyFileOrFolder: sendCopyFileOrFolder,
370
+ renameFileOrFolder: sendRenameFileOrFolder,
371
+ });
372
+ await overlapTree.initialize(overlapOnChange);
373
+
374
+ const primaryRootId = fileTreeNodeId("/repo", "/repo");
375
+ const nestedDirectoryId = fileTreeNodeId("/repo", "/repo/data");
376
+ const additionalRootId = fileTreeNodeId("/repo/data", "/repo/data");
377
+ expect(nestedDirectoryId).not.toBe(additionalRootId);
378
+
379
+ await overlapTree.expand(primaryRootId);
380
+ await overlapTree.expand(nestedDirectoryId);
381
+ await overlapTree.expand(additionalRootId);
382
+
383
+ const roots = overlapOnChange.mock.calls.at(-1)?.[0];
384
+ expect(roots[0].children[0].id).toBe(nestedDirectoryId);
385
+ expect(roots[1].id).toBe(additionalRootId);
386
+ expect(roots[0].children[0].children[0].id).toBe(
387
+ fileTreeNodeId("/repo", "/repo/data/file.csv"),
388
+ );
389
+ expect(roots[1].children[0].id).toBe(
390
+ fileTreeNodeId("/repo/data", "/repo/data/file.csv"),
391
+ );
452
392
  });
453
393
 
454
- describe("relativeFromRoot", () => {
455
- test("should return relative path for Unix paths", async () => {
456
- const tree = new RequestingTree({
394
+ describe("primary-root paths", () => {
395
+ test("returns relative paths only inside the primary root", () => {
396
+ expect(tree.getPrimaryRelativePath("/root/src/file.py" as FilePath)).toBe(
397
+ "src/file.py",
398
+ );
399
+ expect(
400
+ tree.getPrimaryRelativePath("/rooted/file.py" as FilePath),
401
+ ).toBeNull();
402
+ expect(
403
+ tree.getPrimaryRelativePath("/external/file.py" as FilePath),
404
+ ).toBeNull();
405
+ });
406
+
407
+ test("supports Windows path boundaries case-insensitively", async () => {
408
+ getRoots.mockResolvedValueOnce({
409
+ roots: [
410
+ {
411
+ path: "C:\\Users\\Test\\Project",
412
+ name: "Project",
413
+ isPrimary: true,
414
+ },
415
+ ],
416
+ });
417
+ const windowsTree = new RequestingTree({
418
+ getRoots,
457
419
  listFiles: sendListFiles,
458
420
  createFileOrFolder: sendCreateFileOrFolder,
459
421
  deleteFileOrFolder: sendDeleteFileOrFolder,
460
422
  copyFileOrFolder: sendCopyFileOrFolder,
461
423
  renameFileOrFolder: sendRenameFileOrFolder,
462
424
  });
463
-
464
- sendListFiles.mockResolvedValue({
465
- files: [],
466
- root: "/home/user/project",
467
- });
468
-
469
- await tree.initialize(vi.fn());
470
-
471
- const relativePath = tree.relativeFromRoot(
472
- "/home/user/project/src/file.py" as FilePath,
473
- );
474
- expect(relativePath).toBe("src/file.py");
425
+ await windowsTree.initialize(vi.fn());
426
+
427
+ expect(
428
+ windowsTree.getPrimaryRelativePath(
429
+ "c:\\users\\test\\project\\src\\file.py" as FilePath,
430
+ ),
431
+ ).toBe("src\\file.py");
432
+ expect(
433
+ windowsTree.getPrimaryRelativePath(
434
+ "C:\\Users\\Test\\Project-old\\file.py" as FilePath,
435
+ ),
436
+ ).toBeNull();
475
437
  });
476
438
 
477
- test("should return relative path for Windows paths", async () => {
478
- const tree = new RequestingTree({
439
+ test("treats backslashes in POSIX paths as filename characters", async () => {
440
+ const rootPath = "/root/data\\archive";
441
+ const filePath = `${rootPath}/file.txt`;
442
+ getRoots.mockResolvedValueOnce({
443
+ roots: [{ path: rootPath, name: "archive", isPrimary: true }],
444
+ });
445
+ sendListFiles.mockResolvedValueOnce({
446
+ root: rootPath,
447
+ files: [
448
+ {
449
+ id: filePath,
450
+ path: filePath,
451
+ name: "file.txt",
452
+ isDirectory: false,
453
+ isMarimoFile: false,
454
+ children: [],
455
+ },
456
+ ],
457
+ });
458
+ const posixTree = new RequestingTree({
459
+ getRoots,
479
460
  listFiles: sendListFiles,
480
461
  createFileOrFolder: sendCreateFileOrFolder,
481
462
  deleteFileOrFolder: sendDeleteFileOrFolder,
482
463
  copyFileOrFolder: sendCopyFileOrFolder,
483
464
  renameFileOrFolder: sendRenameFileOrFolder,
484
465
  });
466
+ await posixTree.initialize(vi.fn());
485
467
 
486
- sendListFiles.mockResolvedValue({
487
- files: [],
488
- root: "C:\\Users\\test\\project",
489
- });
490
-
491
- await tree.initialize(vi.fn());
492
-
493
- const relativePath = tree.relativeFromRoot(
494
- "C:\\Users\\test\\project\\src\\file.py" as FilePath,
468
+ expect(posixTree.getPrimaryRelativePath(filePath as FilePath)).toBe(
469
+ "file.txt",
495
470
  );
496
- expect(relativePath).toBe("src\\file.py");
471
+ await posixTree.copy(fileTreeNodeId(rootPath, filePath), "copy.txt");
472
+ expect(sendCopyFileOrFolder).toHaveBeenCalledWith({
473
+ path: filePath,
474
+ newPath: `${rootPath}/copy.txt`,
475
+ });
497
476
  });
498
477
 
499
- test("should return original path when not under root", async () => {
500
- const relativePath = requestingTree.relativeFromRoot(
501
- "/other/path/file.py" as FilePath,
478
+ test("uses configured names in root-qualified display paths", () => {
479
+ expect(tree.getDisplayPath("/root/data.csv" as FilePath)).toBe(
480
+ "data.csv",
502
481
  );
503
- expect(relativePath).toBe("/other/path/file.py");
504
- });
505
-
506
- test("should handle root path exactly", async () => {
507
- const relativePath = requestingTree.relativeFromRoot("/root" as FilePath);
508
- expect(relativePath).toBe("/root");
482
+ expect(tree.getDisplayPath("/external/data.csv" as FilePath)).toBe(
483
+ "Shared/data.csv",
484
+ );
485
+ expect(tree.getDisplayPath("/external" as FilePath)).toBe("Shared");
509
486
  });
510
487
  });
511
488
  });