Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions frontend/src/__mocks__/requests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export const MockRequestClient = {
.mockResolvedValue({ files: [], query: "", total_found: 0 }),
sendCreateFileOrFolder: vi.fn().mockResolvedValue({}),
sendDeleteFileOrFolder: vi.fn().mockResolvedValue({}),
sendCopyFileOrFolder: vi.fn().mockResolvedValue({}),
sendRenameFileOrFolder: vi.fn().mockResolvedValue({}),
sendUpdateFile: vi.fn().mockResolvedValue({}),
sendFileDetails: vi.fn().mockResolvedValue({}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { RequestingTree } from "../requesting-tree";
const sendListFiles = vi.fn();
const sendCreateFileOrFolder = vi.fn();
const sendDeleteFileOrFolder = vi.fn();
const sendCopyFileOrFolder = vi.fn();
const sendRenameFileOrFolder = vi.fn();

vi.mock("@/components/ui/use-toast", () => MockModules.toast());
Expand All @@ -21,6 +22,7 @@ describe("RequestingTree", () => {
listFiles: sendListFiles,
createFileOrFolder: sendCreateFileOrFolder,
deleteFileOrFolder: sendDeleteFileOrFolder,
copyFileOrFolder: sendCopyFileOrFolder,
renameFileOrFolder: sendRenameFileOrFolder,
});
sendListFiles.mockResolvedValue({
Expand Down Expand Up @@ -169,6 +171,17 @@ describe("RequestingTree", () => {
`);
});

test("copy should duplicate a file", async () => {
sendCopyFileOrFolder.mockResolvedValue({ success: true });

await requestingTree.copy("1.1", "file1_copy");
expect(sendCopyFileOrFolder).toHaveBeenCalledWith({
path: "/root/file1",
newPath: "/root/file1_copy",
});
expect(mockOnChange).toHaveBeenCalled();
Comment thread
daizutabi marked this conversation as resolved.
});

test("createFile should create a new file", async () => {
sendCreateFileOrFolder.mockResolvedValue({ success: true });

Expand Down Expand Up @@ -236,6 +249,7 @@ describe("RequestingTree", () => {
listFiles: sendListFiles,
createFileOrFolder: sendCreateFileOrFolder,
deleteFileOrFolder: sendDeleteFileOrFolder,
copyFileOrFolder: sendCopyFileOrFolder,
renameFileOrFolder: sendRenameFileOrFolder,
});
sendListFiles.mockRejectedValue(new Error("Network error"));
Expand Down Expand Up @@ -263,6 +277,23 @@ describe("RequestingTree", () => {
});
});

test("copy should handle API failure", async () => {
sendCopyFileOrFolder.mockResolvedValue({
success: false,
message: "Error duplicating",
});

await requestingTree.copy("1.1", "file1_copy");
expect(sendCopyFileOrFolder).toHaveBeenCalledWith({
path: "/root/file1",
newPath: "/root/file1_copy",
});
expect(toast).toHaveBeenCalledWith({
title: "Failed",
description: "Error duplicating",
});
});

test("move should handle missing parent node gracefully", async () => {
await requestingTree.move(["1.x"], "2");
expect(sendRenameFileOrFolder).not.toHaveBeenCalled();
Expand All @@ -284,6 +315,7 @@ describe("RequestingTree", () => {
listFiles: sendListFiles,
createFileOrFolder: sendCreateFileOrFolder,
deleteFileOrFolder: sendDeleteFileOrFolder,
copyFileOrFolder: sendCopyFileOrFolder,
renameFileOrFolder: sendRenameFileOrFolder,
});

Expand All @@ -305,6 +337,7 @@ describe("RequestingTree", () => {
listFiles: sendListFiles,
createFileOrFolder: sendCreateFileOrFolder,
deleteFileOrFolder: sendDeleteFileOrFolder,
copyFileOrFolder: sendCopyFileOrFolder,
renameFileOrFolder: sendRenameFileOrFolder,
});

Expand Down
40 changes: 7 additions & 33 deletions frontend/src/components/editor/file-tree/file-explorer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -419,8 +419,7 @@ const Edit = ({ node }: { node: NodeApi<FileInfo> }) => {
};

const Node = ({ node, style, dragHandle }: NodeRendererProps<FileInfo>) => {
const { openFile, sendCreateFileOrFolder, sendFileDetails } =
useRequestClient();
const { openFile, sendFileDetails } = useRequestClient();
const disableFileDownloads = useAtomValue(disableFileDownloadsAtom);

const fileType: FileIconType = node.data.isDirectory
Expand Down Expand Up @@ -502,37 +501,14 @@ const Node = ({ node, style, dragHandle }: NodeRendererProps<FileInfo>) => {
});

const handleDuplicate = useEvent(async () => {
if (!tree || node.data.isDirectory) {
if (!tree) {
return;
}

const [name, extension] = fileSplit(node.data.name);
const duplicateName = `${name}_copy${extension}`;

try {
// First get the file contents
const details = await sendFileDetails({ path: node.data.path });

// Get the parent directory path
const parentPath = node.parent?.data.path || "";

// Create the duplicate file by creating a new file with the same contents
await sendCreateFileOrFolder({
path: parentPath,
type: "file",
name: duplicateName,
contents: details.contents ? btoa(details.contents) : undefined,
});

// Refresh the parent folder to show the new file
await tree.refreshAll([parentPath]);
} catch {
toast({
title: "Failed to duplicate file",
description: "Unable to create a duplicate of the file",
variant: "danger",
});
}
await tree.copy(node.id, duplicateName);
});

const renderActions = () => {
Expand Down Expand Up @@ -581,12 +557,10 @@ const Node = ({ node, style, dragHandle }: NodeRendererProps<FileInfo>) => {
<Edit3Icon className={ic} />
Rename
</DropdownMenuItem>
{!node.data.isDirectory && (
<DropdownMenuItem onSelect={handleDuplicate}>
<CopyIcon className={ic} />
Duplicate
</DropdownMenuItem>
)}
<DropdownMenuItem onSelect={handleDuplicate}>
<CopyIcon className={ic} />
Duplicate
</DropdownMenuItem>
<DropdownMenuItem
onSelect={async () => {
await copyToClipboard(node.data.path);
Expand Down
41 changes: 41 additions & 0 deletions frontend/src/components/editor/file-tree/requesting-tree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,15 @@ export class RequestingTree {
listFiles: EditRequests["sendListFiles"];
createFileOrFolder: EditRequests["sendCreateFileOrFolder"];
deleteFileOrFolder: EditRequests["sendDeleteFileOrFolder"];
copyFileOrFolder: EditRequests["sendCopyFileOrFolder"];
renameFileOrFolder: EditRequests["sendRenameFileOrFolder"];
};

constructor(callbacks: {
listFiles: EditRequests["sendListFiles"];
createFileOrFolder: EditRequests["sendCreateFileOrFolder"];
deleteFileOrFolder: EditRequests["sendDeleteFileOrFolder"];
copyFileOrFolder: EditRequests["sendCopyFileOrFolder"];
renameFileOrFolder: EditRequests["sendRenameFileOrFolder"];
}) {
this.callbacks = callbacks;
Expand Down Expand Up @@ -74,9 +76,44 @@ export class RequestingTree {
return true;
}

async copy(id: string, newName: string): Promise<void> {
const node = this.delegate.find(id);
if (!node) {
toast({
title: "Failed",
description: `Node with id ${id} not found in the tree`,
});
return;
}
Comment thread
Light2Dark marked this conversation as resolved.
const currentPath = node.data.path as FilePath;
const parentPath = this.path.dirname(currentPath);
const newPath = this.path.join(parentPath, newName);
const newFile = await this.callbacks
.copyFileOrFolder({
path: currentPath,
newPath: newPath,
})
.then(this.handleResponse);
if (!newFile?.info) {
return;
}
this.delegate.create({
parentId: node.parent?.id ?? null,
index: 0,
data: newFile.info,
});
this.onChange(this.delegate.data);
// Refresh the parent folder
await this.refreshAll([parentPath]);
}

async rename(id: string, name: string): Promise<void> {
const node = this.delegate.find(id);
if (!node) {
toast({
title: "Failed",
description: `Node with id ${id} not found in the tree`,
});
return;
}
const currentPath = node.data.path as FilePath;
Expand Down Expand Up @@ -172,6 +209,10 @@ export class RequestingTree {
async delete(id: string): Promise<void> {
const node = this.delegate.find(id);
if (!node) {
toast({
title: "Failed",
description: `Node with id ${id} not found in the tree`,
});
return;
}

Expand Down
1 change: 1 addition & 0 deletions frontend/src/components/editor/file-tree/state.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export const treeAtom = atom<RequestingTree>((get) => {
listFiles: client.sendListFiles,
createFileOrFolder: client.sendCreateFileOrFolder,
deleteFileOrFolder: client.sendDeleteFileOrFolder,
copyFileOrFolder: client.sendCopyFileOrFolder,
renameFileOrFolder: client.sendRenameFileOrFolder,
});
});
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 @@ -237,6 +237,7 @@ export class IslandsPyodideBridge implements RunRequests, EditRequests {
sendPdb = throwNotImplemented;
sendCreateFileOrFolder = throwNotImplemented;
sendDeleteFileOrFolder = throwNotImplemented;
sendCopyFileOrFolder = throwNotImplemented;
sendRenameFileOrFolder = throwNotImplemented;
sendUpdateFile = throwNotImplemented;
sendFileDetails = throwNotImplemented;
Expand Down
1 change: 1 addition & 0 deletions frontend/src/core/network/requests-lazy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ const ACTIONS: Record<keyof AllRequests, Action> = {
sendSearchFiles: "startConnection",
sendCreateFileOrFolder: "throwError",
sendDeleteFileOrFolder: "throwError",
sendCopyFileOrFolder: "throwError",
sendRenameFileOrFolder: "throwError",
sendUpdateFile: "throwError",
sendFileDetails: "throwError",
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 @@ -312,6 +312,14 @@ export function createNetworkRequests(): EditRequests & RunRequests {
})
.then(handleResponse);
},
sendCopyFileOrFolder: async (request) => {
await waitForConnectionOpen();
return getClient()
.POST("/api/files/copy", {
body: request,
})
.then(handleResponse);
},
sendRenameFileOrFolder: async (request) => {
await waitForConnectionOpen();
return getClient()
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 @@ -66,6 +66,7 @@ export function createStaticRequests(): EditRequests & RunRequests {
sendPdb: throwNotInEditMode,
sendCreateFileOrFolder: throwNotInEditMode,
sendDeleteFileOrFolder: throwNotInEditMode,
sendCopyFileOrFolder: throwNotInEditMode,
sendRenameFileOrFolder: throwNotInEditMode,
sendUpdateFile: throwNotInEditMode,
sendFileDetails: throwNotInEditMode,
Expand Down
1 change: 1 addition & 0 deletions frontend/src/core/network/requests-toasting.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export function createErrorToastingRequests(
sendPdb: "Failed to start debug session",
sendCreateFileOrFolder: "Failed to create file or folder",
sendDeleteFileOrFolder: "Failed to delete file or folder",
sendCopyFileOrFolder: "Failed to duplicate file or folder",
sendRenameFileOrFolder: "Failed to rename file or folder",
sendUpdateFile: "Failed to update file",
sendFileDetails: "Failed to get file details",
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/core/network/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ export type ExportAsIPYNBRequest = schemas["ExportAsIPYNBRequest"];
export type ExportAsScriptRequest = schemas["ExportAsScriptRequest"];
export type ExportAsPDFRequest = schemas["ExportAsPDFRequest"];
export type UpdateCellOutputsRequest = schemas["UpdateCellOutputsRequest"];
export type FileCopyRequest = schemas["FileCopyRequest"];
export type FileCopyResponse = schemas["FileCopyResponse"];
export type FileCreateRequest = schemas["FileCreateRequest"];
export type FileCreateResponse = schemas["FileCreateResponse"];
export type FileDeleteRequest = schemas["FileDeleteRequest"];
Expand Down Expand Up @@ -168,6 +170,7 @@ export interface EditRequests {
sendDeleteFileOrFolder: (
request: FileDeleteRequest,
) => Promise<FileDeleteResponse>;
sendCopyFileOrFolder: (request: FileCopyRequest) => Promise<FileCopyResponse>;
sendRenameFileOrFolder: (
request: FileMoveRequest,
) => Promise<FileMoveResponse>;
Expand Down
11 changes: 11 additions & 0 deletions frontend/src/core/wasm/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import type {
EditRequests,
ExportAsHTMLRequest,
ExportAsMarkdownRequest,
FileCopyResponse,
FileCreateResponse,
FileDeleteResponse,
FileDetailsResponse,
Expand Down Expand Up @@ -443,6 +444,16 @@ export class PyodideBridge implements RunRequests, EditRequests {
return response as FileDeleteResponse;
};

sendCopyFileOrFolder: EditRequests["sendCopyFileOrFolder"] = async (
request,
) => {
const response = await this.rpc.proxy.request.bridge({
functionName: "copy_file_or_directory",
payload: request,
});
return response as FileCopyResponse;
};

sendRenameFileOrFolder: EditRequests["sendRenameFileOrFolder"] = async (
request,
) => {
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/core/wasm/worker/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import type {
CopyNotebookRequest,
ExportAsHTMLRequest,
ExportAsMarkdownRequest,
FileCopyRequest,
FileCopyResponse,
FileCreateRequest,
FileCreateResponse,
FileDeleteRequest,
Expand Down Expand Up @@ -86,6 +88,7 @@ export interface RawBridge {
delete_file_or_directory(
request: FileDeleteRequest,
): Promise<FileDeleteResponse>;
copy_file_or_directory(request: FileCopyRequest): Promise<FileCopyResponse>;
move_file_or_directory(request: FileMoveRequest): Promise<FileMoveResponse>;
update_file(request: FileUpdateRequest): Promise<FileUpdateResponse>;
load_packages(request: string): Promise<string>;
Expand Down
1 change: 1 addition & 0 deletions frontend/src/core/wasm/worker/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,7 @@ const namesThatRequireSync = new Set<keyof RawBridge>([
"rename_file",
"create_file_or_directory",
"delete_file_or_directory",
"copy_file_or_directory",
"move_file_or_directory",
"update_file",
]);
Expand Down
2 changes: 2 additions & 0 deletions marimo/_cli/development/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,8 @@ def _generate_server_api_schema() -> dict[str, Any]:
files.FileSearchResponse,
files.FileMoveRequest,
files.FileMoveResponse,
files.FileCopyRequest,
files.FileCopyResponse,
files.FileOpenRequest,
files.FileUpdateRequest,
files.FileUpdateResponse,
Expand Down
16 changes: 16 additions & 0 deletions marimo/_pyodide/pyodide_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@
from marimo._server.files.os_file_system import OSFileSystem
from marimo._server.models.export import ExportAsHTMLRequest
from marimo._server.models.files import (
FileCopyRequest,
FileCopyResponse,
FileCreateRequest,
FileCreateResponse,
FileDeleteRequest,
Expand Down Expand Up @@ -369,6 +371,20 @@ def delete_file_or_directory(
response = FileDeleteResponse(success=success)
return self._dump(response)

def copy_file_or_directory(
self,
request: str,
) -> str:
body = self._parse(request, FileCopyRequest)
try:
info = self.file_system.copy_file_or_directory(
body.path, body.new_path
)
response = FileCopyResponse(success=True, info=info)
except Exception as e:
response = FileCopyResponse(success=False, message=str(e))
return self._dump(response)

def move_file_or_directory(
self,
request: str,
Expand Down
Loading
Loading