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