Skip to content

Commit

Permalink
feat: Create notebook copies (#2227)
Browse files Browse the repository at this point in the history
* wip: Create a notebook copy

* test: Add unit test

* fix: Revert home page, create notebook action

* chore: Lint, typecheck, format

* fix: Open new notebook copy after create

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* fix: Error handling

* fix: Typecheck

* fix: Make change requests

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
  • Loading branch information
wasimsandhu and pre-commit-ci[bot] authored Sep 5, 2024
1 parent e2e7796 commit 4d6b93f
Show file tree
Hide file tree
Showing 18 changed files with 215 additions and 2 deletions.
38 changes: 38 additions & 0 deletions frontend/src/components/editor/actions/useCopyNotebook.tsx
Original file line number Diff line number Diff line change
@@ -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");
});
},
});
};
}
10 changes: 10 additions & 0 deletions frontend/src/components/editor/actions/useNotebookActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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();
Expand All @@ -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);

Expand Down Expand Up @@ -274,6 +278,12 @@ export function useNotebookActions() {
],
},

{
icon: <Files size={14} strokeWidth={1.5} />,
label: "Create notebook copy",
hidden: !filename || isWasm(),
handle: copyNotebook,
},
{
icon: <ClipboardCopyIcon size={14} strokeWidth={1.5} />,
label: "Copy code to clipboard",
Expand Down
5 changes: 4 additions & 1 deletion frontend/src/components/modal/ImperativeModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -124,6 +126,7 @@ export function useImperativeModal() {
{opts.description}
</AlertDialogDescription>
<Input
spellCheck={opts.spellCheck}
defaultValue={opts.defaultValue}
className="my-4 h-8"
name="prompt"
Expand All @@ -136,7 +139,7 @@ export function useImperativeModal() {
<AlertDialogCancel onClick={closeModal}>
Cancel
</AlertDialogCancel>
<Button type="submit">Ok</Button>
<Button type="submit">{opts.confirmText ?? "Ok"}</Button>
</AlertDialogFooter>
</form>
</AlertDialogContent>
Expand Down
1 change: 1 addition & 0 deletions frontend/src/core/islands/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ export class IslandsPyodideBridge implements RunRequests, EditRequests {
getUsageStats = throwNotImplemented;
sendRename = throwNotImplemented;
sendSave = throwNotImplemented;
sendCopy = throwNotImplemented;
sendRunScratchpad = throwNotImplemented;
sendStdin = throwNotImplemented;
sendInterrupt = throwNotImplemented;
Expand Down
8 changes: 8 additions & 0 deletions frontend/src/core/network/requests-network.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", {
Expand Down
1 change: 1 addition & 0 deletions frontend/src/core/network/requests-static.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export function createStaticRequests(): EditRequests & RunRequests {
sendRunScratchpad: throwNotInEditMode,
sendRename: throwNotInEditMode,
sendSave: throwNotInEditMode,
sendCopy: throwNotInEditMode,
sendInterrupt: throwNotInEditMode,
sendShutdown: throwNotInEditMode,
sendFormat: throwNotInEditMode,
Expand Down
1 change: 1 addition & 0 deletions frontend/src/core/network/requests-toasting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions frontend/src/core/network/requests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export const {
sendRestart,
syncCellIds,
sendSave,
sendCopy,
sendStdin,
sendFormat,
sendInterrupt,
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/core/network/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -95,6 +96,7 @@ export interface RunRequests {
export interface EditRequests {
sendRename: (request: RenameFileRequest) => Promise<null>;
sendSave: (request: SaveNotebookRequest) => Promise<null>;
sendCopy: (request: CopyNotebookRequest) => Promise<null>;
sendStdin: (request: StdinRequest) => Promise<null>;
sendRun: (request: RunRequest) => Promise<null>;
sendRunScratchpad: (request: RunScratchpadRequest) => Promise<null>;
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/core/wasm/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/core/wasm/worker/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import type { PyodideInterface } from "pyodide";
import type {
CodeCompletionRequest,
CopyNotebookRequest,
ExportAsHTMLRequest,
FileCreateRequest,
FileCreateResponse,
Expand Down Expand Up @@ -66,6 +67,7 @@ export interface RawBridge {
read_snippets(): Promise<Snippets>;
format(request: FormatRequest): Promise<FormatResponse>;
save(request: SaveNotebookRequest): Promise<string>;
copy(request: CopyNotebookRequest): Promise<string>;
save_app_config(request: SaveAppConfigurationRequest): Promise<string>;
save_user_config(request: SaveUserConfigurationRequest): Promise<null>;
rename_file(request: string): Promise<string>;
Expand Down
1 change: 1 addition & 0 deletions marimo/_cli/development/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ def _generate_schema() -> dict[str, Any]:
models.RunScratchpadRequest,
models.SaveAppConfigurationRequest,
models.SaveNotebookRequest,
models.CopyNotebookRequest,
models.SaveUserConfigurationRequest,
models.StdinRequest,
models.SuccessResponse,
Expand Down
29 changes: 29 additions & 0 deletions marimo/_server/api/endpoints/files.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from marimo._server.api.utils import parse_request
from marimo._server.models.models import (
BaseResponse,
CopyNotebookRequest,
OpenFileRequest,
ReadCodeResponse,
RenameFileRequest,
Expand Down Expand Up @@ -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(
Expand Down
11 changes: 10 additions & 1 deletion marimo/_server/file_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import os
import pathlib
import shutil
from typing import Any, Dict, Optional

from marimo import _loggers
Expand All @@ -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()
Expand Down Expand Up @@ -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(
Expand Down
20 changes: 20 additions & 0 deletions marimo/_server/models/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions openapi/api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
43 changes: 43 additions & 0 deletions openapi/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -2367,6 +2406,10 @@ export interface components {
names: string[];
persist: boolean;
};
CopyNotebookRequest: {
source: string;
destination: string;
};
SaveUserConfigurationRequest: {
config: components["schemas"]["MarimoConfig"];
};
Expand Down
Loading

0 comments on commit 4d6b93f

Please sign in to comment.