diff --git a/src/diff/file.ts b/src/diff/file.ts new file mode 100644 index 0000000..7fc07bd --- /dev/null +++ b/src/diff/file.ts @@ -0,0 +1,469 @@ +import { MergeView } from '@codemirror/merge'; +import { basicSetup } from 'codemirror'; +import { EditorView, ViewUpdate } from '@codemirror/view'; +import { jupyterTheme } from '@jupyterlab/codemirror'; +import { Widget } from '@lumino/widgets'; +import type { IDocumentWidget } from '@jupyterlab/docregistry'; +import type { FileEditor } from '@jupyterlab/fileeditor'; +import type { TranslationBundle } from '@jupyterlab/translation'; +import type { CodeMirrorEditor } from '@jupyterlab/codemirror'; +import { Compartment } from '@codemirror/state'; +import { undo, redo } from '@codemirror/commands'; +import { + undoIcon, + redoIcon, + checkIcon, + closeIcon, + LabIcon +} from '@jupyterlab/ui-components'; + +export interface ISplitFileDiffOptions { + /** + * The file editor widget (document widget) that contains the CodeMirror editor. + * This is optional but helpful for toolbar placement or context. + */ + fileEditorWidget?: IDocumentWidget; + + /** + * The CodeMirrorEditor instance for the file being compared + */ + editor: CodeMirrorEditor; + + /** + * Original source text + */ + originalSource: string; + + /** + * New / modified source text + */ + newSource: string; + + /** + * Translation bundle (optional, kept for parity with other APIs) + */ + trans?: TranslationBundle; + + /** + * Whether to open the diff immediately (defaults to true). + */ + openDiff?: boolean; +} + +/** + * A Lumino widget that contains a CodeMirror MergeView (side-by-side) + * for file diffs. This left pane is view only and right pane in editable. + */ +export class CodeMirrorSplitFileWidget extends Widget { + private _originalCode: string; + private _modifiedCode: string; + private _mergeView: MergeView | null = null; + private _openDiff: boolean; + private _scrollWrapper: HTMLElement; + private _fileEditorWidget?: IDocumentWidget; + private _listenerCompartment?: Compartment; + + private _isSyncingScroll = false; + + private _rebuildTimeout: number | null = null; + private _rebuildDelay = 300; + + private _toolbarElement: HTMLElement | null = null; + + constructor(options: ISplitFileDiffOptions) { + super(); + this.addClass('jp-SplitFileDiffView'); + this._originalCode = options.originalSource; + this._modifiedCode = options.newSource; + this._openDiff = options.openDiff ?? true; + this._fileEditorWidget = options.fileEditorWidget; + + this.node.style.display = 'flex'; + this.node.style.flexDirection = 'column'; + this.node.style.height = '100%'; + this.node.style.width = '100%'; + + this._toolbarElement = document.createElement('div'); + this._toolbarElement.className = 'jp-SplitFileDiff-toolbar'; + this._toolbarElement.style.display = 'flex'; + this._toolbarElement.style.gap = '8px'; + this._toolbarElement.style.padding = '6px'; + this._toolbarElement.style.alignItems = 'center'; + this.node.appendChild(this._toolbarElement); + + // Scrollable wrapper for MergeView (fills remaining space) + this._scrollWrapper = document.createElement('div'); + this._scrollWrapper.classList.add('jp-SplitDiff-scroll'); + this._scrollWrapper.style.flex = '1 1 auto'; + this._scrollWrapper.style.overflow = 'auto'; + this._scrollWrapper.style.minHeight = '0'; + this.node.appendChild(this._scrollWrapper); + + this._buildToolbarButtons(); + } + + protected onAfterAttach(): void { + this._createSplitView(); + } + + protected onBeforeDetach(): void { + this._destroySplitView(); + } + + private _createSplitView(): void { + if (this._mergeView) { + return; + } + + // Create MergeView — left (a) readonly, right (b) editable + this._mergeView = new MergeView({ + a: { + doc: this._originalCode, + extensions: [ + basicSetup, + EditorView.editable.of(false), + EditorView.lineWrapping, + jupyterTheme + ] + }, + b: { + doc: this._modifiedCode, + extensions: [basicSetup, EditorView.lineWrapping, jupyterTheme] + }, + parent: this._scrollWrapper + }); + + this._enableRightPaneSync(); + + // Set up scroll sync between left & right + this._enableScrollSync(); + + if (!this._openDiff) { + this.hide(); + } + } + + private _destroySplitView(): void { + if (this._rebuildTimeout) { + window.clearTimeout(this._rebuildTimeout); + this._rebuildTimeout = null; + } + + // remove compartments if any + this._listenerCompartment = undefined; + + if (this._mergeView) { + try { + this._mergeView.destroy(); + } catch (err) { + console.warn('Error destroying split-file merge view', err); + } + this._mergeView = null; + } + } + + dispose(): void { + this._destroySplitView(); + super.dispose(); + } + + /** + * Build top toolbar with Undo / Redo / Accept All / Reject All buttons. + * Undo/Redo operate on the right editor. Accept All writes right -> model. + * Reject All resets right editor to the original source. + */ + private _buildToolbarButtons(): void { + if (!this._toolbarElement) { + return; + } + + const makeButton = ( + label: string, + icon: LabIcon, + tooltip: string, + onClick: () => void + ) => { + const button = document.createElement('button'); + button.type = 'button'; + button.title = tooltip; + button.classList.add('jp-SplitFileDiff-btn'); + button.addEventListener('click', onClick); + + const iconElement = icon.element({ + stylesheet: 'toolbarButton', + tag: 'span' + }); + iconElement.setAttribute('aria-hidden', 'true'); + button.appendChild(iconElement); + + const span = document.createElement('span'); + span.textContent = label; + span.style.marginLeft = '4px'; + button.appendChild(span); + + return button; + }; + + // Undo button + const undoButton = makeButton('Undo', undoIcon, 'Undo last change', () => { + const right = this._mergeView?.b; + if (right) { + undo(right); + } + }); + + // Redo button + const redoButton = makeButton('Redo', redoIcon, 'Redo last change', () => { + const right = this._mergeView?.b; + if (right) { + redo(right); + } + }); + + // Accept All (write right -> shared model) + const acceptAllButton = makeButton( + 'Accept All', + checkIcon, + 'Accept all changes', + () => { + const right = this._mergeView?.b; + if (!right || !this._fileEditorWidget) { + return; + } + const newText = right.state.doc.toString(); + this._fileEditorWidget.content.model.sharedModel.setSource(newText); + this._scheduleRebuildImmediate(); + + this.dispose(); + } + ); + + // Reject All (reset right -> original) + const rejectAllButton = makeButton( + 'Reject All', + closeIcon, + 'Reject all changes', + () => { + const right = this._mergeView?.b; + if (!right) { + return; + } + const leftText = + this._mergeView?.a?.state?.doc?.toString() ?? this._originalCode; + right.dispatch({ + changes: { from: 0, to: right.state.doc.length, insert: leftText } + }); + if (this._fileEditorWidget) { + this._fileEditorWidget.content.model.sharedModel.setSource(leftText); + } + this._scheduleRebuildImmediate(); + + this.dispose(); + } + ); + + this._toolbarElement.appendChild(undoButton); + this._toolbarElement.appendChild(redoButton); + + const spacer = document.createElement('div'); + spacer.style.flex = '1 1 auto'; + this._toolbarElement.appendChild(spacer); + + this._toolbarElement.appendChild(rejectAllButton); + this._toolbarElement.appendChild(acceptAllButton); + } + + /** + * Enable sync of right editor changes -> JupyterLab model (sharedModel), + * and trigger a debounced rebuild of the MergeView to refresh highlights. + */ + private _enableRightPaneSync(): void { + if (!this._fileEditorWidget || !this._mergeView?.b) { + // Even when no fileEditorWidget is provided we still want to auto-rebuild highlights. + } + + const rightEditor = this._mergeView?.b; + if (!rightEditor) { + return; + } + + // create a compartment for attaching the update listener to the right editor + this._listenerCompartment = new Compartment(); + + const updateListener = EditorView.updateListener.of( + (update: ViewUpdate) => { + // if document changed: + if (update.docChanged) { + const newText = update.state.doc.toString(); + + // If we have a FileEditor model, update the sharedModel — this marks file dirty + if (this._fileEditorWidget) { + try { + this._fileEditorWidget.content.model.sharedModel.setSource( + newText + ); + } catch (err) { + console.warn('Error syncing right pane to sharedModel', err); + } + } + + // Debounced rebuild so MergeView highlights update after the edit. + this._scheduleRebuildDebounced(); + } + } + ); + + rightEditor.dispatch({ + effects: this._listenerCompartment.reconfigure(updateListener) + }); + } + + /** + * Debounce helper: schedule a rebuild after _rebuildDelay ms of inactivity. + */ + private _scheduleRebuildDebounced(): void { + if (this._rebuildTimeout) { + window.clearTimeout(this._rebuildTimeout); + } + this._rebuildTimeout = window.setTimeout(() => { + this._rebuildTimeout = null; + this._rebuildMergeViewPreserveState(); + }, this._rebuildDelay); + } + + /** + * Immediate rebuild trigger (used after Accept/Reject) + */ + private _scheduleRebuildImmediate(): void { + if (this._rebuildTimeout) { + window.clearTimeout(this._rebuildTimeout); + this._rebuildTimeout = null; + } + this._rebuildMergeViewPreserveState(); + } + + /** + * Rebuild the MergeView using current left/right text while attempting to + * preserve cursor/scroll/selection state for the right editor. + * Diff must refresh fully after changes, Keep left/right diffs aligned + */ + private _rebuildMergeViewPreserveState(): void { + if (!this._mergeView) { + return; + } + + // read current texts & right-state + const leftText = + this._mergeView.a?.state?.doc?.toString() ?? this._originalCode; + const rightEditor = this._mergeView.b; + const rightText = rightEditor?.state?.doc?.toString() ?? this._modifiedCode; + + const rightSelection = rightEditor?.state?.selection; + const rightScrollTop = rightEditor?.scrollDOM?.scrollTop ?? 0; + const rightScrollLeft = rightEditor?.scrollDOM?.scrollLeft ?? 0; + + // Destroy existing view + try { + this._mergeView.destroy(); + } catch (err) { + console.warn('Error destroying merge view during rebuild', err); + } + this._mergeView = null; + + // Recreate + this._originalCode = leftText; + this._modifiedCode = rightText; + + this._mergeView = new MergeView({ + a: { + doc: leftText, + extensions: [ + basicSetup, + EditorView.editable.of(false), + EditorView.lineWrapping, + jupyterTheme + ] + }, + b: { + doc: rightText, + extensions: [basicSetup, EditorView.lineWrapping, jupyterTheme] + }, + parent: this._scrollWrapper + }); + + // re-attach listeners / sync / scroll / selection + this._enableRightPaneSync(); + this._enableScrollSync(); + + // restore selection & scroll on right editor + const newRight = this._mergeView.b; + if (newRight && rightSelection) { + try { + newRight.dispatch({ + selection: rightSelection + } as any); + } catch (err) { + // selection restore is best-effort + } + // restore scroll + if (newRight.scrollDOM) { + newRight.scrollDOM.scrollTop = rightScrollTop; + newRight.scrollDOM.scrollLeft = rightScrollLeft; + } + } + } + + /** + * Sync vertical scroll between left & right editors. + * Avoid infinite loops by a simple boolean guard. + */ + private _enableScrollSync(): void { + if (!this._mergeView?.a || !this._mergeView?.b) { + return; + } + + const left = this._mergeView.a; + const right = this._mergeView.b; + + const syncFrom = (from: EditorView, to: EditorView) => { + const fromDOM = from.scrollDOM; + const toDOM = to.scrollDOM; + if (!fromDOM || !toDOM) { + return; + } + + const onScroll = () => { + if (this._isSyncingScroll) { + return; + } + this._isSyncingScroll = true; + const fraction = + fromDOM.scrollTop / + Math.max(1, fromDOM.scrollHeight - fromDOM.clientHeight); + toDOM.scrollTop = Math.floor( + fraction * Math.max(0, toDOM.scrollHeight - toDOM.clientHeight) + ); + // small timeout to release guard + window.setTimeout(() => { + this._isSyncingScroll = false; + }, 10); + }; + + fromDOM.addEventListener('scroll', onScroll, { passive: true }); + }; + + syncFrom(left, right); + syncFrom(right, left); + } +} + +/** + * Factory to create a CodeMirrorSplitFileWidget. + * Keep the signature async to match other factories and future expansion. + */ +export async function createCodeMirrorSplitFileWidget( + options: ISplitFileDiffOptions +): Promise { + const widget = new CodeMirrorSplitFileWidget(options); + return widget; +} diff --git a/src/plugin.ts b/src/plugin.ts index 07d042e..51b0019 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -423,8 +423,112 @@ const unifiedFileDiffPlugin: JupyterFrontEndPlugin = { } }; +/** + * Split file diff plugin (side-by-side) + */ +const splitFileDiffPlugin: JupyterFrontEndPlugin = { + id: 'jupyterlab-diff:split-file-diff-plugin', + description: 'Show file diff using side-by-side split view', + requires: [IEditorTracker], + optional: [ITranslator], + autoStart: true, + activate: async ( + app: JupyterFrontEnd, + editorTracker: IEditorTracker, + translator: ITranslator | null + ) => { + const { commands } = app; + const trans = (translator ?? nullTranslator).load(TRANSLATION_NAMESPACE); + + commands.addCommand('jupyterlab-diff:split-file-diff', { + label: trans.__('Diff File (Split view)'), + describedBy: { + args: { + type: 'object', + properties: { + filePath: { + type: 'string', + description: trans.__( + 'Path to the file to diff. Defaults to current file in editor.' + ) + }, + originalSource: { + type: 'string', + description: trans.__('Original source code to compare against') + }, + newSource: { + type: 'string', + description: trans.__('New source code to compare with') + }, + openDiff: { + type: 'boolean', + description: trans.__('Whether to open the diff automatically') + } + }, + required: ['originalSource', 'newSource'] + } + }, + execute: async (args: any = {}) => { + const { filePath, originalSource, newSource, openDiff = true } = args; + + if (!originalSource || !newSource) { + console.error( + trans.__('Missing required arguments: originalSource and newSource') + ); + return; + } + + // Resolve the file editor widget: prefer the one matching filePath if provided. + let fileEditorWidget = editorTracker.currentWidget; + if (filePath) { + const found = editorTracker.find(widget => { + return widget.context?.path === filePath; + }); + if (found) { + fileEditorWidget = found; + } + } + + if (!fileEditorWidget) { + console.error(trans.__('No editor found for the file')); + return; + } + + // Grab the CodeMirrorEditor instance from the FileEditor content + // FileEditor.content.editor should be the underlying editor instance. + const editor = fileEditorWidget.content + .editor as any as CodeMirrorEditor; + if (!editor) { + console.error(trans.__('No code editor found in the file widget')); + return; + } + + // Create the split widget and add to main area + const { createCodeMirrorSplitFileWidget } = await import('./diff/file'); + + const widget = await createCodeMirrorSplitFileWidget({ + fileEditorWidget, + editor, + originalSource, + newSource, + trans, + openDiff + }); + + widget.id = `jp-split-file-diff-${Date.now()}`; + widget.title.label = `Split Diff: ${fileEditorWidget.title.label}`; + widget.title.closable = true; + + app.shell.add(widget, 'main'); + app.shell.activateById(widget.id); + } + }); + } +}; + export default [ splitCellDiffPlugin, unifiedCellDiffPlugin, - unifiedFileDiffPlugin + unifiedFileDiffPlugin, + splitFileDiffPlugin ]; diff --git a/style/base.css b/style/base.css index 82fd63e..b73e3b5 100644 --- a/style/base.css +++ b/style/base.css @@ -77,3 +77,74 @@ background-color: var(--jp-layout-color3); border-color: var(--jp-border-color1); } + +/* Root container: allow layout to flex & scroll correctly */ +.jp-SplitFileDiffView { + display: flex; + flex-direction: column; + height: 100%; + width: 100%; + min-height: 0; + min-width: 0; + background-color: var(--jp-layout-color0); + color: var(--jp-ui-font-color1); +} + +/* Scroll container: hosts the side-by-side MergeView */ +.jp-SplitDiff-scroll { + flex: 1 1 auto; + overflow: auto; + min-height: 0; +} + +/* CodeMirror internal scroll area */ +.jp-SplitDiff-scroll .cm-scroller { + height: 100%; + overflow: auto; +} + +/* Allow editor to shrink instead of expanding infinitely */ +.jp-SplitDiff-scroll .cm-editor { + min-height: 0; +} + +.jp-SplitFileDiff-btn { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 10px; + height: 28px; + font-size: var(--jp-ui-font-size1); + color: var(--jp-ui-font-color1); + border: 1px solid var(--jp-border-color1); + background: var(--jp-layout-color1); + border-radius: 4px; + cursor: pointer; + transition: + background-color 0.12s, + border-color 0.12s; +} + +.jp-SplitFileDiff-btn .jp-Icon { + width: 16px; + height: 16px; + margin-right: 2px; +} + +.jp-SplitFileDiff-btn:hover, +.jp-SplitFileDiff-btn:focus-visible { + background: var(--jp-layout-color2); + border-color: var(--jp-border-color2); + filter: brightness(1.05); + transform: translateY(-1px); + box-shadow: 0 2px 4px rgb(0 0 0 / 20%); +} + +.jp-SplitFileDiff-btn:focus-visible { + outline: 2px solid var(--jp-brand-color1); + outline-offset: 2px; +} + +.jp-SplitFileDiff-btn:active { + background: var(--jp-layout-color3); +}