diff --git a/frontend/src/components/editor/actions/useCopyNotebook.tsx b/frontend/src/components/editor/actions/useCopyNotebook.tsx
new file mode 100644
index 00000000000..949f37b4f11
--- /dev/null
+++ b/frontend/src/components/editor/actions/useCopyNotebook.tsx
@@ -0,0 +1,38 @@
+/* Copyright 2024 Marimo. All rights reserved. */
+import { useImperativeModal } from "@/components/modal/ImperativeModal";
+import { toast } from "@/components/ui/use-toast";
+import { sendCopy } from "@/core/network/requests";
+import { PathBuilder, Paths } from "@/utils/paths";
+
+export function useCopyNotebook(source: string | null) {
+ const { openPrompt, closeModal } = useImperativeModal();
+
+ return () => {
+ if (!source) {
+ return null;
+ }
+ const pathBuilder = PathBuilder.guessDeliminator(source);
+ const filename = Paths.basename(source);
+
+ openPrompt({
+ title: "Copy notebook",
+ description: "Enter a new filename for the notebook copy.",
+ defaultValue: `_${filename}`,
+ confirmText: "Copy notebook",
+ spellCheck: false,
+ onConfirm: (destination: string) => {
+ sendCopy({
+ source: source,
+ destination: pathBuilder.join(Paths.dirname(source), destination),
+ }).then(() => {
+ closeModal();
+ toast({
+ title: "Notebook copied",
+ description: "A copy of the notebook has been created.",
+ });
+ window.open(`/?file=${destination}`, "_blank");
+ });
+ },
+ });
+ };
+}
diff --git a/frontend/src/components/editor/actions/useNotebookActions.tsx b/frontend/src/components/editor/actions/useNotebookActions.tsx
index 0ab296d0125..b22ea547863 100644
--- a/frontend/src/components/editor/actions/useNotebookActions.tsx
+++ b/frontend/src/components/editor/actions/useNotebookActions.tsx
@@ -29,6 +29,7 @@ import {
PresentationIcon,
EditIcon,
LayoutTemplateIcon,
+ Files,
} from "lucide-react";
import { commandPaletteAtom } from "../controls/command-palette";
import { useCellActions, useNotebook } from "@/core/cells/cells";
@@ -61,6 +62,8 @@ import { LAYOUT_TYPES } from "../renderers/types";
import { displayLayoutName, getLayoutIcon } from "../renderers/layout-select";
import { useLayoutState, useLayoutActions } from "@/core/layout/layout";
import { useTogglePresenting } from "@/core/layout/useTogglePresenting";
+import { useCopyNotebook } from "./useCopyNotebook";
+import { isWasm } from "@/core/wasm/utils";
const NOOP_HANDLER = (event?: Event) => {
event?.preventDefault();
@@ -78,6 +81,7 @@ export function useNotebookActions() {
const notebook = useNotebook();
const { updateCellConfig, undoDeleteCell } = useCellActions();
const restartKernel = useRestartKernel();
+ const copyNotebook = useCopyNotebook(filename);
const setCommandPaletteOpen = useSetAtom(commandPaletteAtom);
const setKeyboardShortcutsOpen = useSetAtom(keyboardShortcutsAtom);
@@ -274,6 +278,12 @@ export function useNotebookActions() {
],
},
+ {
+ icon: ,
+ label: "Create notebook copy",
+ hidden: !filename || isWasm(),
+ handle: copyNotebook,
+ },
{
icon: ,
label: "Copy code to clipboard",
diff --git a/frontend/src/components/modal/ImperativeModal.tsx b/frontend/src/components/modal/ImperativeModal.tsx
index 4f075a5f656..8919728ed07 100644
--- a/frontend/src/components/modal/ImperativeModal.tsx
+++ b/frontend/src/components/modal/ImperativeModal.tsx
@@ -99,6 +99,8 @@ export function useImperativeModal() {
title: React.ReactNode;
description?: React.ReactNode;
defaultValue?: string;
+ spellCheck?: boolean;
+ confirmText?: string;
onConfirm: (value: string) => void;
}) => {
context.setModal(
@@ -124,6 +126,7 @@ export function useImperativeModal() {
{opts.description}
Cancel
-
+
diff --git a/frontend/src/core/islands/bridge.ts b/frontend/src/core/islands/bridge.ts
index d3bfe1f894f..7c970552307 100644
--- a/frontend/src/core/islands/bridge.ts
+++ b/frontend/src/core/islands/bridge.ts
@@ -125,6 +125,7 @@ export class IslandsPyodideBridge implements RunRequests, EditRequests {
getUsageStats = throwNotImplemented;
sendRename = throwNotImplemented;
sendSave = throwNotImplemented;
+ sendCopy = throwNotImplemented;
sendRunScratchpad = throwNotImplemented;
sendStdin = throwNotImplemented;
sendInterrupt = throwNotImplemented;
diff --git a/frontend/src/core/network/requests-network.ts b/frontend/src/core/network/requests-network.ts
index b1cfa3bb9bd..629a7ee32c5 100644
--- a/frontend/src/core/network/requests-network.ts
+++ b/frontend/src/core/network/requests-network.ts
@@ -40,6 +40,14 @@ export function createNetworkRequests(): EditRequests & RunRequests {
})
.then(handleResponseReturnNull);
},
+ sendCopy: (request) => {
+ return marimoClient
+ .POST("/api/kernel/copy", {
+ body: request,
+ parseAs: "text",
+ })
+ .then(handleResponseReturnNull);
+ },
sendFormat: (request) => {
return marimoClient
.POST("/api/kernel/format", {
diff --git a/frontend/src/core/network/requests-static.ts b/frontend/src/core/network/requests-static.ts
index 1aaf22b20fa..7728c10d76e 100644
--- a/frontend/src/core/network/requests-static.ts
+++ b/frontend/src/core/network/requests-static.ts
@@ -37,6 +37,7 @@ export function createStaticRequests(): EditRequests & RunRequests {
sendRunScratchpad: throwNotInEditMode,
sendRename: throwNotInEditMode,
sendSave: throwNotInEditMode,
+ sendCopy: throwNotInEditMode,
sendInterrupt: throwNotInEditMode,
sendShutdown: throwNotInEditMode,
sendFormat: throwNotInEditMode,
diff --git a/frontend/src/core/network/requests-toasting.ts b/frontend/src/core/network/requests-toasting.ts
index 035633b4dfc..b9f0540ab74 100644
--- a/frontend/src/core/network/requests-toasting.ts
+++ b/frontend/src/core/network/requests-toasting.ts
@@ -18,6 +18,7 @@ export function createErrorToastingRequests(
sendRunScratchpad: "Failed to run scratchpad",
sendRename: "Failed to rename",
sendSave: "Failed to save",
+ sendCopy: "Failed to copy",
sendInterrupt: "Failed to interrupt",
sendShutdown: "Failed to shutdown",
sendFormat: "Failed to format",
diff --git a/frontend/src/core/network/requests.ts b/frontend/src/core/network/requests.ts
index 06d7bce9dc7..1a0addf04b0 100644
--- a/frontend/src/core/network/requests.ts
+++ b/frontend/src/core/network/requests.ts
@@ -31,6 +31,7 @@ export const {
sendRestart,
syncCellIds,
sendSave,
+ sendCopy,
sendStdin,
sendFormat,
sendInterrupt,
diff --git a/frontend/src/core/network/types.ts b/frontend/src/core/network/types.ts
index 9625c47c86b..d82ef1b4a1b 100644
--- a/frontend/src/core/network/types.ts
+++ b/frontend/src/core/network/types.ts
@@ -56,6 +56,7 @@ export type RunScratchpadRequest = schemas["RunScratchpadRequest"];
export type SaveAppConfigurationRequest =
schemas["SaveAppConfigurationRequest"];
export type SaveNotebookRequest = schemas["SaveNotebookRequest"];
+export type CopyNotebookRequest = schemas["CopyNotebookRequest"];
export type SaveUserConfigurationRequest =
schemas["SaveUserConfigurationRequest"];
export interface SetCellConfigRequest {
@@ -95,6 +96,7 @@ export interface RunRequests {
export interface EditRequests {
sendRename: (request: RenameFileRequest) => Promise;
sendSave: (request: SaveNotebookRequest) => Promise;
+ sendCopy: (request: CopyNotebookRequest) => Promise;
sendStdin: (request: StdinRequest) => Promise;
sendRun: (request: RunRequest) => Promise;
sendRunScratchpad: (request: RunScratchpadRequest) => Promise;
diff --git a/frontend/src/core/wasm/bridge.ts b/frontend/src/core/wasm/bridge.ts
index 6e6cd1ce448..01ed06d1f10 100644
--- a/frontend/src/core/wasm/bridge.ts
+++ b/frontend/src/core/wasm/bridge.ts
@@ -187,6 +187,10 @@ export class PyodideBridge implements RunRequests, EditRequests {
return null;
};
+ sendCopy: EditRequests["sendCopy"] = async () => {
+ throwNotImplemented();
+ };
+
sendStdin: EditRequests["sendStdin"] = async (request) => {
await this.rpc.proxy.request.bridge({
functionName: "put_input",
diff --git a/frontend/src/core/wasm/worker/types.ts b/frontend/src/core/wasm/worker/types.ts
index 34054fa0c36..3364ce192d5 100644
--- a/frontend/src/core/wasm/worker/types.ts
+++ b/frontend/src/core/wasm/worker/types.ts
@@ -2,6 +2,7 @@
import type { PyodideInterface } from "pyodide";
import type {
CodeCompletionRequest,
+ CopyNotebookRequest,
ExportAsHTMLRequest,
FileCreateRequest,
FileCreateResponse,
@@ -66,6 +67,7 @@ export interface RawBridge {
read_snippets(): Promise;
format(request: FormatRequest): Promise;
save(request: SaveNotebookRequest): Promise;
+ copy(request: CopyNotebookRequest): Promise;
save_app_config(request: SaveAppConfigurationRequest): Promise;
save_user_config(request: SaveUserConfigurationRequest): Promise;
rename_file(request: string): Promise;
diff --git a/marimo/_cli/development/commands.py b/marimo/_cli/development/commands.py
index 9169d61fefc..4e818bc3fd8 100644
--- a/marimo/_cli/development/commands.py
+++ b/marimo/_cli/development/commands.py
@@ -149,6 +149,7 @@ def _generate_schema() -> dict[str, Any]:
models.RunScratchpadRequest,
models.SaveAppConfigurationRequest,
models.SaveNotebookRequest,
+ models.CopyNotebookRequest,
models.SaveUserConfigurationRequest,
models.StdinRequest,
models.SuccessResponse,
diff --git a/marimo/_server/api/endpoints/files.py b/marimo/_server/api/endpoints/files.py
index fc14e526604..5574521e966 100644
--- a/marimo/_server/api/endpoints/files.py
+++ b/marimo/_server/api/endpoints/files.py
@@ -15,6 +15,7 @@
from marimo._server.api.utils import parse_request
from marimo._server.models.models import (
BaseResponse,
+ CopyNotebookRequest,
OpenFileRequest,
ReadCodeResponse,
RenameFileRequest,
@@ -182,6 +183,34 @@ async def save(
return PlainTextResponse(content=contents)
+@router.post("/copy")
+@requires("edit")
+async def copy(
+ *,
+ request: Request,
+) -> PlainTextResponse:
+ """
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/CopyNotebookRequest"
+ responses:
+ 200:
+ description: Copy notebook
+ content:
+ text/plain:
+ schema:
+ type: string
+ """
+ app_state = AppState(request)
+ body = await parse_request(request, cls=CopyNotebookRequest)
+ session = app_state.require_current_session()
+ contents = session.app_file_manager.copy(body)
+
+ return PlainTextResponse(content=contents)
+
+
@router.post("/save_app_config")
@requires("edit")
async def save_app_config(
diff --git a/marimo/_server/file_manager.py b/marimo/_server/file_manager.py
index d7fc6f212b7..61ceee281f7 100644
--- a/marimo/_server/file_manager.py
+++ b/marimo/_server/file_manager.py
@@ -3,6 +3,7 @@
import os
import pathlib
+import shutil
from typing import Any, Dict, Optional
from marimo import _loggers
@@ -16,7 +17,10 @@
save_layout_config,
)
from marimo._server.api.status import HTTPException, HTTPStatus
-from marimo._server.models.models import SaveNotebookRequest
+from marimo._server.models.models import (
+ CopyNotebookRequest,
+ SaveNotebookRequest,
+)
from marimo._server.utils import canonicalize_filename
LOGGER = _loggers.marimo_logger()
@@ -270,6 +274,11 @@ def save(self, request: SaveNotebookRequest) -> str:
persist=request.persist,
)
+ def copy(self, request: CopyNotebookRequest) -> str:
+ source, destination = request.source, request.destination
+ shutil.copy(source, destination)
+ return os.path.basename(destination)
+
def to_code(self) -> str:
"""Read the contents of the unsaved file."""
contents = codegen.generate_filecontents(
diff --git a/marimo/_server/models/models.py b/marimo/_server/models/models.py
index 1ff4af47861..12d212dc7df 100644
--- a/marimo/_server/models/models.py
+++ b/marimo/_server/models/models.py
@@ -132,6 +132,26 @@ def __post_init__(self) -> None:
), "Mismatched cell_ids and configs"
+@dataclass
+class CopyNotebookRequest:
+ # path to app
+ source: str
+ destination: str
+
+ # Validate filenames are valid, and destination path does not already exist
+ def __post_init__(self) -> None:
+ destination = os.path.basename(self.destination)
+ assert self.source is not None
+ assert self.destination is not None
+ assert os.path.exists(self.source), (
+ f'File "{self.source}" does not exist.'
+ + "Please save the notebook and try again."
+ )
+ assert not os.path.exists(
+ self.destination
+ ), f'File "{destination}" already exists in this directory.'
+
+
@dataclass
class SaveAppConfigurationRequest:
# partial app config
diff --git a/openapi/api.yaml b/openapi/api.yaml
index 1e5a19d544a..f3b644d04f3 100644
--- a/openapi/api.yaml
+++ b/openapi/api.yaml
@@ -2241,6 +2241,20 @@ paths:
schema:
type: string
description: Save the current app
+ /api/kernel/copy:
+ post:
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/CopyNotebookRequest'
+ responses:
+ 200:
+ content:
+ text/plain:
+ schema:
+ type: string
+ description: Copy notebook
/api/kernel/save_app_config:
post:
requestBody:
diff --git a/openapi/src/api.ts b/openapi/src/api.ts
index 95c27d89945..3bd54c5777c 100644
--- a/openapi/src/api.ts
+++ b/openapi/src/api.ts
@@ -1233,6 +1233,45 @@ export interface paths {
patch?: never;
trace?: never;
};
+ "/api/kernel/copy": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ post: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: {
+ content: {
+ "application/json": components["schemas"]["CopyNotebookRequest"];
+ };
+ };
+ responses: {
+ /** @description Save the app as a new file */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "text/plain": string;
+ };
+ };
+ };
+ };
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
"/api/kernel/save_app_config": {
parameters: {
query?: never;
@@ -2367,6 +2406,10 @@ export interface components {
names: string[];
persist: boolean;
};
+ CopyNotebookRequest: {
+ source: string;
+ destination: string;
+ };
SaveUserConfigurationRequest: {
config: components["schemas"]["MarimoConfig"];
};
diff --git a/tests/_server/api/endpoints/test_files.py b/tests/_server/api/endpoints/test_files.py
index 00cfaef0272..c5c374aecc1 100644
--- a/tests/_server/api/endpoints/test_files.py
+++ b/tests/_server/api/endpoints/test_files.py
@@ -232,6 +232,32 @@ def test_save_app_config(client: TestClient) -> None:
assert 'marimo.App(width="medium"' in file_contents
+@with_session(SESSION_ID)
+def test_copy_file(client: TestClient) -> None:
+ filename = get_session_manager(client).file_router.get_unique_file_key()
+ assert filename
+ assert os.path.exists(filename)
+ file_contents = open(filename).read()
+ assert "import marimo as mo" in file_contents
+ assert 'marimo.App(width="full"' in file_contents
+
+ filename_copy = f"_{os.path.basename(filename)}"
+ copied_file = os.path.join(os.path.dirname(filename), filename_copy)
+ response = client.post(
+ "/api/kernel/copy",
+ headers=HEADERS,
+ json={
+ "source": filename,
+ "destination": copied_file,
+ },
+ )
+ assert response.status_code == 200, response.text
+ assert filename_copy in response.text
+ file_contents = open(copied_file).read()
+ assert "import marimo as mo" in file_contents
+ assert 'marimo.App(width="full"' in file_contents
+
+
@with_websocket_session(SESSION_ID)
def test_rename_propagates(
client: TestClient, websocket: WebSocketTestSession