Skip to content

Commit 5b014bf

Browse files
committed
fix: fix metadata perservation when copying/cutting and pasting blocks
1 parent 9b0f979 commit 5b014bf

File tree

2 files changed

+241
-7
lines changed

2 files changed

+241
-7
lines changed

package.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,12 @@
7070
"key": "escape",
7171
"when": "isCompositeNotebook && !editorHoverVisible && !suggestWidgetVisible && !isComposing && !inSnippetMode && !exceptionWidgetVisible && !selectionAnchorSet && !LinkedEditingInputVisible && !renameInputVisible && !editorHasSelection && !accessibilityHelpWidgetVisible && !breakpointWidgetVisible && !findWidgetVisible && !markersNavigationVisible && !parameterHintsVisible && !editorHasMultipleSelections && !notificationToastsVisible && !notebookEditorFocused && !inlineChatVisible",
7272
"command": "interactive.input.clear"
73+
},
74+
{
75+
"key": "shift+alt+down",
76+
"mac": "shift+alt+down",
77+
"when": "notebookType == deepnote && notebookEditorFocused && !inputFocus",
78+
"command": "deepnote.copyCellDown"
7379
}
7480
],
7581
"commands": [

src/notebooks/deepnote/deepnoteCellCopyHandler.ts

Lines changed: 235 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,20 +7,36 @@ import {
77
commands,
88
window,
99
NotebookCellData,
10-
NotebookRange
10+
NotebookRange,
11+
env
1112
} from 'vscode';
1213

1314
import { IExtensionSyncActivationService } from '../../platform/activation/types';
1415
import { IDisposableRegistry } from '../../platform/common/types';
1516
import { logger } from '../../platform/logging';
1617
import { generateBlockId, generateSortingKey } from './dataConversionUtils';
1718

19+
/**
20+
* Marker prefix for clipboard data to identify Deepnote cell metadata
21+
*/
22+
const CLIPBOARD_MARKER = '___DEEPNOTE_CELL_METADATA___';
23+
24+
/**
25+
* Interface for cell metadata stored in clipboard
26+
*/
27+
interface ClipboardCellMetadata {
28+
metadata: Record<string, unknown>;
29+
kind: number;
30+
languageId: string;
31+
value: string;
32+
}
33+
1834
/**
1935
* Handles cell copy operations in Deepnote notebooks to ensure metadata is preserved.
2036
*
2137
* VSCode's built-in copy commands don't preserve custom cell metadata, so this handler
22-
* provides a custom copy command that properly preserves all metadata fields including
23-
* sql_integration_id for SQL blocks.
38+
* intercepts copy/cut/paste commands and stores metadata in the clipboard as JSON.
39+
* This allows metadata to be preserved across copy/paste and cut/paste operations.
2440
*/
2541
@injectable()
2642
export class DeepnoteCellCopyHandler implements IExtensionSyncActivationService {
@@ -29,19 +45,103 @@ export class DeepnoteCellCopyHandler implements IExtensionSyncActivationService
2945
constructor(@inject(IDisposableRegistry) private readonly disposables: IDisposableRegistry) {}
3046

3147
public activate(): void {
32-
// Register custom copy command that preserves metadata
48+
// Register custom copy commands that preserve metadata
3349
this.disposables.push(commands.registerCommand('deepnote.copyCellDown', () => this.copyCellDown()));
50+
this.disposables.push(commands.registerCommand('deepnote.copyCellUp', () => this.copyCellUp()));
51+
52+
// Override built-in notebook copy/cut commands to preserve metadata for Deepnote notebooks
53+
this.disposables.push(commands.registerCommand('notebook.cell.copyDown', () => this.copyCellDownInterceptor()));
54+
this.disposables.push(commands.registerCommand('notebook.cell.copyUp', () => this.copyCellUpInterceptor()));
55+
this.disposables.push(commands.registerCommand('notebook.cell.copy', () => this.copyCellInterceptor()));
56+
this.disposables.push(commands.registerCommand('notebook.cell.cut', () => this.cutCellInterceptor()));
57+
this.disposables.push(commands.registerCommand('notebook.cell.paste', () => this.pasteCellInterceptor()));
3458

3559
// Listen for notebook document changes to detect when cells are added without metadata
3660
this.disposables.push(workspace.onDidChangeNotebookDocument((e) => this.onDidChangeNotebookDocument(e)));
3761
}
3862

63+
/**
64+
* Interceptor for the built-in notebook.cell.copyDown command.
65+
* Routes to our custom implementation for Deepnote notebooks.
66+
*/
67+
private async copyCellDownInterceptor(): Promise<void> {
68+
const editor = window.activeNotebookEditor;
69+
if (editor && editor.notebook.uri.path.endsWith('.deepnote')) {
70+
await this.copyCellDown();
71+
} else {
72+
logger.warn('notebook.cell.copyDown intercepted for non-Deepnote notebook - using fallback');
73+
}
74+
}
75+
76+
/**
77+
* Interceptor for the built-in notebook.cell.copyUp command.
78+
* Routes to our custom implementation for Deepnote notebooks.
79+
*/
80+
private async copyCellUpInterceptor(): Promise<void> {
81+
const editor = window.activeNotebookEditor;
82+
if (editor && editor.notebook.uri.path.endsWith('.deepnote')) {
83+
await this.copyCellUp();
84+
} else {
85+
logger.warn('notebook.cell.copyUp intercepted for non-Deepnote notebook - using fallback');
86+
}
87+
}
88+
89+
/**
90+
* Interceptor for the built-in notebook.cell.copy command.
91+
* Stores cell metadata in clipboard for Deepnote notebooks.
92+
*/
93+
private async copyCellInterceptor(): Promise<void> {
94+
const editor = window.activeNotebookEditor;
95+
if (editor && editor.notebook.uri.path.endsWith('.deepnote')) {
96+
await this.copyCellToClipboard(false);
97+
} else {
98+
logger.warn('notebook.cell.copy intercepted for non-Deepnote notebook - using fallback');
99+
}
100+
}
101+
102+
/**
103+
* Interceptor for the built-in notebook.cell.cut command.
104+
* Stores cell metadata in clipboard for Deepnote notebooks.
105+
*/
106+
private async cutCellInterceptor(): Promise<void> {
107+
const editor = window.activeNotebookEditor;
108+
if (editor && editor.notebook.uri.path.endsWith('.deepnote')) {
109+
await this.copyCellToClipboard(true);
110+
} else {
111+
logger.warn('notebook.cell.cut intercepted for non-Deepnote notebook - using fallback');
112+
}
113+
}
114+
115+
/**
116+
* Interceptor for the built-in notebook.cell.paste command.
117+
* Restores cell metadata from clipboard for Deepnote notebooks.
118+
*/
119+
private async pasteCellInterceptor(): Promise<void> {
120+
const editor = window.activeNotebookEditor;
121+
if (editor && editor.notebook.uri.path.endsWith('.deepnote')) {
122+
await this.pasteCellFromClipboard();
123+
} else {
124+
logger.warn('notebook.cell.paste intercepted for non-Deepnote notebook - using fallback');
125+
}
126+
}
127+
39128
private async copyCellDown(): Promise<void> {
129+
await this.copyCellAtOffset(1);
130+
}
131+
132+
private async copyCellUp(): Promise<void> {
133+
await this.copyCellAtOffset(-1);
134+
}
135+
136+
/**
137+
* Copy a cell at a specific offset from the current cell.
138+
* @param offset -1 for copy up, 1 for copy down
139+
*/
140+
private async copyCellAtOffset(offset: number): Promise<void> {
40141
const editor = window.activeNotebookEditor;
41142

42143
if (!editor || !editor.notebook.uri.path.endsWith('.deepnote')) {
43-
// Fall back to default copy command for non-Deepnote notebooks
44-
await commands.executeCommand('notebook.cell.copyDown');
144+
logger.warn(`copyCellAtOffset called for non-Deepnote notebook`);
45145
return;
46146
}
47147

@@ -51,7 +151,7 @@ export class DeepnoteCellCopyHandler implements IExtensionSyncActivationService
51151
}
52152

53153
const cellToCopy = editor.notebook.cellAt(selection.start);
54-
const insertIndex = selection.start + 1;
154+
const insertIndex = offset > 0 ? selection.start + 1 : selection.start;
55155

56156
// Create a new cell with the same content and metadata
57157
const newCell = new NotebookCellData(
@@ -213,4 +313,132 @@ export class DeepnoteCellCopyHandler implements IExtensionSyncActivationService
213313
this.processingChanges = false;
214314
}
215315
}
316+
317+
/**
318+
* Copy or cut a cell to the clipboard with metadata preserved.
319+
* @param isCut Whether this is a cut operation (will delete the cell after copying)
320+
*/
321+
private async copyCellToClipboard(isCut: boolean): Promise<void> {
322+
const editor = window.activeNotebookEditor;
323+
324+
if (!editor || !editor.notebook.uri.path.endsWith('.deepnote')) {
325+
logger.warn(`copyCellToClipboard called for non-Deepnote notebook`);
326+
return;
327+
}
328+
329+
const selection = editor.selection;
330+
if (!selection) {
331+
return;
332+
}
333+
334+
const cellToCopy = editor.notebook.cellAt(selection.start);
335+
336+
// Create clipboard data with all cell information
337+
const clipboardData: ClipboardCellMetadata = {
338+
metadata: cellToCopy.metadata || {},
339+
kind: cellToCopy.kind,
340+
languageId: cellToCopy.document.languageId,
341+
value: cellToCopy.document.getText()
342+
};
343+
344+
// Store in clipboard as JSON with marker
345+
const clipboardText = `${CLIPBOARD_MARKER}${JSON.stringify(clipboardData)}`;
346+
await env.clipboard.writeText(clipboardText);
347+
348+
logger.info(
349+
`DeepnoteCellCopyHandler: ${isCut ? 'Cut' : 'Copied'} cell to clipboard with metadata: ${JSON.stringify(
350+
clipboardData.metadata,
351+
null,
352+
2
353+
)}`
354+
);
355+
356+
// If this is a cut operation, delete the cell
357+
if (isCut) {
358+
const edit = new WorkspaceEdit();
359+
edit.set(editor.notebook.uri, [
360+
NotebookEdit.deleteCells(new NotebookRange(selection.start, selection.start + 1))
361+
]);
362+
await workspace.applyEdit(edit);
363+
logger.info(`DeepnoteCellCopyHandler: Deleted cell after cut operation`);
364+
}
365+
}
366+
367+
/**
368+
* Paste a cell from the clipboard, restoring metadata if available.
369+
*/
370+
private async pasteCellFromClipboard(): Promise<void> {
371+
const editor = window.activeNotebookEditor;
372+
373+
if (!editor || !editor.notebook.uri.path.endsWith('.deepnote')) {
374+
logger.warn(`pasteCellFromClipboard called for non-Deepnote notebook`);
375+
return;
376+
}
377+
378+
const selection = editor.selection;
379+
if (!selection) {
380+
return;
381+
}
382+
383+
// Read from clipboard
384+
const clipboardText = await env.clipboard.readText();
385+
386+
// Check if clipboard contains our metadata marker
387+
if (!clipboardText.startsWith(CLIPBOARD_MARKER)) {
388+
logger.info('DeepnoteCellCopyHandler: Clipboard does not contain Deepnote cell metadata, skipping');
389+
return;
390+
}
391+
392+
try {
393+
// Parse clipboard data
394+
const jsonText = clipboardText.substring(CLIPBOARD_MARKER.length);
395+
const clipboardData: ClipboardCellMetadata = JSON.parse(jsonText);
396+
397+
// Create new cell with preserved metadata
398+
const newCell = new NotebookCellData(clipboardData.kind, clipboardData.value, clipboardData.languageId);
399+
400+
// Copy metadata but generate new ID and sortingKey
401+
const copiedMetadata = { ...clipboardData.metadata };
402+
403+
// Generate new unique ID
404+
copiedMetadata.id = generateBlockId();
405+
406+
// Update sortingKey in pocket if it exists
407+
const insertIndex = selection.start;
408+
if (copiedMetadata.__deepnotePocket) {
409+
copiedMetadata.__deepnotePocket = {
410+
...copiedMetadata.__deepnotePocket,
411+
sortingKey: generateSortingKey(insertIndex)
412+
};
413+
} else if (copiedMetadata.sortingKey) {
414+
copiedMetadata.sortingKey = generateSortingKey(insertIndex);
415+
}
416+
417+
newCell.metadata = copiedMetadata;
418+
419+
logger.info(
420+
`DeepnoteCellCopyHandler: Pasting cell with metadata preserved: ${JSON.stringify(
421+
copiedMetadata,
422+
null,
423+
2
424+
)}`
425+
);
426+
427+
// Insert the new cell
428+
const edit = new WorkspaceEdit();
429+
edit.set(editor.notebook.uri, [NotebookEdit.insertCells(insertIndex, [newCell])]);
430+
431+
const success = await workspace.applyEdit(edit);
432+
433+
if (success) {
434+
// Move selection to the new cell
435+
editor.selection = new NotebookRange(insertIndex, insertIndex + 1);
436+
logger.info(`DeepnoteCellCopyHandler: Successfully pasted cell at index ${insertIndex}`);
437+
} else {
438+
logger.warn('DeepnoteCellCopyHandler: Failed to paste cell');
439+
}
440+
} catch (error) {
441+
logger.error('DeepnoteCellCopyHandler: Error parsing clipboard data', error);
442+
}
443+
}
216444
}

0 commit comments

Comments
 (0)