-
Notifications
You must be signed in to change notification settings - Fork 4
feat: SQL block duplicating commands (and copy&paste) #101
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 7 commits
Commits
Show all changes
13 commits
Select commit
Hold shift + click to select a range
9b0f979
add actually working "copy cell down" command
jankuca 5b014bf
fix: fix metadata perservation when copying/cutting and pasting blocks
jankuca 8a4b506
replace .deepnote extension check with doc type check
jankuca e1c12c6
redact logs
jankuca 05b7caa
fix: deep clone outputs
jankuca 483dd85
remove deepnote.copy* commands since we are overriding native ones
jankuca 0c661e0
fix fallback to native commands in non-deepnote notebooks
jankuca 5b2cf58
fix: preserve output metadata
jankuca 9efd426
lower log level
jankuca e58d440
fix: add fallback to default command on error on non-deepnote copy&paste
jankuca e7d9e07
use structured clonning
jankuca 5f6bb3a
Merge branch 'main' into jk/feat/sql-block-duplicate
jankuca 585bb26
Merge branch 'main' into jk/feat/sql-block-duplicate
jankuca File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,396 @@ | ||
| import { injectable, inject } from 'inversify'; | ||
| import { | ||
| workspace, | ||
| NotebookDocumentChangeEvent, | ||
| NotebookEdit, | ||
| WorkspaceEdit, | ||
| commands, | ||
| window, | ||
| NotebookCellData, | ||
| NotebookRange, | ||
| env, | ||
| NotebookCellOutputItem, | ||
| NotebookCellOutput | ||
| } from 'vscode'; | ||
|
|
||
| import { IExtensionSyncActivationService } from '../../platform/activation/types'; | ||
| import { IDisposableRegistry } from '../../platform/common/types'; | ||
| import { logger } from '../../platform/logging'; | ||
| import { generateBlockId, generateSortingKey } from './dataConversionUtils'; | ||
|
|
||
| /** | ||
| * Marker prefix for clipboard data to identify Deepnote cell metadata | ||
| */ | ||
| const CLIPBOARD_MARKER = '___DEEPNOTE_CELL_METADATA___'; | ||
|
|
||
| /** | ||
| * Interface for cell metadata stored in clipboard | ||
| */ | ||
| interface ClipboardCellMetadata { | ||
| metadata: Record<string, unknown>; | ||
| kind: number; | ||
| languageId: string; | ||
| value: string; | ||
| } | ||
|
|
||
| /** | ||
| * Handles cell copy operations in Deepnote notebooks to ensure metadata is preserved. | ||
| * | ||
| * VSCode's built-in copy commands don't preserve custom cell metadata, so this handler | ||
| * intercepts copy/cut/paste commands and stores metadata in the clipboard as JSON. | ||
| * This allows metadata to be preserved across copy/paste and cut/paste operations. | ||
| */ | ||
| @injectable() | ||
| export class DeepnoteCellCopyHandler implements IExtensionSyncActivationService { | ||
| private processingChanges = false; | ||
|
|
||
| constructor(@inject(IDisposableRegistry) private readonly disposables: IDisposableRegistry) {} | ||
|
|
||
| public activate(): void { | ||
| // Override built-in notebook copy/cut commands to preserve metadata for Deepnote notebooks | ||
| this.disposables.push(commands.registerCommand('notebook.cell.copyDown', () => this.copyCellDownInterceptor())); | ||
| this.disposables.push(commands.registerCommand('notebook.cell.copyUp', () => this.copyCellUpInterceptor())); | ||
| this.disposables.push(commands.registerCommand('notebook.cell.copy', () => this.copyCellInterceptor())); | ||
| this.disposables.push(commands.registerCommand('notebook.cell.cut', () => this.cutCellInterceptor())); | ||
| this.disposables.push(commands.registerCommand('notebook.cell.paste', () => this.pasteCellInterceptor())); | ||
|
|
||
jankuca marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| // Listen for notebook document changes to detect when cells are added without metadata | ||
| this.disposables.push(workspace.onDidChangeNotebookDocument((e) => this.onDidChangeNotebookDocument(e))); | ||
| } | ||
|
|
||
| /** | ||
| * Interceptor for the built-in notebook.cell.copyDown command. | ||
| * Routes to our custom implementation for Deepnote notebooks. | ||
| */ | ||
| private async copyCellDownInterceptor(): Promise<void> { | ||
| const editor = window.activeNotebookEditor; | ||
| if (editor && editor.notebook && editor.notebook.notebookType === 'deepnote') { | ||
| await this.copyCellDown(); | ||
| } else { | ||
| await commands.executeCommand('default:notebook.cell.copyDown'); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Interceptor for the built-in notebook.cell.copyUp command. | ||
| * Routes to our custom implementation for Deepnote notebooks. | ||
| */ | ||
| private async copyCellUpInterceptor(): Promise<void> { | ||
| const editor = window.activeNotebookEditor; | ||
| if (editor && editor.notebook && editor.notebook.notebookType === 'deepnote') { | ||
| await this.copyCellUp(); | ||
| } else { | ||
| await commands.executeCommand('default:notebook.cell.copyUp'); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Interceptor for the built-in notebook.cell.copy command. | ||
| * Stores cell metadata in clipboard for Deepnote notebooks. | ||
| */ | ||
| private async copyCellInterceptor(): Promise<void> { | ||
| const editor = window.activeNotebookEditor; | ||
| if (editor && editor.notebook && editor.notebook.notebookType === 'deepnote') { | ||
| await this.copyCellToClipboard({ isCut: false }); | ||
| } else { | ||
| await commands.executeCommand('default:notebook.cell.copy'); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Interceptor for the built-in notebook.cell.cut command. | ||
| * Stores cell metadata in clipboard for Deepnote notebooks. | ||
| */ | ||
| private async cutCellInterceptor(): Promise<void> { | ||
| const editor = window.activeNotebookEditor; | ||
| if (editor && editor.notebook && editor.notebook.notebookType === 'deepnote') { | ||
| await this.copyCellToClipboard({ isCut: true }); | ||
| } else { | ||
| await commands.executeCommand('default:notebook.cell.cut'); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Interceptor for the built-in notebook.cell.paste command. | ||
| * Restores cell metadata from clipboard for Deepnote notebooks. | ||
| */ | ||
| private async pasteCellInterceptor(): Promise<void> { | ||
| const editor = window.activeNotebookEditor; | ||
| if (editor && editor.notebook && editor.notebook.notebookType === 'deepnote') { | ||
| await this.pasteCellFromClipboard(); | ||
| } else { | ||
| await commands.executeCommand('default:notebook.cell.paste'); | ||
| } | ||
| } | ||
|
|
||
| private async copyCellDown(): Promise<void> { | ||
| await this.copyCellAtOffset(1); | ||
| } | ||
|
|
||
| private async copyCellUp(): Promise<void> { | ||
| await this.copyCellAtOffset(-1); | ||
| } | ||
|
|
||
| /** | ||
| * Copy a cell at a specific offset from the current cell. | ||
| * @param offset -1 for copy up, 1 for copy down | ||
| */ | ||
| private async copyCellAtOffset(offset: number): Promise<void> { | ||
| const editor = window.activeNotebookEditor; | ||
|
|
||
| if (!editor || !editor.notebook || editor.notebook.notebookType !== 'deepnote') { | ||
| logger.warn(`copyCellAtOffset called for non-Deepnote notebook`); | ||
| return; | ||
| } | ||
jankuca marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| const selection = editor.selection; | ||
| if (!selection) { | ||
| return; | ||
| } | ||
|
|
||
| const cellToCopy = editor.notebook.cellAt(selection.start); | ||
| const insertIndex = offset > 0 ? selection.start + 1 : selection.start; | ||
|
|
||
| // Create a new cell with the same content and metadata | ||
| const newCell = new NotebookCellData( | ||
| cellToCopy.kind, | ||
| cellToCopy.document.getText(), | ||
| cellToCopy.document.languageId | ||
| ); | ||
|
|
||
| // Copy all metadata (ID and sortingKey will be generated by onDidChangeNotebookDocument) | ||
| if (cellToCopy.metadata) { | ||
| const copiedMetadata = { ...cellToCopy.metadata }; | ||
| newCell.metadata = copiedMetadata; | ||
coderabbitai[bot] marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| logger.debug('DeepnoteCellCopyHandler: Copying cell with metadata preserved'); | ||
| } | ||
|
|
||
| // Copy outputs if present | ||
| if (cellToCopy.outputs.length > 0) { | ||
| newCell.outputs = cellToCopy.outputs.map( | ||
| (o) => new NotebookCellOutput(o.items.map((i) => new NotebookCellOutputItem(i.data, i.mime))) | ||
| ); | ||
| } | ||
jankuca marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| // Insert the new cell | ||
| const edit = new WorkspaceEdit(); | ||
| edit.set(editor.notebook.uri, [NotebookEdit.insertCells(insertIndex, [newCell])]); | ||
|
|
||
| const success = await workspace.applyEdit(edit); | ||
|
|
||
| if (success) { | ||
| // Move selection to the new cell | ||
| editor.selection = new NotebookRange(insertIndex, insertIndex + 1); | ||
| logger.debug(`DeepnoteCellCopyHandler: Successfully copied cell to index ${insertIndex}`); | ||
| } else { | ||
| logger.warn('DeepnoteCellCopyHandler: Failed to copy cell'); | ||
| } | ||
| } | ||
|
|
||
| private async onDidChangeNotebookDocument(e: NotebookDocumentChangeEvent): Promise<void> { | ||
| // Only process Deepnote notebooks | ||
| if (e.notebook.notebookType !== 'deepnote') { | ||
| return; | ||
| } | ||
|
|
||
| // Avoid recursive processing | ||
| if (this.processingChanges) { | ||
| return; | ||
| } | ||
|
|
||
| // Check for cell additions (which includes copies) | ||
| for (const change of e.contentChanges) { | ||
| if (change.addedCells.length === 0) { | ||
| continue; | ||
| } | ||
|
|
||
| // When cells are copied, VSCode should preserve metadata automatically. | ||
| // However, we need to ensure that: | ||
| // 1. Each cell has a unique ID | ||
| // 2. The sortingKey is updated based on the new position | ||
| // 3. All other metadata (including sql_integration_id) is preserved | ||
|
|
||
| const cellsNeedingMetadataFix: Array<{ index: number; metadata: Record<string, unknown> }> = []; | ||
|
|
||
| for (const cell of change.addedCells) { | ||
| const metadata = cell.metadata || {}; | ||
|
|
||
| // Log the metadata to see what's actually being copied | ||
| logger.debug('DeepnoteCellCopyHandler: Cell added with metadata'); | ||
|
|
||
| // Only process Deepnote cells (cells with type or pocket metadata) | ||
| if (!metadata.type && !metadata.__deepnotePocket) { | ||
| continue; | ||
| } | ||
|
|
||
| const cellIndex = e.notebook.getCells().indexOf(cell); | ||
|
|
||
| if (cellIndex === -1) { | ||
| continue; | ||
| } | ||
|
|
||
| // Check if this cell needs metadata updates | ||
| // We update the ID and sortingKey for all added Deepnote cells to ensure uniqueness | ||
| const updatedMetadata = { ...metadata }; | ||
|
|
||
| // Generate new ID for the cell (important for copied cells) | ||
| updatedMetadata.id = generateBlockId(); | ||
|
|
||
| // Update sortingKey based on the new position | ||
| if (updatedMetadata.__deepnotePocket) { | ||
| updatedMetadata.__deepnotePocket = { | ||
| ...updatedMetadata.__deepnotePocket, | ||
| sortingKey: generateSortingKey(cellIndex) | ||
| }; | ||
| } else if (updatedMetadata.sortingKey) { | ||
| updatedMetadata.sortingKey = generateSortingKey(cellIndex); | ||
| } | ||
|
|
||
| // All other metadata (including sql_integration_id) is preserved from the original metadata | ||
| cellsNeedingMetadataFix.push({ | ||
| index: cellIndex, | ||
| metadata: updatedMetadata | ||
| }); | ||
|
|
||
| logger.debug( | ||
| `DeepnoteCellCopyHandler: Updated metadata for ${metadata.type} cell at index ${cellIndex}` | ||
| ); | ||
| } | ||
|
|
||
| // Apply metadata fixes if needed | ||
| if (cellsNeedingMetadataFix.length > 0) { | ||
| await this.applyMetadataFixes(e.notebook.uri, cellsNeedingMetadataFix); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| private async applyMetadataFixes( | ||
| notebookUri: import('vscode').Uri, | ||
| fixes: Array<{ index: number; metadata: Record<string, unknown> }> | ||
| ): Promise<void> { | ||
| try { | ||
| this.processingChanges = true; | ||
|
|
||
| const edit = new WorkspaceEdit(); | ||
|
|
||
| // Create all the edits at once instead of calling set() multiple times | ||
| const edits = fixes.map((fix) => NotebookEdit.updateCellMetadata(fix.index, fix.metadata)); | ||
| edit.set(notebookUri, edits); | ||
|
|
||
| const success = await workspace.applyEdit(edit); | ||
|
|
||
| if (success) { | ||
| logger.debug(`DeepnoteCellCopyHandler: Successfully updated metadata for ${fixes.length} cell(s)`); | ||
| } else { | ||
| logger.warn(`DeepnoteCellCopyHandler: Failed to apply metadata fixes for ${fixes.length} cell(s)`); | ||
| } | ||
| } catch (error) { | ||
| logger.error('DeepnoteCellCopyHandler: Error applying metadata fixes', error); | ||
| } finally { | ||
| this.processingChanges = false; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Copy or cut a cell to the clipboard with metadata preserved. | ||
| * @param isCut Whether this is a cut operation (will delete the cell after copying) | ||
| */ | ||
| private async copyCellToClipboard(params: { isCut: boolean }): Promise<void> { | ||
| const editor = window.activeNotebookEditor; | ||
|
|
||
| if (!editor || !editor.notebook || editor.notebook.notebookType !== 'deepnote') { | ||
| logger.warn(`copyCellToClipboard called for non-Deepnote notebook`); | ||
| return; | ||
| } | ||
|
|
||
| const selection = editor.selection; | ||
| if (!selection) { | ||
| return; | ||
| } | ||
|
|
||
| const cellToCopy = editor.notebook.cellAt(selection.start); | ||
|
|
||
| // Create clipboard data with all cell information | ||
| const clipboardData: ClipboardCellMetadata = { | ||
| metadata: cellToCopy.metadata || {}, | ||
| kind: cellToCopy.kind, | ||
| languageId: cellToCopy.document.languageId, | ||
| value: cellToCopy.document.getText() | ||
| }; | ||
|
|
||
| // Store in clipboard as JSON with marker | ||
| const clipboardText = `${CLIPBOARD_MARKER}${JSON.stringify(clipboardData)}`; | ||
| await env.clipboard.writeText(clipboardText); | ||
|
|
||
| logger.debug(`DeepnoteCellCopyHandler: ${params.isCut ? 'Cut' : 'Copied'} cell to clipboard with metadata`); | ||
|
|
||
| // If this is a cut operation, delete the cell | ||
| if (params.isCut) { | ||
| const edit = new WorkspaceEdit(); | ||
| edit.set(editor.notebook.uri, [ | ||
| NotebookEdit.deleteCells(new NotebookRange(selection.start, selection.start + 1)) | ||
| ]); | ||
| await workspace.applyEdit(edit); | ||
| logger.debug(`DeepnoteCellCopyHandler: Deleted cell after cut operation`); | ||
| } | ||
| } | ||
jankuca marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| /** | ||
| * Paste a cell from the clipboard, restoring metadata if available. | ||
| */ | ||
| private async pasteCellFromClipboard(): Promise<void> { | ||
| const editor = window.activeNotebookEditor; | ||
|
|
||
| if (!editor || !editor.notebook || editor.notebook.notebookType !== 'deepnote') { | ||
| logger.warn(`pasteCellFromClipboard called for non-Deepnote notebook`); | ||
| return; | ||
| } | ||
|
|
||
| const selection = editor.selection; | ||
| if (!selection) { | ||
| return; | ||
| } | ||
|
|
||
| // Read from clipboard | ||
| const clipboardText = await env.clipboard.readText(); | ||
|
|
||
| // Check if clipboard contains our metadata marker | ||
| if (!clipboardText.startsWith(CLIPBOARD_MARKER)) { | ||
| logger.debug('DeepnoteCellCopyHandler: Clipboard does not contain Deepnote cell metadata, skipping'); | ||
| return; | ||
| } | ||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| try { | ||
| // Parse clipboard data | ||
| const jsonText = clipboardText.substring(CLIPBOARD_MARKER.length); | ||
| const clipboardData: ClipboardCellMetadata = JSON.parse(jsonText); | ||
|
|
||
| // Create new cell with preserved metadata | ||
| const newCell = new NotebookCellData(clipboardData.kind, clipboardData.value, clipboardData.languageId); | ||
|
|
||
| const insertIndex = selection.start; | ||
|
|
||
| // Copy metadata (ID and sortingKey will be generated by onDidChangeNotebookDocument) | ||
| const copiedMetadata = { ...clipboardData.metadata }; | ||
| newCell.metadata = copiedMetadata; | ||
|
|
||
| logger.debug('DeepnoteCellCopyHandler: Copying cell with metadata preserved'); | ||
|
|
||
| // Insert the new cell | ||
| const edit = new WorkspaceEdit(); | ||
| edit.set(editor.notebook.uri, [NotebookEdit.insertCells(insertIndex, [newCell])]); | ||
|
|
||
| const success = await workspace.applyEdit(edit); | ||
|
|
||
| if (success) { | ||
| // Move selection to the new cell | ||
| editor.selection = new NotebookRange(insertIndex, insertIndex + 1); | ||
| logger.debug(`DeepnoteCellCopyHandler: Successfully pasted cell at index ${insertIndex}`); | ||
| } else { | ||
| logger.warn('DeepnoteCellCopyHandler: Failed to paste cell'); | ||
| } | ||
| } catch (error) { | ||
| logger.error('DeepnoteCellCopyHandler: Error parsing clipboard data', error); | ||
| } | ||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.