@@ -7,20 +7,36 @@ import {
77 commands ,
88 window ,
99 NotebookCellData ,
10- NotebookRange
10+ NotebookRange ,
11+ env
1112} from 'vscode' ;
1213
1314import { IExtensionSyncActivationService } from '../../platform/activation/types' ;
1415import { IDisposableRegistry } from '../../platform/common/types' ;
1516import { logger } from '../../platform/logging' ;
1617import { 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 ( )
2642export 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