@marimo-team/islands 0.23.3-dev71 → 0.23.3-dev9
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.
- package/dist/{chat-ui-CTt4WX0V.js → chat-ui-BLFhPclV.js} +2 -2
- package/dist/{html-to-image-BdsDysfl.js → html-to-image-XYwXqg2E.js} +2107 -2107
- package/dist/main.js +6 -6
- package/dist/{process-output-COL2Pf5I.js → process-output-BDVjDpbu.js} +1 -1
- package/dist/{reveal-component-Cd5Y35Ny.js → reveal-component-CrnLosc4.js} +2 -2
- package/dist/{slide-BEerfanN.js → slide-Dl7Rf496.js} +1 -1
- package/package.json +2 -2
- package/src/components/editor/file-tree/__tests__/requesting-tree.test.ts +84 -2
- package/src/components/editor/file-tree/file-explorer.tsx +142 -203
- package/src/components/editor/file-tree/file-name-input.tsx +41 -0
- package/src/components/editor/file-tree/file-operations.tsx +266 -0
- package/src/components/editor/file-tree/requesting-tree.tsx +68 -49
- package/src/components/home/state.ts +13 -1
- package/src/components/pages/home-page.tsx +116 -10
- package/src/core/network/requests-network.ts +0 -3
- package/src/utils/__tests__/path.test.ts +20 -0
- package/src/utils/pathUtils.test.ts +141 -1
- package/src/utils/pathUtils.ts +46 -0
- package/src/utils/paths.ts +9 -1
|
@@ -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
|
-
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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(
|
|
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
|
-
|
|
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 {
|
|
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
|
+
});
|
package/src/utils/pathUtils.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/utils/paths.ts
CHANGED
|
@@ -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("/") ||
|
|
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) => {
|