feat: add cut cells command (#7423)#8019
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
All contributors have signed the CLA ✍️ ✅ |
| // NOTE: We don't support Cut yet. We can wait for feedback before implementing. | ||
| // It is a bit more complex as will need to: | ||
| // - include id, outputs, and name | ||
| // - delete the existing cell, but don't place on the undo stack |
There was a problem hiding this comment.
throughout my testing this actually felt a bit unintuitive. should users be able to undo a cut?
| // It is a bit more complex as will need to: | ||
| // - include id, outputs, and name | ||
| // - delete the existing cell, but don't place on the undo stack | ||
| // - don't want to invalidate downstream cells |
There was a problem hiding this comment.
I think this is unavoidable in my current implementation.
What might be worth considering though is a "pending" state where we grey out the cell -> paste -> use dropCellOverCell to preserve cellId ? thoughts?
|
I have read the CLA Document and I hereby sign the CLA |
|
we originally held off on what is the use-case? it is solely to move cells to another place? |
Yeah i think the main benefit would be for bulk moving cells. How about something like this? cut.cells.pending.mp4
|
|
@charliegiang that is a pretty nice interaction. What do you think @manzt @Light2Dark r.e. UX? |
|
That looks pretty good imo @charliegiang, I'm guessing just a styling change to mark the cell as 'cut'. And on refresh or cell runs it would be as normal. I think there should be a way to remove the cut as well (maybe Esc) |
90b5709 to
1b48033
Compare
Yeah I agree, how's this? Unmark cells for cut with esc -> esc unmark.cell.for.cut.mp4Undo cut -> paste cells cut.cells.and.undo.mp4 |
|
This is a really nice interaction, and I appreciate the thoughtfulness in evolving it to be non-destructive!
Since the original use case mentioned is moving cells around, I just wanted to point out that we do have bulk move in "command mode." You can select multiple cells and use move up/down or send to top/bottom. Screen.Recording.2026-01-30.at.11.38.21.AM.movThat said, I think cut really shines for moving cells between notebooks or applications, where the clipboard-based approach makes a lot of sense. |
|
This pull request has been automatically marked as stale because it has not had activity in 30 days. It will be closed in 14 days if no further activity occurs. If this PR is still relevant, please leave a comment or push new changes to keep it open. Thank you for your contribution! |
|
Hi @charliegiang , sorry we did not get around to this after the review. Would you be up to fix the merge conflict and tests? |
|
@Light2Dark Yep - thanks for the reminder. I'll make time to work on it this week. 👍 |
a865a0c to
3ef6523
Compare
There was a problem hiding this comment.
Pull request overview
Adds a “cut cells” command to command mode, enabling a cut-then-paste workflow that moves cells (and supports undo) while visually marking cells as pending cut.
Changes:
- Added
cutCells+ pending-cut state/service, plus UI styling to indicate “pending cut”. - Extended clipboard payload to include cell
nameandconfig, and paste now preserves those fields. - Added multi-cell move + undo support via
MultiColumn.moveCellsRelativeTo,MultiColumn.placeCells, and notebook history entries/labels.
Reviewed changes
Copilot reviewed 17 out of 17 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| frontend/src/utils/id-tree.tsx | Adds multi-cell move and placement helpers used for cut/paste and undo. |
| frontend/src/utils/tests/id-tree.test.ts | Adds unit tests for multi-cell move behavior. |
| frontend/src/css/app/Cell.css | Adds visual treatment for cells marked as pending cut. |
| frontend/src/core/hotkeys/hotkeys.ts | Introduces the x hotkey for “Cut cell” in command mode. |
| frontend/src/core/codemirror/cells/extensions.ts | Clears pending cut state when editing a cut-marked cell. |
| frontend/src/core/cells/utils.ts | Adds dynamic undo label based on latest history entry type. |
| frontend/src/core/cells/pending-cut-service.ts | New service/atoms/hooks for tracking pending cut cells + clipboard payload. |
| frontend/src/core/cells/cells.ts | Adds history entry typing, move/undo support, and extends createNewCell for name/config. |
| frontend/src/core/cells/tests/pending-cut-service.test.tsx | Adds tests for pending-cut service behavior. |
| frontend/src/core/cells/tests/cells.test.ts | Adds tests for createNewCell name/config and move+undo scenarios. |
| frontend/src/components/editor/notebook-cell.tsx | Applies pending-cut class to cells marked for cut. |
| frontend/src/components/editor/navigation/navigation.ts | Wires cut hotkey and Escape-to-clear-pending-cut behavior. |
| frontend/src/components/editor/navigation/clipboard.ts | Implements cut/paste move logic and clipboard schema including name/config. |
| frontend/src/components/editor/navigation/tests/navigation.test.ts | Adds hotkey tests for cutting single/multiple selected cells. |
| frontend/src/components/editor/navigation/tests/clipboard.test.ts | Adds tests for cut/copy behavior and pending-cut clearing on copy. |
| frontend/src/components/editor/controls/Controls.tsx | Uses the new undo label atom for tooltip content. |
| frontend/src/components/editor/actions/useNotebookActions.tsx | Uses the new undo label atom for the command palette action label. |
3ef6523 to
6366ffd
Compare
6366ffd to
811b04f
Compare
811b04f to
a1fb1b3
Compare
a1fb1b3 to
9766e9f
Compare
9766e9f to
a2436e9
Compare
| if (last.type === "move") { | ||
| const { cellIds, placements } = last; | ||
| if (cellIds.length === 0 || placements.length !== cellIds.length) { | ||
| return { ...state, history: state.history.slice(0, -1) }; | ||
| } | ||
| const toRestore = cellIds.map((id, i) => ({ | ||
| id, | ||
| columnId: placements[i].columnId, | ||
| index: placements[i].index, | ||
| })); | ||
| return { | ||
| ...state, | ||
| cellIds: state.cellIds.placeCells(toRestore), | ||
| history: state.history.slice(0, -1), | ||
| scrollKey: cellIds[0] ?? null, | ||
| }; |
There was a problem hiding this comment.
In the undoDeleteCell move-undo branch, placeCells() will happily re-insert IDs even if those cells no longer exist in state.cellData (e.g., user moves cells, deletes one of the moved cells, undoes the delete which restores a new ID, then undoes the move). This can leave state.cellIds containing cell IDs that have no corresponding cellData/cellRuntime, corrupting state. Before restoring, verify every cellId still exists in state.cellData (or drop the move history entry if any are missing).
Light2Dark
left a comment
There was a problem hiding this comment.
there's some type errors to fix
for more information, see https://pre-commit.ci
Light2Dark
left a comment
There was a problem hiding this comment.
This is great!
A nice thing to add is if pasting to an empty cell, we just replace it instead of appending below.
The css change and ^ can be followed-up by us or if you'd like to tackle it.
| &.pending-cut { | ||
| opacity: 0.6; | ||
| border-style: dashed; | ||
| border-color: var(--amber-7); | ||
|
|
||
| .output-area, | ||
| .cm-gutters, | ||
| .cm { | ||
| background-color: var(--amber-2); | ||
| } | ||
| } |
manzt
left a comment
There was a problem hiding this comment.
Thanks for the contribution. Looking really great! just some minor questions re: potentially vestigial code.
| function buildClipboardPayload( | ||
| cells: Array<{ code: string; name?: string; config?: CellConfig }>, | ||
| ): { clipboardData: ClipboardCellData; plainText: string } { | ||
| const clipboardData: ClipboardCellData = { | ||
| cells: cells.map((cell) => ({ | ||
| code: cell.code, | ||
| name: cell.name, | ||
| config: cell.config, | ||
| })), | ||
| version: "1.0", | ||
| }; | ||
| const plainText = cells.map((cell) => cell.code).join("\n\n"); | ||
| return { clipboardData, plainText }; | ||
| } | ||
|
|
||
| async function writeCellsToClipboard( | ||
| clipboardData: ClipboardCellData, | ||
| plainText: string, | ||
| ): Promise<void> { | ||
| const clipboardItem = new ClipboardItemBuilder() | ||
| .add(MARIMO_CELL_MIMETYPE, clipboardData) | ||
| .add("text/plain", plainText) | ||
| .build(); | ||
| await navigator.clipboard.write([clipboardItem]); | ||
| } |
There was a problem hiding this comment.
I wonder, could this be encapsulated in a single function, so the call site is just:
await writeCellsToClipboard(cells);That way we don't leak the internals out or require the caller to map these together.
|
|
||
| // Check if we have pending cut cells (internal move) | ||
| if (pendingCutState.cellIds.size > 0) { | ||
| const pendingCellIds = [...pendingCutState.cellIds]; |
There was a problem hiding this comment.
I only see cellIds used from the pendingCutState. This function re-derives everything from pendingCutState.cellIds. I think this is the right design (just keep track of the pending ids (not the data), so we can probably remove pendingCutState.clipboardData?
|
|
||
| interface PendingCutState { | ||
| cellIds: Set<CellId>; | ||
| clipboardData: ClipboardCellData | null; |
There was a problem hiding this comment.
I think this is dead state, can we remove this field?
| cellIds: [firstCellId, "1" as CellId], | ||
| targetCellId: "2" as CellId, |
There was a problem hiding this comment.
nit: we have a helper made for branding types (instead of typecasts):
// frontend/src/__tests__/branded.ts
import { cellId } from "@/__tests__/branded";| cellIds: [firstCellId, "1" as CellId], | |
| targetCellId: "2" as CellId, | |
| cellIds: [firstCellId, cellId("1")], | |
| targetCellId: cellId("2"), |
|
🚀 Development release published. You may be able to view the changes at https://marimo.app?v=0.23.7-dev17 |
<img width="1055" height="321" alt="image" src="https://github.com/user-attachments/assets/53ab00ed-4bd5-4fbf-990f-c4029b69541d" /> ## 📝 Summary Follow-up to #8019, which merged before a few unresolved review threads were addressed. This PR addresses the actionable items. Closes # ## 📋 Pre-Review Checklist - [ ] For large changes, or changes that affect the public API: this change was discussed or approved through an issue, on [Discord](https://marimo.io/discord?ref=pr), or the community [discussions](https://github.com/marimo-team/marimo/discussions) (Please provide a link if applicable). - [x] Any AI generated code has been reviewed line-by-line by the human PR author, who stands by it. - [x] Video or media evidence is provided for any visual changes (optional). <!-- PR is more likely to be merged if evidence is provided for changes made --> ## ✅ Merge Checklist - [x] I have read the [contributor guidelines](https://github.com/marimo-team/marimo/blob/main/CONTRIBUTING.md). - [x] Documentation has been updated where applicable, including docstrings for API changes. - [x] Tests have been added for the changes made. Made with [Cursor](https://cursor.com)

📝 Summary
Added the ability to cut cells in command mode
Partially addresses #7423
nits / super nits encouraged 🙂
🔍 Description of Changes
cut.cells.mp4
cutCellsfunction copies cell data (code, name, config) to the clipboarduseDeleteManyCellsSilentCallbackhook anddeleteCellSilentreducer action to delete cells without pushing to the undo stack.createNewCellextended to accept optional name and config parameters so that pasted cells retain their original names and configurations.📋 Checklist