Skip to content

Commit

Permalink
improvement: allow pasting full marimo applications in a cell (#3273)
Browse files Browse the repository at this point in the history
  • Loading branch information
mscolnick authored Dec 22, 2024
1 parent 90f55be commit e1b33c2
Show file tree
Hide file tree
Showing 12 changed files with 613 additions and 5 deletions.
11 changes: 11 additions & 0 deletions frontend/src/components/editor/cell/code/cell-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,17 @@ const CellEditorInternal = ({
deleteCell: handleDelete,
createAbove,
createBelow,
createManyBelow: (cells) => {
for (const code of [...cells].reverse()) {
createNewCell({
code,
before: false,
cellId: cellId,
// If the code already exists, skip creation
skipIfCodeExists: true,
});
}
},
moveUp,
moveDown,
focusUp,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,8 @@ const SnippetViewer: React.FC<{ snippet: Snippet }> = ({ snippet }) => {
code: section.code,
before: false,
cellId: lastFocusedCellId ?? "__end__",
// If the code already exists, skip creation
skipIfCodeExists: true,
});
}
}
Expand Down
42 changes: 42 additions & 0 deletions frontend/src/core/cells/__tests__/cells.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1710,4 +1710,46 @@ describe("cell reducer", () => {
expect(state.cellRuntime[secondCellId].output).toBeNull();
expect(state.cellRuntime[secondCellId].consoleOutputs).toEqual([]);
});

it("skips creating new cell if code exists and skipIfCodeExists is true", () => {
// Add initial cell with code
actions.updateCellCode({
cellId: firstCellId,
code: "import numpy as np",
formattingChange: false,
});

// Try to create new cell with same code and skipIfCodeExists
actions.createNewCell({
cellId: "__end__",
code: "import numpy as np",
before: false,
skipIfCodeExists: true,
});

// Should still only have one cell
expect(state.cellIds.inOrderIds.length).toBe(1);
expect(formatCells(state)).toMatchInlineSnapshot(`
"
[0] 'import numpy as np'
"
`);

// Verify we can still add cell with different code
actions.createNewCell({
cellId: "__end__",
code: "import pandas as pd",
before: false,
skipIfCodeExists: true,
});

expect(state.cellIds.inOrderIds.length).toBe(2);
expect(formatCells(state)).toMatchInlineSnapshot(`
"
[0] 'import numpy as np'
[1] 'import pandas as pd'
"
`);
});
});
23 changes: 23 additions & 0 deletions frontend/src/core/cells/cells.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,13 +195,26 @@ const {
createNewCell: (
state,
action: {
/** The target cell ID to create a new cell relative to. Can be:
* - A CellId string for an existing cell
* - "__end__" to append at the end of the first column
* - {type: "__end__", columnId} to append at the end of a specific column
*/
cellId: CellId | "__end__" | { type: "__end__"; columnId: CellColumnId };
/** Whether to insert before (true) or after (false) the target cell */
before: boolean;
/** Initial code content for the new cell */
code?: string;
/** The last executed code for the new cell */
lastCodeRun?: string;
/** Timestamp of the last execution */
lastExecutionTime?: number;
/** Optional custom ID for the new cell. Auto-generated if not provided */
newCellId?: CellId;
/** Whether to focus the new cell after creation */
autoFocus?: boolean;
/** If true, skip creation if code already exists */
skipIfCodeExists?: boolean;
},
) => {
const {
Expand All @@ -211,11 +224,21 @@ const {
lastCodeRun = null,
lastExecutionTime = null,
autoFocus = true,
skipIfCodeExists = false,
} = action;

let columnId: CellColumnId;
let cellIndex: number;

// If skipIfCodeExists is true, check if the code already exists in the notebook
if (skipIfCodeExists) {
for (const cellId of state.cellIds.inOrderIds) {
if (state.cellData[cellId]?.code === code) {
return state;
}
}
}

if (cellId === "__end__") {
const column = state.cellIds.atOrThrow(0);
columnId = column.id;
Expand Down
1 change: 1 addition & 0 deletions frontend/src/core/codemirror/__tests__/setup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ function getOpts() {
deleteCell: namedFunction("deleteCell"),
createAbove: namedFunction("createAbove"),
createBelow: namedFunction("createBelow"),
createManyBelow: namedFunction("createManyBelow"),
moveUp: namedFunction("moveUp"),
moveDown: namedFunction("moveDown"),
focusUp: namedFunction("focusUp"),
Expand Down
1 change: 1 addition & 0 deletions frontend/src/core/codemirror/cells/extensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export interface MovementCallbacks
deleteCell: () => void;
createAbove: () => void;
createBelow: () => void;
createManyBelow: (content: string[]) => void;
moveUp: () => void;
moveDown: () => void;
focusUp: () => void;
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/core/codemirror/cm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,9 @@ import { historyCompartment } from "./editing/extensions";
import { goToDefinitionBundle } from "./go-to-definition/extension";
import type { HotkeyProvider } from "../hotkeys/hotkeys";
import { lightTheme } from "./theme/light";
import { dndBundle } from "./dnd/extension";
import { dndBundle } from "./misc/dnd";
import { jupyterHelpExtension } from "./compat/jupyter";
import { pasteBundle } from "./misc/paste";

export interface CodeMirrorSetupOpts {
cellId: CellId;
Expand Down Expand Up @@ -87,6 +88,7 @@ export const setupCodeMirror = (opts: CodeMirrorSetupOpts): Extension[] => {
// Editor keymaps (vim or defaults) based on user config
keymapBundle(keymapConfig, cellMovementCallbacks),
dndBundle(),
pasteBundle(),
jupyterHelpExtension(),
// Cell editing
cellMovementBundle(cellId, cellMovementCallbacks, hotkeys),
Expand Down
111 changes: 111 additions & 0 deletions frontend/src/core/codemirror/misc/__tests__/dnd.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/* Copyright 2024 Marimo. All rights reserved. */
/* eslint-disable @typescript-eslint/no-explicit-any */
import { EditorView } from "@codemirror/view";
import { dndBundle } from "../dnd";
import { describe, beforeEach, afterEach, it, expect } from "vitest";

describe("dnd", () => {
let view: EditorView;

beforeEach(() => {
const el = document.createElement("div");
view = new EditorView({
parent: el,
});
});

afterEach(() => {
view.destroy();
});

it("handles text file drops", () => {
const extension = dndBundle();
const handlers = extension[0] as any;
const dropHandler = handlers.domEventHandlers.drop;

const file = new File(["test content"], "test.txt", { type: "text/plain" });
const event = new DragEvent("drop", {
dataTransfer: new DataTransfer(),
});
event.dataTransfer?.items.add(file);

const result = dropHandler(event, view);
expect(result).toBe(true);
});

it("handles image file drops", () => {
const extension = dndBundle();
const handlers = extension[0] as any;
const dropHandler = handlers.domEventHandlers.drop;

const file = new File([""], "test.png", { type: "image/png" });
const event = new DragEvent("drop", {
dataTransfer: new DataTransfer(),
});
event.dataTransfer?.items.add(file);

const result = dropHandler(event, view);
expect(result).toBe(true);
});

it("handles plain text drops", () => {
const extension = dndBundle();
const handlers = extension[0] as any;
const dropHandler = handlers.domEventHandlers.drop;

const event = new DragEvent("drop", {
dataTransfer: new DataTransfer(),
clientX: 0,
clientY: 0,
});
event.dataTransfer?.setData("text/plain", "dropped text");

const result = dropHandler(event, view);
expect(result).toBe(true);
});
});

class DragEvent extends Event {
dataTransfer: DataTransfer;
clientX: number;
clientY: number;

constructor(
type: string,
{
dataTransfer,
clientX,
clientY,
}: { dataTransfer?: DataTransfer; clientX?: number; clientY?: number } = {},
) {
super(type);
this.dataTransfer = dataTransfer || new DataTransfer();
this.clientX = clientX || 0;
this.clientY = clientY || 0;
}
}

class DataTransfer {
data: Record<string, string> = {};
_items: File[] = [];

setData(type: string, data: string) {
this.data[type] = data;
}

get items() {
return {
add: (file: File) => {
this._items.push(file);
},
};
}

get files() {
return this._items;
}

getData(type: string) {
return this.data[type];
}
}
Loading

0 comments on commit e1b33c2

Please sign in to comment.