@marimo-team/islands 0.23.3-dev7 → 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.
@@ -0,0 +1,41 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import type { NodeApi } from "react-arborist";
4
+ import { useEffect, useRef } from "react";
5
+ import type { FileInfo } from "@/core/network/types";
6
+
7
+ /**
8
+ * Inline rename input used by `react-arborist` nodes when `node.isEditing`
9
+ * is true. Auto-focuses and selects everything except the extension so a
10
+ * user can type straight into the name.
11
+ */
12
+ export const FileNameInput = ({ node }: { node: NodeApi<FileInfo> }) => {
13
+ const ref = useRef<HTMLInputElement>(null);
14
+ useEffect(() => {
15
+ ref.current?.focus();
16
+ // Select everything but the extension. For extensionless names
17
+ // (`README`) and dotfiles (`.env`), select the full name.
18
+ const name = node.data.name;
19
+ const dotIndex = name.lastIndexOf(".");
20
+ const end = dotIndex > 0 ? dotIndex : name.length;
21
+ ref.current?.setSelectionRange(0, end);
22
+ }, [node.data.name]);
23
+
24
+ return (
25
+ <input
26
+ ref={ref}
27
+ className="flex-1 bg-transparent border border-border text-muted-foreground"
28
+ defaultValue={node.data.name}
29
+ onClick={(e) => e.stopPropagation()}
30
+ onBlur={() => node.reset()}
31
+ onKeyDown={(e) => {
32
+ if (e.key === "Escape") {
33
+ node.reset();
34
+ }
35
+ if (e.key === "Enter") {
36
+ node.submit(e.currentTarget.value);
37
+ }
38
+ }}
39
+ />
40
+ );
41
+ };
@@ -0,0 +1,266 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { CopyIcon, Edit3Icon, Trash2Icon } from "lucide-react";
4
+ import type React from "react";
5
+ import { useCallback } from "react";
6
+ import type { NodeApi } from "react-arborist";
7
+ import useEvent from "react-use-event-hook";
8
+ import { useImperativeModal } from "@/components/modal/ImperativeModal";
9
+ import { AlertDialogDestructiveAction } from "@/components/ui/alert-dialog";
10
+ import {
11
+ DropdownMenu,
12
+ DropdownMenuContent,
13
+ DropdownMenuItem,
14
+ DropdownMenuTrigger,
15
+ } from "@/components/ui/dropdown-menu";
16
+ import { useRequestClient } from "@/core/network/requests";
17
+ import type { FileInfo } from "@/core/network/types";
18
+ import {
19
+ makeDuplicateName,
20
+ resolvePaths,
21
+ toAbsolutePath,
22
+ } from "@/utils/pathUtils";
23
+ import {
24
+ type FileOperationResult,
25
+ handleFileResponse,
26
+ } from "./requesting-tree";
27
+ import { MENU_ITEM_ICON_CLASS, MoreActionsButton } from "./tree-actions";
28
+
29
+ /**
30
+ * Hook that exposes rename / duplicate / delete operations against absolute
31
+ * paths, handling path resolution (via `resolvePaths`) and error toasting.
32
+ *
33
+ * `root` is the workspace / tree root used to turn relative node paths into
34
+ * absolute paths for the API. Pass `""` if all node paths are already
35
+ * absolute (in which case `resolvePaths` is a no-op transformation).
36
+ */
37
+ export function useFileOperations({ root }: { root: string }) {
38
+ const {
39
+ sendRenameFileOrFolder,
40
+ sendCopyFileOrFolder,
41
+ sendDeleteFileOrFolder,
42
+ } = useRequestClient();
43
+
44
+ const renameFile = useCallback(
45
+ async (
46
+ file: Pick<FileInfo, "path">,
47
+ newName: string,
48
+ ): Promise<FileOperationResult> => {
49
+ const { path, newPath } = resolvePaths({
50
+ path: file.path,
51
+ name: newName,
52
+ root,
53
+ });
54
+ const resp = await sendRenameFileOrFolder({ path, newPath });
55
+ return handleFileResponse(resp);
56
+ },
57
+ [root, sendRenameFileOrFolder],
58
+ );
59
+
60
+ const duplicateFile = useCallback(
61
+ async (
62
+ file: Pick<FileInfo, "path" | "name">,
63
+ ): Promise<FileOperationResult> => {
64
+ const { path, newPath } = resolvePaths({
65
+ path: file.path,
66
+ name: makeDuplicateName(file.name),
67
+ root,
68
+ });
69
+ const resp = await sendCopyFileOrFolder({ path, newPath });
70
+ return handleFileResponse(resp);
71
+ },
72
+ [root, sendCopyFileOrFolder],
73
+ );
74
+
75
+ const deleteFile = useCallback(
76
+ async (file: Pick<FileInfo, "path">): Promise<FileOperationResult> => {
77
+ const resp = await sendDeleteFileOrFolder({
78
+ path: toAbsolutePath(file.path, root),
79
+ });
80
+ return handleFileResponse(resp);
81
+ },
82
+ [root, sendDeleteFileOrFolder],
83
+ );
84
+
85
+ return { renameFile, duplicateFile, deleteFile };
86
+ }
87
+
88
+ export type FileItemKind = "file" | "folder" | "notebook";
89
+
90
+ const DELETE_TITLE_BY_KIND: Record<FileItemKind, string> = {
91
+ file: "Delete file",
92
+ folder: "Delete folder",
93
+ notebook: "Delete notebook",
94
+ };
95
+
96
+ export function useConfirmDeleteFile() {
97
+ const { openConfirm } = useImperativeModal();
98
+
99
+ return useCallback(
100
+ (
101
+ target: { name: string; kind?: FileItemKind },
102
+ onConfirm: () => void | Promise<void>,
103
+ ) => {
104
+ const kind = target.kind ?? "file";
105
+ openConfirm({
106
+ title: DELETE_TITLE_BY_KIND[kind],
107
+ description: `Are you sure you want to delete ${target.name}?`,
108
+ confirmAction: (
109
+ <AlertDialogDestructiveAction
110
+ onClick={async () => {
111
+ await onConfirm();
112
+ }}
113
+ aria-label="Confirm"
114
+ >
115
+ Delete
116
+ </AlertDialogDestructiveAction>
117
+ ),
118
+ });
119
+ },
120
+ [openConfirm],
121
+ );
122
+ }
123
+
124
+ /**
125
+ * High-level handlers for rename/duplicate/delete against a single node
126
+ *
127
+ * All successful operations fire `onAfterChange` so callers can refresh their
128
+ * data sources (workspace tree + recent notebooks on the homepage).
129
+ */
130
+ export function useNotebookFileActions({
131
+ node,
132
+ root,
133
+ onAfterChange,
134
+ }: {
135
+ node: NodeApi<FileInfo>;
136
+ root: string;
137
+ onAfterChange?: () => void;
138
+ }) {
139
+ const { duplicateFile, deleteFile } = useFileOperations({ root });
140
+ const confirmDelete = useConfirmDeleteFile();
141
+
142
+ const handleRename = useEvent(() => {
143
+ node.edit();
144
+ });
145
+
146
+ const handleDuplicate = useEvent(async () => {
147
+ const result = await duplicateFile(node.data);
148
+ if (result) {
149
+ onAfterChange?.();
150
+ }
151
+ });
152
+
153
+ const handleDelete = useEvent(() => {
154
+ const kind: FileItemKind = node.data.isDirectory
155
+ ? "folder"
156
+ : node.data.isMarimoFile
157
+ ? "notebook"
158
+ : "file";
159
+ confirmDelete({ name: node.data.name, kind }, async () => {
160
+ const result = await deleteFile(node.data);
161
+ if (result) {
162
+ onAfterChange?.();
163
+ }
164
+ });
165
+ });
166
+
167
+ return { handleRename, handleDuplicate, handleDelete };
168
+ }
169
+
170
+ export const FileActionsDropdown = ({
171
+ testId,
172
+ buttonClassName,
173
+ iconClassName,
174
+ contentClassName,
175
+ preventDefaultOnTrigger = false,
176
+ children,
177
+ }: {
178
+ testId?: string;
179
+ buttonClassName?: string;
180
+ iconClassName?: string;
181
+ contentClassName?: string;
182
+ /**
183
+ * When true, the trigger also calls `preventDefault()` on click — needed
184
+ * when the dropdown is nested inside an `<a>` or other element whose
185
+ * default click behavior (navigation, submit, etc.) should be suppressed.
186
+ */
187
+ preventDefaultOnTrigger?: boolean;
188
+ children: React.ReactNode;
189
+ }) => (
190
+ <DropdownMenu modal={false}>
191
+ <DropdownMenuTrigger
192
+ asChild={true}
193
+ tabIndex={-1}
194
+ onClick={(e) => {
195
+ e.stopPropagation();
196
+ if (preventDefaultOnTrigger) {
197
+ e.preventDefault();
198
+ }
199
+ }}
200
+ >
201
+ <MoreActionsButton
202
+ data-testid={testId}
203
+ className={buttonClassName}
204
+ iconClassName={iconClassName}
205
+ />
206
+ </DropdownMenuTrigger>
207
+ <DropdownMenuContent
208
+ align="end"
209
+ className={contentClassName ?? "print:hidden w-[220px]"}
210
+ onClick={(e) => e.stopPropagation()}
211
+ onCloseAutoFocus={(e) => e.preventDefault()}
212
+ >
213
+ {children}
214
+ </DropdownMenuContent>
215
+ </DropdownMenu>
216
+ );
217
+
218
+ export const RenameMenuItem = ({
219
+ onSelect,
220
+ disabled,
221
+ title,
222
+ }: {
223
+ onSelect: (evt: Event) => void;
224
+ disabled?: boolean;
225
+ title?: string;
226
+ }) => (
227
+ <DropdownMenuItem onSelect={onSelect} disabled={disabled} title={title}>
228
+ <Edit3Icon className={MENU_ITEM_ICON_CLASS} />
229
+ Rename
230
+ </DropdownMenuItem>
231
+ );
232
+
233
+ export const DuplicateMenuItem = ({
234
+ onSelect,
235
+ disabled,
236
+ title,
237
+ }: {
238
+ onSelect: (evt: Event) => void;
239
+ disabled?: boolean;
240
+ title?: string;
241
+ }) => (
242
+ <DropdownMenuItem onSelect={onSelect} disabled={disabled} title={title}>
243
+ <CopyIcon className={MENU_ITEM_ICON_CLASS} />
244
+ Duplicate
245
+ </DropdownMenuItem>
246
+ );
247
+
248
+ export const DeleteMenuItem = ({
249
+ onSelect,
250
+ disabled,
251
+ title,
252
+ }: {
253
+ onSelect: (evt: Event) => void;
254
+ disabled?: boolean;
255
+ title?: string;
256
+ }) => (
257
+ <DropdownMenuItem
258
+ onSelect={onSelect}
259
+ variant="danger"
260
+ disabled={disabled}
261
+ title={title}
262
+ >
263
+ <Trash2Icon className={MENU_ITEM_ICON_CLASS} />
264
+ Delete
265
+ </DropdownMenuItem>
266
+ );
@@ -10,6 +10,26 @@ import type {
10
10
  import { prettyError } from "@/utils/errors";
11
11
  import { Functions } from "@/utils/functions";
12
12
  import { type FilePath, PathBuilder } from "@/utils/paths";
13
+ import { resolvePaths } from "@/utils/pathUtils";
14
+
15
+ /**
16
+ * Normalized result of a file mutation: the server response when successful,
17
+ * `null` when the server rejected the request and a toast was surfaced.
18
+ */
19
+ export type FileOperationResult = FileUpdateResponse | null;
20
+
21
+ export function handleFileResponse(
22
+ response: FileUpdateResponse,
23
+ ): FileOperationResult {
24
+ if (!response.success) {
25
+ toast({
26
+ title: "Failed",
27
+ description: response.message,
28
+ });
29
+ return null;
30
+ }
31
+ return response;
32
+ }
13
33
 
14
34
  export class RequestingTree {
15
35
  private delegate = new SimpleTree<FileInfo>([]);
@@ -85,15 +105,15 @@ export class RequestingTree {
85
105
  });
86
106
  return;
87
107
  }
88
- const currentPath = node.data.path as FilePath;
89
- const parentPath = this.path.dirname(currentPath);
90
- const newPath = this.path.join(parentPath, newName);
108
+ const { path, newPath } = resolvePaths({
109
+ path: node.data.path,
110
+ name: newName,
111
+ root: this.rootPath,
112
+ });
113
+ const parentPath = this.path.dirname(path);
91
114
  const newFile = await this.callbacks
92
- .copyFileOrFolder({
93
- path: currentPath,
94
- newPath: newPath,
95
- })
96
- .then(this.handleResponse);
115
+ .copyFileOrFolder({ path, newPath })
116
+ .then(handleFileResponse);
97
117
  if (!newFile?.info) {
98
118
  return;
99
119
  }
@@ -116,14 +136,18 @@ export class RequestingTree {
116
136
  });
117
137
  return;
118
138
  }
119
- const currentPath = node.data.path as FilePath;
120
- const newPath = this.path.join(this.path.dirname(currentPath), name);
121
- await this.callbacks
122
- .renameFileOrFolder({
123
- path: currentPath,
124
- newPath: newPath,
125
- })
126
- .then(this.handleResponse);
139
+ const { path, newPath } = resolvePaths({
140
+ path: node.data.path,
141
+ name,
142
+ root: this.rootPath,
143
+ });
144
+ const result = await this.callbacks
145
+ .renameFileOrFolder({ path, newPath })
146
+ .then(handleFileResponse);
147
+ if (!result) {
148
+ return;
149
+ }
150
+
127
151
  this.delegate.update({ id, changes: { name, path: newPath } });
128
152
  this.onChange(this.delegate.data);
129
153
  // Rename all of its children
@@ -136,23 +160,25 @@ export class RequestingTree {
136
160
  : this.rootPath;
137
161
 
138
162
  await Promise.all(
139
- fromIds.map((id) => {
140
- this.delegate.move({ id, parentId, index: 0 });
163
+ fromIds.map(async (id) => {
141
164
  const node = this.delegate.find(id);
142
165
  if (!node) {
143
- return Promise.resolve();
166
+ return;
144
167
  }
168
+ const originalPath = node.data.path;
145
169
  const newPath = this.path.join(
146
170
  parentPath,
147
- this.path.basename(node.data.path as FilePath),
171
+ this.path.basename(originalPath as FilePath),
148
172
  );
173
+ const result = await this.callbacks
174
+ .renameFileOrFolder({ path: originalPath, newPath })
175
+ .then(handleFileResponse);
176
+ if (!result) {
177
+ return;
178
+ }
179
+
180
+ this.delegate.move({ id, parentId, index: 0 });
149
181
  this.delegate.update({ id, changes: { path: newPath } });
150
- return this.callbacks
151
- .renameFileOrFolder({
152
- path: node.data.path,
153
- newPath: newPath,
154
- })
155
- .then(this.handleResponse);
156
182
  }),
157
183
  );
158
184
 
@@ -162,17 +188,21 @@ export class RequestingTree {
162
188
  await this.refreshAll([parentPath]);
163
189
  }
164
190
 
165
- async createFile(
166
- name: string,
167
- parentId: string | null,
168
- type: "file" | "notebook" = "file",
169
- ): Promise<void> {
191
+ async createFile({
192
+ name,
193
+ parentId,
194
+ type = "file",
195
+ }: {
196
+ name: string;
197
+ parentId: string | null;
198
+ type?: "file" | "notebook";
199
+ }): Promise<void> {
170
200
  const parentPath = parentId
171
201
  ? (this.delegate.find(parentId)?.data.path ?? parentId)
172
202
  : this.rootPath;
173
203
  const newFile = await this.callbacks
174
204
  .createFileOrFolder({ path: parentPath, type: type, name: name })
175
- .then(this.handleResponse);
205
+ .then(handleFileResponse);
176
206
  if (!newFile?.info) {
177
207
  return;
178
208
  }
@@ -192,7 +222,7 @@ export class RequestingTree {
192
222
  : this.rootPath;
193
223
  const newFolder = await this.callbacks
194
224
  .createFileOrFolder({ path: parentPath, type: "directory", name: name })
195
- .then(this.handleResponse);
225
+ .then(handleFileResponse);
196
226
  if (!newFolder?.info) {
197
227
  return;
198
228
  }
@@ -216,9 +246,12 @@ export class RequestingTree {
216
246
  return;
217
247
  }
218
248
 
219
- await this.callbacks
249
+ const result = await this.callbacks
220
250
  .deleteFileOrFolder({ path: node.data.path })
221
- .then(this.handleResponse);
251
+ .then(handleFileResponse);
252
+ if (!result) {
253
+ return;
254
+ }
222
255
  this.delegate.drop({ id });
223
256
  this.onChange(this.delegate.data);
224
257
  }
@@ -262,18 +295,4 @@ export class RequestingTree {
262
295
  }
263
296
  return path;
264
297
  };
265
-
266
- private handleResponse = (
267
- response: FileUpdateResponse,
268
- ): FileUpdateResponse | null => {
269
- if (!response.success) {
270
- toast({
271
- title: "Failed",
272
- description: response.message,
273
- });
274
- return null;
275
- }
276
-
277
- return response;
278
- };
279
298
  }
@@ -15,7 +15,19 @@ export const RunningNotebooksContext = React.createContext<{
15
15
  runningNotebooks: new Map(),
16
16
  setRunningNotebooks: Functions.NOOP,
17
17
  });
18
- export const WorkspaceRootContext = React.createContext<string>("");
18
+
19
+ /**
20
+ * Context providing the workspace root plus a `refreshWorkspace` hook used by
21
+ * file actions (rename/duplicate/delete) so they can invalidate both the
22
+ * workspace tree and any sibling views (e.g. recent notebooks) in one call.
23
+ */
24
+ export const WorkspaceContext = React.createContext<{
25
+ root: string;
26
+ refreshWorkspace: () => void;
27
+ }>({
28
+ root: "",
29
+ refreshWorkspace: Functions.NOOP,
30
+ });
19
31
 
20
32
  export const includeMarkdownAtom = atomWithStorage<boolean>(
21
33
  "marimo:home:include-markdown",