Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Create notebook copies #2227

Merged
merged 11 commits into from
Sep 5, 2024
50 changes: 50 additions & 0 deletions frontend/src/components/editor/actions/useCopyNotebook.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/* 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 = new PathBuilder("/");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.",
});
const notebookCopy = window.location.href.replace(
filename,
destination,
);
window.open(notebookCopy);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i would just directly do:

window.open(`/?file=${destination}`, "_blank");

})
.catch((error) => {
toast({
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think we already toast from the request wrapper in requests-toasting.tsx

title: "Failed to copy notebook",
description: error.detail,
variant: "danger",
});
});
},
});
};
}
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
Loading