@marimo-team/islands 0.23.3-dev71 → 0.23.3-dev8

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.
@@ -14,7 +14,7 @@ import {
14
14
  SearchIcon,
15
15
  } from "lucide-react";
16
16
  import type React from "react";
17
- import { Suspense, use, useEffect, useRef, useState } from "react";
17
+ import { Suspense, use, useEffect, useMemo, useRef, useState } from "react";
18
18
  import {
19
19
  type NodeApi,
20
20
  type NodeRendererProps,
@@ -22,16 +22,27 @@ import {
22
22
  type TreeApi,
23
23
  } from "react-arborist";
24
24
  import { useLocale } from "react-aria";
25
+ import useEvent from "react-use-event-hook";
25
26
  import { MarkdownIcon } from "@/components/editor/cell/code/icons";
26
27
  import {
27
28
  FILE_ICON as FILE_TYPE_ICONS,
28
29
  type FileIconType as FileType,
29
30
  guessFileIconType as guessFileType,
30
31
  } from "@/components/editor/file-tree/file-icons";
32
+ import { FileNameInput } from "@/components/editor/file-tree/file-name-input";
33
+ import {
34
+ DeleteMenuItem,
35
+ DuplicateMenuItem,
36
+ FileActionsDropdown,
37
+ RenameMenuItem,
38
+ useFileOperations,
39
+ useNotebookFileActions,
40
+ } from "@/components/editor/file-tree/file-operations";
31
41
  import { useImperativeModal } from "@/components/modal/ImperativeModal";
32
42
  import { AlertDialogDestructiveAction } from "@/components/ui/alert-dialog";
33
43
  import { Button } from "@/components/ui/button";
34
44
  import { Checkbox } from "@/components/ui/checkbox";
45
+ import { DropdownMenuSeparator } from "@/components/ui/dropdown-menu";
35
46
  import { Label } from "@/components/ui/label";
36
47
  import { Tooltip } from "@/components/ui/tooltip";
37
48
  import { toast } from "@/components/ui/use-toast";
@@ -61,7 +72,7 @@ import {
61
72
  expandedFoldersAtom,
62
73
  includeMarkdownAtom,
63
74
  RunningNotebooksContext,
64
- WorkspaceRootContext,
75
+ WorkspaceContext,
65
76
  } from "../home/state";
66
77
  import { Spinner } from "../icons/spinner";
67
78
  import { Input } from "../ui/input";
@@ -131,7 +142,7 @@ const HomePage: React.FC = () => {
131
142
  files={recents.files}
132
143
  />
133
144
  <ErrorBoundary>
134
- <WorkspaceNotebooks />
145
+ <WorkspaceNotebooks onRefreshRecents={recentsResponse.refetch} />
135
146
  </ErrorBoundary>
136
147
  </div>
137
148
  </RunningNotebooksContext>
@@ -139,7 +150,9 @@ const HomePage: React.FC = () => {
139
150
  );
140
151
  };
141
152
 
142
- const WorkspaceNotebooks: React.FC = () => {
153
+ const WorkspaceNotebooks: React.FC<{ onRefreshRecents: () => void }> = ({
154
+ onRefreshRecents,
155
+ }) => {
143
156
  const { getWorkspaceFiles } = useRequestClient();
144
157
  const [includeMarkdown, setIncludeMarkdown] = useAtom(includeMarkdownAtom);
145
158
  const [searchText, setSearchText] = useState("");
@@ -154,6 +167,19 @@ const WorkspaceNotebooks: React.FC = () => {
154
167
  [includeMarkdown],
155
168
  );
156
169
 
170
+ // Fire-and-forget refresh of both the workspace tree and the "Recent
171
+ // notebooks" list — file mutations on the workspace tree can affect both,
172
+ // so we invalidate them together rather than having two refresh triggers.
173
+ const refreshWorkspace = useEvent(() => {
174
+ refetch();
175
+ onRefreshRecents();
176
+ });
177
+
178
+ const workspaceContextValue = useMemo(
179
+ () => ({ root: workspace?.root ?? "", refreshWorkspace }),
180
+ [workspace?.root, refreshWorkspace],
181
+ );
182
+
157
183
  if (isPending) {
158
184
  return <Spinner centered={true} size="xlarge" className="mt-6" />;
159
185
  }
@@ -167,7 +193,7 @@ const WorkspaceNotebooks: React.FC = () => {
167
193
  }
168
194
 
169
195
  return (
170
- <WorkspaceRootContext value={workspace.root}>
196
+ <WorkspaceContext value={workspaceContextValue}>
171
197
  <div className="flex flex-col gap-2">
172
198
  {workspace.hasMore && (
173
199
  <Banner kind="warn" className="rounded p-4">
@@ -216,7 +242,7 @@ const WorkspaceNotebooks: React.FC = () => {
216
242
  <NotebookFileTree searchText={searchText} files={workspace.files} />
217
243
  </div>
218
244
  </div>
219
- </WorkspaceRootContext>
245
+ </WorkspaceContext>
220
246
  );
221
247
  };
222
248
 
@@ -244,6 +270,8 @@ const NotebookFileTree: React.FC<{
244
270
  const [openState, setOpenState] = useAtom(expandedFoldersAtom);
245
271
  const openStateIsEmpty = Object.keys(openState).length === 0;
246
272
  const ref = useRef<TreeApi<FileInfo>>(undefined);
273
+ const { root, refreshWorkspace } = use(WorkspaceContext);
274
+ const { renameFile } = useFileOperations({ root });
247
275
 
248
276
  useEffect(() => {
249
277
  // If empty, collapse all
@@ -252,6 +280,21 @@ const NotebookFileTree: React.FC<{
252
280
  }
253
281
  }, [openStateIsEmpty]);
254
282
 
283
+ const handleRename = useEvent(async (id: string, name: string) => {
284
+ const node = ref.current?.get(id);
285
+ if (!node) {
286
+ toast({
287
+ title: "Failed",
288
+ description: `Node with id ${id} not found in the tree`,
289
+ });
290
+ return;
291
+ }
292
+ const result = await renameFile(node.data, name);
293
+ if (result) {
294
+ refreshWorkspace();
295
+ }
296
+ });
297
+
255
298
  if (files.length === 0) {
256
299
  return (
257
300
  <div className="flex flex-col px-5 py-10 items-center justify-center">
@@ -277,6 +320,9 @@ const NotebookFileTree: React.FC<{
277
320
  const prevOpen = openState[id] ?? false;
278
321
  setOpenState({ ...openState, [id]: !prevOpen });
279
322
  }}
323
+ onRename={async ({ id, name }) => {
324
+ await handleRename(id, name);
325
+ }}
280
326
  padding={5}
281
327
  rowHeight={35}
282
328
  indent={15}
@@ -286,7 +332,6 @@ const NotebookFileTree: React.FC<{
286
332
  // Disable interactions
287
333
  disableDrop={true}
288
334
  disableDrag={true}
289
- disableEdit={true}
290
335
  disableMultiSelection={true}
291
336
  >
292
337
  {Node}
@@ -301,11 +346,24 @@ const Node = ({ node, style }: NodeRendererProps<FileInfo>) => {
301
346
 
302
347
  const Icon = FILE_TYPE_ICONS[fileType];
303
348
  const iconEl = <Icon className="w-5 h-5 shrink-0" strokeWidth={1.5} />;
304
- const root = use(WorkspaceRootContext);
349
+ const { root } = use(WorkspaceContext);
350
+ const { runningNotebooks } = use(RunningNotebooksContext);
305
351
 
306
352
  const renderItem = () => {
307
353
  const itemClassName =
308
354
  "flex items-center pl-1 cursor-pointer hover:bg-accent/50 hover:text-accent-foreground rounded-l flex-1 overflow-hidden h-full pr-3 gap-2";
355
+
356
+ // Inline rename input; react-arborist flips `node.isEditing` when
357
+ // `node.edit()` is called from the FileActions menu.
358
+ if (node.isEditing) {
359
+ return (
360
+ <div className={itemClassName}>
361
+ {iconEl}
362
+ <FileNameInput node={node} />
363
+ </div>
364
+ );
365
+ }
366
+
309
367
  if (node.data.isDirectory) {
310
368
  return (
311
369
  <span className={itemClassName}>
@@ -322,6 +380,7 @@ const Node = ({ node, style }: NodeRendererProps<FileInfo>) => {
322
380
 
323
381
  const isMarkdown =
324
382
  relativePath.endsWith(".md") || relativePath.endsWith(".qmd");
383
+ const isRunning = runningNotebooks.has(relativePath);
325
384
 
326
385
  return (
327
386
  <a
@@ -334,10 +393,19 @@ const Node = ({ node, style }: NodeRendererProps<FileInfo>) => {
334
393
  {node.data.name}
335
394
  {isMarkdown && <MarkdownIcon className="ml-2 inline opacity-80" />}
336
395
  </span>
337
- <SessionShutdownButton filePath={relativePath} />
396
+
397
+ <FileActions node={node} isRunning={isRunning} />
398
+ {/*
399
+ Trailing action slots. Using a fixed-width row here (rather than
400
+ conditionally rendered inline elements) keeps every row's right
401
+ edge aligned even though any individual slot may be empty.
402
+ */}
403
+ <div className="w-8 h-8 flex items-center justify-center shrink-0">
404
+ <SessionShutdownButton filePath={relativePath} />
405
+ </div>
338
406
  <ExternalLinkIcon
339
407
  size={20}
340
- className="group-hover:opacity-100 opacity-0 text-primary"
408
+ className="group-hover:opacity-100 opacity-0 text-primary shrink-0"
341
409
  />
342
410
  </a>
343
411
  );
@@ -362,6 +430,44 @@ const Node = ({ node, style }: NodeRendererProps<FileInfo>) => {
362
430
  );
363
431
  };
364
432
 
433
+ const FileActions = ({
434
+ node,
435
+ isRunning,
436
+ }: {
437
+ node: NodeApi<FileInfo>;
438
+ isRunning: boolean;
439
+ }) => {
440
+ const { root, refreshWorkspace } = use(WorkspaceContext);
441
+ const { handleRename, handleDuplicate, handleDelete } =
442
+ useNotebookFileActions({ node, root, onAfterChange: refreshWorkspace });
443
+
444
+ const lockedReason = isRunning
445
+ ? "Stop the notebook's kernel before renaming or deleting."
446
+ : undefined;
447
+
448
+ return (
449
+ <FileActionsDropdown
450
+ testId="workspace-more-button"
451
+ buttonClassName="w-8 h-8 p-0 shrink-0"
452
+ contentClassName="print:hidden w-fit min-w-[140px]"
453
+ preventDefaultOnTrigger={true}
454
+ >
455
+ <RenameMenuItem
456
+ onSelect={handleRename}
457
+ disabled={isRunning}
458
+ title={lockedReason}
459
+ />
460
+ <DuplicateMenuItem onSelect={handleDuplicate} />
461
+ <DropdownMenuSeparator />
462
+ <DeleteMenuItem
463
+ onSelect={handleDelete}
464
+ disabled={isRunning}
465
+ title={lockedReason}
466
+ />
467
+ </FileActionsDropdown>
468
+ );
469
+ };
470
+
365
471
  const FolderArrow = ({ node }: { node: NodeApi<FileInfo> }) => {
366
472
  if (!node.data.isDirectory) {
367
473
  return <span className="w-5 h-5 shrink-0" />;
@@ -305,7 +305,6 @@ export function createNetworkRequests(): EditRequests & RunRequests {
305
305
  .then(handleResponse);
306
306
  },
307
307
  sendDeleteFileOrFolder: async (request) => {
308
- await waitForConnectionOpen();
309
308
  return getClient()
310
309
  .POST("/api/files/delete", {
311
310
  body: request,
@@ -313,7 +312,6 @@ export function createNetworkRequests(): EditRequests & RunRequests {
313
312
  .then(handleResponse);
314
313
  },
315
314
  sendCopyFileOrFolder: async (request) => {
316
- await waitForConnectionOpen();
317
315
  return getClient()
318
316
  .POST("/api/files/copy", {
319
317
  body: request,
@@ -321,7 +319,6 @@ export function createNetworkRequests(): EditRequests & RunRequests {
321
319
  .then(handleResponse);
322
320
  },
323
321
  sendRenameFileOrFolder: async (request) => {
324
- await waitForConnectionOpen();
325
322
  return getClient()
326
323
  .POST("/api/files/move", {
327
324
  body: request,
@@ -9,6 +9,26 @@ describe("Paths", () => {
9
9
  expect(Paths.isAbsolute("/user/docs/Letter.txt")).toBe(true);
10
10
  expect(Paths.isAbsolute("C:\\user\\docs\\Letter.txt")).toBe(true);
11
11
  expect(Paths.isAbsolute("user/docs/Letter.txt")).toBe(false);
12
+
13
+ // Any Windows drive letter, either separator, and any case
14
+ expect(Paths.isAbsolute("D:\\Users\\x\\a.py")).toBe(true);
15
+ expect(Paths.isAbsolute("z:/tmp/file")).toBe(true);
16
+ expect(Paths.isAbsolute("e:\\")).toBe(true);
17
+
18
+ // UNC / server paths
19
+ expect(Paths.isAbsolute("\\\\server\\share\\file")).toBe(true);
20
+
21
+ // URI schemes
22
+ expect(Paths.isAbsolute("s3://bucket/key")).toBe(true);
23
+ expect(Paths.isAbsolute("gs://bucket/key")).toBe(true);
24
+ expect(Paths.isAbsolute("file:///tmp/file")).toBe(true);
25
+ expect(Paths.isAbsolute("http://example.com/x")).toBe(true);
26
+
27
+ // Negative cases
28
+ expect(Paths.isAbsolute("C:file")).toBe(false); // drive without separator
29
+ expect(Paths.isAbsolute("notebook.py")).toBe(false);
30
+ expect(Paths.isAbsolute("./relative")).toBe(false);
31
+ expect(Paths.isAbsolute("")).toBe(false);
12
32
  });
13
33
 
14
34
  describe("dirname", () => {
@@ -1,6 +1,12 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
  import { describe, expect, it } from "vitest";
3
- import { fileSplit, getProtocolAndParentDirectories } from "./pathUtils";
3
+ import {
4
+ fileSplit,
5
+ getProtocolAndParentDirectories,
6
+ makeDuplicateName,
7
+ resolvePaths,
8
+ toAbsolutePath,
9
+ } from "./pathUtils";
4
10
 
5
11
  describe("getProtocolAndParentDirectories", () => {
6
12
  it("should extract protocol and list parent directories correctly", () => {
@@ -111,3 +117,137 @@ describe("fileSplit", () => {
111
117
  ]);
112
118
  });
113
119
  });
120
+
121
+ describe("makeDuplicateName", () => {
122
+ it("appends _copy before the extension", () => {
123
+ expect(makeDuplicateName("notebook.py")).toBe("notebook_copy.py");
124
+ expect(makeDuplicateName("foo.tar.gz")).toBe("foo.tar_copy.gz");
125
+ });
126
+
127
+ it("appends _copy for extensionless names", () => {
128
+ expect(makeDuplicateName("README")).toBe("README_copy");
129
+ });
130
+ });
131
+
132
+ describe("toAbsolutePath", () => {
133
+ it("joins relative paths against the root", () => {
134
+ expect(toAbsolutePath("notebooks/a.py", "/workspaces/marimo")).toBe(
135
+ "/workspaces/marimo/notebooks/a.py",
136
+ );
137
+ });
138
+
139
+ it("returns absolute POSIX paths unchanged", () => {
140
+ expect(toAbsolutePath("/abs/a.py", "/workspaces/marimo")).toBe("/abs/a.py");
141
+ });
142
+
143
+ it("returns absolute Windows paths unchanged", () => {
144
+ expect(toAbsolutePath("C:\\Users\\x\\a.py", "C:\\Users\\marimo")).toBe(
145
+ "C:\\Users\\x\\a.py",
146
+ );
147
+ });
148
+
149
+ it("joins relative paths using the Windows delimiter", () => {
150
+ expect(toAbsolutePath("a.py", "C:\\Users\\marimo")).toBe(
151
+ "C:\\Users\\marimo\\a.py",
152
+ );
153
+ });
154
+ });
155
+
156
+ describe("resolvePaths", () => {
157
+ it("resolves relative paths against the root", () => {
158
+ expect(
159
+ resolvePaths({
160
+ path: "notebooks/notebook.py",
161
+ name: "notebook_copy.py",
162
+ root: "/workspaces/marimo",
163
+ }),
164
+ ).toEqual({
165
+ path: "/workspaces/marimo/notebooks/notebook.py",
166
+ newPath: "/workspaces/marimo/notebooks/notebook_copy.py",
167
+ });
168
+ });
169
+
170
+ it("keeps absolute source paths untouched", () => {
171
+ expect(
172
+ resolvePaths({
173
+ path: "/abs/path/notebook.py",
174
+ name: "notebook_copy.py",
175
+ root: "/workspaces/marimo",
176
+ }),
177
+ ).toEqual({
178
+ path: "/abs/path/notebook.py",
179
+ newPath: "/abs/path/notebook_copy.py",
180
+ });
181
+ });
182
+
183
+ it("handles Windows roots for relative paths", () => {
184
+ expect(
185
+ resolvePaths({
186
+ path: "notebook.py",
187
+ name: "notebook_copy.py",
188
+ root: "C:\\Users\\marimo",
189
+ }),
190
+ ).toEqual({
191
+ path: "C:\\Users\\marimo\\notebook.py",
192
+ newPath: "C:\\Users\\marimo\\notebook_copy.py",
193
+ });
194
+ });
195
+
196
+ it("handles Windows absolute source paths", () => {
197
+ // The tricky case: `path` is an absolute Windows path with a drive letter,
198
+ // so it must be detected by `Paths.isAbsolute` AND the deliminator must be
199
+ // picked up from the root so the join uses backslashes.
200
+ expect(
201
+ resolvePaths({
202
+ path: "C:\\Users\\marimo\\folder\\notebook.py",
203
+ name: "notebook_copy.py",
204
+ root: "C:\\Users\\marimo",
205
+ }),
206
+ ).toEqual({
207
+ path: "C:\\Users\\marimo\\folder\\notebook.py",
208
+ newPath: "C:\\Users\\marimo\\folder\\notebook_copy.py",
209
+ });
210
+ });
211
+
212
+ it("keeps the file in its current directory when renaming", () => {
213
+ expect(
214
+ resolvePaths({
215
+ path: "/root/a/b/file.py",
216
+ name: "renamed.py",
217
+ root: "/root",
218
+ }),
219
+ ).toEqual({
220
+ path: "/root/a/b/file.py",
221
+ newPath: "/root/a/b/renamed.py",
222
+ });
223
+ });
224
+
225
+ it("handles an empty root with an absolute POSIX path", () => {
226
+ // Callers that already hold absolute paths pass `root: ""`. In that case
227
+ // the delimiter must be inferred from the path itself, not from `""` (which
228
+ // would otherwise default to Windows backslashes).
229
+ expect(
230
+ resolvePaths({
231
+ path: "/abs/path/file.py",
232
+ name: "renamed.py",
233
+ root: "",
234
+ }),
235
+ ).toEqual({
236
+ path: "/abs/path/file.py",
237
+ newPath: "/abs/path/renamed.py",
238
+ });
239
+ });
240
+
241
+ it("handles an empty root with an absolute Windows path", () => {
242
+ expect(
243
+ resolvePaths({
244
+ path: "C:\\Users\\marimo\\file.py",
245
+ name: "renamed.py",
246
+ root: "",
247
+ }),
248
+ ).toEqual({
249
+ path: "C:\\Users\\marimo\\file.py",
250
+ newPath: "C:\\Users\\marimo\\renamed.py",
251
+ });
252
+ });
253
+ });
@@ -1,5 +1,7 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
+ import { type FilePath, Paths, PathBuilder } from "./paths";
4
+
3
5
  /**
4
6
  * Get the protocol and parent directories of a path.
5
7
  */
@@ -74,3 +76,47 @@ export function fileSplit(path: string): [name: string, extension: string] {
74
76
  const extension = lastDotIndex > 0 ? path.slice(lastDotIndex) : "";
75
77
  return [name, extension];
76
78
  }
79
+
80
+ /**
81
+ * Build the "_copy" filename used for duplicate operations.
82
+ * e.g. `foo.py` → `foo_copy.py`, `README` → `README_copy`.
83
+ */
84
+ export function makeDuplicateName(name: string): string {
85
+ const [base, extension] = fileSplit(name);
86
+ return `${base}_copy${extension}`;
87
+ }
88
+
89
+ /**
90
+ * Return `path` as an absolute path, joining against `root` when it's
91
+ * workspace-relative. `root`'s delimiter determines the join style so
92
+ * Windows and POSIX paths both behave correctly.
93
+ */
94
+ export function toAbsolutePath(path: string, root: string): FilePath {
95
+ if (Paths.isAbsolute(path)) {
96
+ return path as FilePath;
97
+ }
98
+ return PathBuilder.guessDeliminator(root).join(root, path as FilePath);
99
+ }
100
+
101
+ /**
102
+ * Resolve absolute current/new paths for a rename- or copy-style operation.
103
+ * Given a node's current `path` (absolute or workspace-relative) and a
104
+ * desired new `name` (basename only), returns the absolute source and the
105
+ * absolute destination, keeping the file in the same parent directory.
106
+ */
107
+ export function resolvePaths({
108
+ path,
109
+ name,
110
+ root,
111
+ }: {
112
+ path: string;
113
+ name: string;
114
+ root: string;
115
+ }): { path: FilePath; newPath: FilePath } {
116
+ const absPath = toAbsolutePath(path, root);
117
+ // When `root` is empty (callers with already-absolute paths), fall back to
118
+ // the resolved absolute path so we don't default to the Windows delimiter.
119
+ const pathBuilder = PathBuilder.guessDeliminator(root || absPath);
120
+ const newPath = pathBuilder.join(pathBuilder.dirname(absPath), name);
121
+ return { path: absPath, newPath };
122
+ }
@@ -4,10 +4,18 @@ import type { TypedString } from "./typed";
4
4
 
5
5
  export type FilePath = TypedString<"FilePath">;
6
6
 
7
+ // Windows drive-letter prefix like `C:\`, `d:/`, `Z:\`.
8
+ const WINDOWS_DRIVE_PREFIX = /^[A-Za-z]:[/\\]/;
9
+ // URI scheme prefix like `s3://`, `gs://`, `http://`, `file://`.
10
+ const URI_SCHEME_PREFIX = /^[A-Za-z][\dA-Za-z+.-]*:\/\//;
11
+
7
12
  export const Paths = {
8
13
  isAbsolute: (path: string): boolean => {
9
14
  return (
10
- path.startsWith("/") || path.startsWith("\\") || path.startsWith("C:\\")
15
+ path.startsWith("/") ||
16
+ path.startsWith("\\") ||
17
+ WINDOWS_DRIVE_PREFIX.test(path) ||
18
+ URI_SCHEME_PREFIX.test(path)
11
19
  );
12
20
  },
13
21
  dirname: (path: string) => {