diff --git a/package.json b/package.json index 538041a..0ec2cf7 100644 --- a/package.json +++ b/package.json @@ -87,6 +87,11 @@ "default": true, "description": "Automatically strip outputs from cells with conflicts" }, + "mergeNB.autoResolve.whitespace": { + "type": "boolean", + "default": true, + "description": "Automatically resolve conflicts when source differences are whitespace-only" + }, "mergeNB.ui.showCellHeaders": { "type": "boolean", "default": false, @@ -96,6 +101,11 @@ "type": "boolean", "default": true, "description": "Enable Ctrl+Z / Ctrl+Shift+Z undo and redo shortcuts in the conflict resolution web UI" + }, + "mergeNB.ui.showBaseColumn": { + "type": "boolean", + "default": false, + "description": "Show the base (common ancestor) branch column in the conflict resolution view. When false, only current and incoming columns are shown." } } } @@ -151,4 +161,4 @@ "markdown-it": "^14.1.0", "ws": "^8.19.0" } -} +} \ No newline at end of file diff --git a/src/conflictDetector.ts b/src/conflictDetector.ts index 13b9d17..d4457aa 100644 --- a/src/conflictDetector.ts +++ b/src/conflictDetector.ts @@ -19,6 +19,17 @@ import { matchCells, detectReordering } from './cellMatcher'; import { parseNotebook } from './notebookParser'; import { getSettings, MergeNBSettings } from './settings'; +function stripAllWhitespace(text: string): string { + return text.replace(/\r\n/g, '\n'); +} + +function isWhitespaceOnlyDifference(left: string, right: string): boolean { + if (left === right) return false; + const normalizeLines = (s: string) => + s.replace(/\r\n/g, '\n').split('\n').map(l => l.trimEnd()).join('\n'); + return normalizeLines(left) === normalizeLines(right); +} + /** * Result of auto-resolution preprocessing */ @@ -373,6 +384,38 @@ export function applyAutoResolutions( } } + // Auto-resolve whitespace-only differences when enabled + if (!autoResolved && effectiveSettings.autoResolveWhitespace) { + if (conflict.type === 'cell-modified') { + const currentSource = conflict.currentContent?.source; + const incomingSource = conflict.incomingContent?.source; + + const currentSourceStr = Array.isArray(currentSource) ? currentSource.join('') : (currentSource || ''); + const incomingSourceStr = Array.isArray(incomingSource) ? incomingSource.join('') : (incomingSource || ''); + + if (isWhitespaceOnlyDifference(currentSourceStr, incomingSourceStr)) { + autoResolved = true; + autoResolvedCount++; + autoResolvedDescriptions.push(`Whitespace-only change resolved (cell ${(conflict.currentCellIndex ?? 0) + 1})`); + } + } + + if (!autoResolved && conflict.type === 'cell-added' && conflict.currentContent && conflict.incomingContent) { + const currentSource = Array.isArray(conflict.currentContent.source) + ? conflict.currentContent.source.join('') + : conflict.currentContent.source; + const incomingSource = Array.isArray(conflict.incomingContent.source) + ? conflict.incomingContent.source.join('') + : conflict.incomingContent.source; + + if (isWhitespaceOnlyDifference(currentSource, incomingSource)) { + autoResolved = true; + autoResolvedCount++; + autoResolvedDescriptions.push(`Whitespace-only added cell resolved (cell ${(conflict.currentCellIndex ?? 0) + 1})`); + } + } + } + if (!autoResolved) { remainingConflicts.push(conflict); } diff --git a/src/resolver.ts b/src/resolver.ts index 111b7b4..e9736bd 100644 --- a/src/resolver.ts +++ b/src/resolver.ts @@ -11,7 +11,7 @@ * 6. Stages the resolved file in Git */ -import * as path from 'path'; +import * as path from 'path'; import * as vscode from 'vscode'; import { detectSemanticConflicts, applyAutoResolutions, AutoResolveResult } from './conflictDetector'; import { serializeNotebook, renumberExecutionCounts } from './notebookParser'; @@ -56,7 +56,7 @@ export interface ConflictedNotebook { * Main service for handling notebook merge conflict resolution. */ export class NotebookConflictResolver { - constructor(private readonly extensionUri: vscode.Uri) {} + constructor(private readonly extensionUri: vscode.Uri) { } /** * Check if a file has semantic conflicts (Git UU status). @@ -75,7 +75,7 @@ export class NotebookConflictResolver { async hasAnyConflicts(uri: vscode.Uri): Promise { try { const isUnmerged = await gitIntegration.isUnmergedFile(uri.fsPath); - + if (isUnmerged) { return { uri, @@ -94,24 +94,24 @@ export class NotebookConflictResolver { */ async findNotebooksWithConflicts(): Promise { const withConflicts: ConflictedNotebook[] = []; - + // Get unmerged files from Git status const unmergedFiles = await gitIntegration.getUnmergedFiles(); - + for (const file of unmergedFiles) { // Only process .ipynb files if (!file.path.endsWith('.ipynb')) { continue; } - + const uri = vscode.Uri.file(file.path); - + withConflicts.push({ uri, hasSemanticConflicts: true }); } - + return withConflicts; } @@ -134,7 +134,7 @@ export class NotebookConflictResolver { */ async resolveSemanticConflicts(uri: vscode.Uri): Promise { const semanticConflict = await detectSemanticConflicts(uri.fsPath); - + if (!semanticConflict) { vscode.window.showInformationMessage('No semantic conflicts detected.'); return; @@ -197,7 +197,8 @@ export class NotebookConflictResolver { semanticConflict: filteredSemanticConflict, autoResolveResult: autoResolveResult, hideNonConflictOutputs: settings.hideNonConflictOutputs, - enableUndoRedoHotkeys: settings.enableUndoRedoHotkeys + enableUndoRedoHotkeys: settings.enableUndoRedoHotkeys, + showBaseColumn: settings.showBaseColumn }; const resolutionCallback = async (resolution: UnifiedResolution): Promise => { @@ -227,12 +228,12 @@ export class NotebookConflictResolver { } const resolvedRows = resolution.resolvedRows; - + if (!resolvedRows || resolvedRows.length === 0) { // No resolutions provided if (autoResolveResult) { let resolvedNotebook = autoResolveResult.resolvedNotebook; - + const renumber = await vscode.window.showQuickPick( ['Yes', 'No'], { @@ -259,9 +260,9 @@ export class NotebookConflictResolver { } await this.applySemanticResolutionsFromRows( - uri, - semanticConflict, - resolvedRows, + uri, + semanticConflict, + resolvedRows, resolution.markAsResolved, resolution.renumberExecutionCounts, autoResolveResult @@ -320,7 +321,7 @@ export class NotebookConflictResolver { for (const row of rowsForResolution) { const { baseCell, currentCell, incomingCell, resolution: res } = row; - + let cellToUse: NotebookCell | undefined; if (res) { @@ -352,7 +353,7 @@ export class NotebookConflictResolver { metadata: referenceCell?.metadata ? JSON.parse(JSON.stringify(referenceCell.metadata)) : {}, source: resolvedContent.split(/(?<=\n)/) } as NotebookCell; - + if (cellType === 'code') { (cellToUse as any).execution_count = null; (cellToUse as any).outputs = []; @@ -394,7 +395,7 @@ export class NotebookConflictResolver { markAsResolved, renumberExecutionCounts: shouldRenumber }); - + // Show success notification (non-blocking, fire and forget) vscode.window.showInformationMessage( `Resolved conflicts in ${path.basename(uri.fsPath)}` @@ -408,12 +409,12 @@ export class NotebookConflictResolver { const content = serializeNotebook(notebook); const encoder = new TextEncoder(); await vscode.workspace.fs.writeFile(uri, encoder.encode(content)); - + // Mark as resolved with git add if requested if (markAsResolved) { await this.markFileAsResolved(uri); } - + // Fire event to notify extension (for status bar, decorations, etc.) onDidResolveConflict.fire(uri); } @@ -433,7 +434,7 @@ export class NotebookConflictResolver { vscode.window.showWarningMessage('Cannot mark file as resolved: not in a workspace'); return; } - + const relativePath = vscode.workspace.asRelativePath(uri, false); await exec(`git add "${relativePath}"`, { cwd: workspaceFolder.uri.fsPath }); vscode.window.showInformationMessage(`Marked ${relativePath} as resolved`); diff --git a/src/settings.ts b/src/settings.ts index 1c824b0..cf5debb 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -6,8 +6,10 @@ * - autoResolveExecutionCount: Set execution_count to null (default: true) * - autoResolveKernelVersion: Use current kernel/Python version (default: true) * - stripOutputs: Clear cell outputs during merge (default: true) + * - autoResolveWhitespace: Auto-resolve whitespace-only source diffs (default: true) * - hideNonConflictOutputs: Hide outputs for non-conflicted cells in UI (default: true) * - enableUndoRedoHotkeys: Enable Ctrl+Z / Ctrl+Shift+Z in web UI (default: true) + * - showBaseColumn: Show base branch column in 3-column view (default: false, true in headless/testing) * * These reduce manual conflict resolution for common non-semantic differences. */ @@ -24,8 +26,10 @@ export interface MergeNBSettings { autoResolveExecutionCount: boolean; autoResolveKernelVersion: boolean; stripOutputs: boolean; + autoResolveWhitespace: boolean; hideNonConflictOutputs: boolean; enableUndoRedoHotkeys: boolean; + showBaseColumn: boolean; } /** Default settings used in headless mode */ @@ -33,8 +37,10 @@ const DEFAULT_SETTINGS: MergeNBSettings = { autoResolveExecutionCount: true, autoResolveKernelVersion: true, stripOutputs: true, + autoResolveWhitespace: true, hideNonConflictOutputs: true, - enableUndoRedoHotkeys: true + enableUndoRedoHotkeys: true, + showBaseColumn: true }; /** @@ -45,15 +51,17 @@ export function getSettings(): MergeNBSettings { if (!vscode) { return { ...DEFAULT_SETTINGS }; } - + const config = vscode.workspace.getConfiguration('mergeNB'); - + return { autoResolveExecutionCount: config.get('autoResolve.executionCount', true), autoResolveKernelVersion: config.get('autoResolve.kernelVersion', true), stripOutputs: config.get('autoResolve.stripOutputs', true), + autoResolveWhitespace: config.get('autoResolve.whitespace', true), hideNonConflictOutputs: config.get('ui.hideNonConflictOutputs', true), enableUndoRedoHotkeys: config.get('ui.enableUndoRedoHotkeys', true), + showBaseColumn: config.get('ui.showBaseColumn', false), }; } diff --git a/src/tests/repoSetup.ts b/src/tests/repoSetup.ts index 0c74a5d..53d8136 100644 --- a/src/tests/repoSetup.ts +++ b/src/tests/repoSetup.ts @@ -44,6 +44,13 @@ export function createMergeConflictRepo( ): string { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mergeNB-integration-')); + const vscodeDir = path.join(tmpDir, '.vscode'); + fs.mkdirSync(vscodeDir, { recursive: true }); + const settingsPath = path.join(vscodeDir, 'settings.json'); + fs.writeFileSync(settingsPath, JSON.stringify({ + 'mergeNB.ui.showBaseColumn': true + }, null, 4)); + git(tmpDir, 'init'); git(tmpDir, 'config', 'user.email', '"test@mergenb.test"'); git(tmpDir, 'config', 'user.name', '"MergeNB Test"'); diff --git a/src/web/WebConflictPanel.ts b/src/web/WebConflictPanel.ts index b7b732e..7728a93 100644 --- a/src/web/WebConflictPanel.ts +++ b/src/web/WebConflictPanel.ts @@ -28,7 +28,7 @@ import { UnifiedConflict, UnifiedResolution, ResolvedRow } from './webTypes'; */ export class WebConflictPanel { public static currentPanel: WebConflictPanel | undefined; - + private readonly _extensionUri: vscode.Uri; private _conflict: UnifiedConflict | undefined; private _onResolutionComplete: ((resolution: UnifiedResolution) => Promise) | undefined; @@ -47,7 +47,7 @@ export class WebConflictPanel { const panel = new WebConflictPanel(extensionUri, conflict, onResolutionComplete); WebConflictPanel.currentPanel = panel; - + await panel._openInBrowser(); } @@ -72,7 +72,7 @@ export class WebConflictPanel { private async _openInBrowser(): Promise { const server = getWebServer(); server.setExtensionUri(this._extensionUri); - + // Start server if not running if (!server.isRunning()) { await server.start(); @@ -105,7 +105,7 @@ export class WebConflictPanel { if (!this._sessionId || !this._conflict) return; const server = getWebServer(); - + // Build the data payload for the React app const data = { filePath: this._conflict.filePath, @@ -114,25 +114,27 @@ export class WebConflictPanel { autoResolveResult: this._conflict.autoResolveResult, hideNonConflictOutputs: this._conflict.hideNonConflictOutputs, enableUndoRedoHotkeys: this._conflict.enableUndoRedoHotkeys, + showBaseColumn: this._conflict.showBaseColumn, currentBranch: this._conflict.semanticConflict?.currentBranch, incomingBranch: this._conflict.semanticConflict?.incomingBranch, }; + logger.debug(`[WebConflictPanel] Sending conflict data with showBaseColumn=${this._conflict.showBaseColumn}`); server.sendConflictData(this._sessionId, data); } private _handleMessage(message: unknown): void { if (this._isDisposed) return; - - const msg = message as { - command?: string; - type?: string; - resolutions?: Array<{ index: number; choice: string; resolvedContent: string }>; + + const msg = message as { + command?: string; + type?: string; + resolutions?: Array<{ index: number; choice: string; resolvedContent: string }>; resolvedRows?: ResolvedRow[]; - semanticChoice?: string; - markAsResolved?: boolean + semanticChoice?: string; + markAsResolved?: boolean }; - + logger.debug('[WebConflictPanel] Received message:', msg.command || msg.type); switch (msg.command) { @@ -150,11 +152,11 @@ export class WebConflictPanel { } } - private async _handleResolution(message: { - type?: string; - resolutions?: Array<{ index: number; choice: string; resolvedContent: string }>; + private async _handleResolution(message: { + type?: string; + resolutions?: Array<{ index: number; choice: string; resolvedContent: string }>; resolvedRows?: ResolvedRow[]; - semanticChoice?: string; + semanticChoice?: string; markAsResolved?: boolean; renumberExecutionCounts?: boolean; }): Promise { @@ -176,7 +178,7 @@ export class WebConflictPanel { markAsResolved: message.markAsResolved ?? false, renumberExecutionCounts: message.renumberExecutionCounts ?? false }); - + // Send success message to browser if (this._sessionId) { const server = getWebServer(); @@ -184,14 +186,14 @@ export class WebConflictPanel { type: 'resolution-success', message: 'Conflicts resolved successfully!' }); - + // Wait to ensure message is delivered to browser await new Promise(resolve => setTimeout(resolve, 500)); } } catch (error) { logger.error('[WebConflictPanel] Error applying semantic resolutions:', error); vscode.window.showErrorMessage(`Failed to apply resolutions: ${error}`); - + // Send error message to browser if (this._sessionId) { const server = getWebServer(); @@ -210,14 +212,14 @@ export class WebConflictPanel { public dispose(): void { if (this._isDisposed) return; this._isDisposed = true; - + WebConflictPanel.currentPanel = undefined; - + if (this._sessionId) { const server = getWebServer(); server.closeSession(this._sessionId); } - + logger.debug('[WebConflictPanel] Disposed'); } } diff --git a/src/web/client/CellContent.tsx b/src/web/client/CellContent.tsx index 2205552..f85d286 100644 --- a/src/web/client/CellContent.tsx +++ b/src/web/client/CellContent.tsx @@ -19,6 +19,8 @@ interface CellContentProps { side: 'base' | 'current' | 'incoming'; isConflict?: boolean; compareCell?: NotebookCell; + baseCell?: NotebookCell; + diffMode?: 'base' | 'conflict'; showOutputs?: boolean; onDragStart?: (e: React.DragEvent) => void; onDragEnd?: () => void; @@ -31,6 +33,8 @@ export function CellContent({ side, isConflict = false, compareCell, + baseCell, + diffMode = 'base', showOutputs = true, onDragStart, onDragEnd, @@ -86,10 +90,15 @@ export function CellContent({ data-cell-type={cellType} >
- {cellType === 'markdown' ? ( + {cellType === 'markdown' && !isConflict ? ( - ) : isConflict && compareCell ? ( - + ) : isConflict && (compareCell || baseCell) ? ( + // Show conflict diffs as raw text (no markdown rendering) + ) : (
{source}
)} @@ -120,10 +129,11 @@ function MarkdownContent({ source }: MarkdownContentProps): React.ReactElement { interface DiffContentProps { source: string; compareSource: string; - side: 'base' | 'current' | 'incoming'; + side?: 'base' | 'current' | 'incoming'; + diffMode: 'base' | 'conflict'; } -function DiffContent({ source, compareSource, side }: DiffContentProps): React.ReactElement { +function DiffContent({ source, compareSource, side, diffMode }: DiffContentProps): React.ReactElement { const diff = computeLineDiff(compareSource, source); // Use the right side for display (shows the "new" content with change markers) const diffLines = diff.right; @@ -132,58 +142,98 @@ function DiffContent({ source, compareSource, side }: DiffContentProps): React.R return (
-            {visibleLines.map((line, i) => (
-                
-                    
-                        {line.inlineChanges ? (
-                            line.inlineChanges.map((change, j) => (
-                                
-                                    {change.text}
-                                
-                            ))
-                        ) : (
-                            line.content
-                        )}
-                    
-                    {i < visibleLines.length - 1 ? '\n' : ''}
-                
-            ))}
+            {visibleLines.map((line, i) => {
+                const whitespaceOnly = isWhitespaceOnlyLineChange(line);
+                return (
+                    
+                        
+                            {line.inlineChanges ? (
+                                line.inlineChanges.map((change, j) => (
+                                    
+                                        {change.text}
+                                    
+                                ))
+                            ) : (
+                                line.content
+                            )}
+                        
+                        {i < visibleLines.length - 1 ? '\n' : ''}
+                    
+                );
+            })}
         
); } /** * Get CSS class for diff line based on type and side. + * Branch-based coloring: green for current, blue for incoming. */ -function getDiffLineClass(line: DiffLine, side: 'base' | 'current' | 'incoming'): string { +function getDiffLineClass( + line: DiffLine, + side: 'base' | 'current' | 'incoming', + diffMode: 'base' | 'conflict', + isWhitespaceOnly: boolean +): string { switch (line.type) { case 'unchanged': return 'diff-line'; case 'added': - return 'diff-line added'; case 'removed': - return 'diff-line removed'; case 'modified': - // Modified lines show inline changes - return side === 'current' ? 'diff-line modified-old' : 'diff-line modified-new'; + if (diffMode === 'conflict' || isWhitespaceOnly) { + return 'diff-line diff-line-conflict'; + } + // Use branch-based coloring: the color tells you which branch the content comes from + return side === 'current' ? 'diff-line diff-line-current' : 'diff-line diff-line-incoming'; default: return 'diff-line'; } } -function getInlineChangeClass(type: 'unchanged' | 'added' | 'removed', side: 'base' | 'current' | 'incoming'): string { +function getInlineChangeClass( + type: 'unchanged' | 'added' | 'removed', + side: 'base' | 'current' | 'incoming', + diffMode: 'base' | 'conflict', + isWhitespaceOnly: boolean +): string { switch (type) { case 'unchanged': return 'diff-inline-unchanged'; case 'added': - return 'diff-inline-added'; case 'removed': - return 'diff-inline-removed'; + if (diffMode === 'conflict' || isWhitespaceOnly) { + return 'diff-inline-conflict'; + } + // Use branch-based coloring for inline changes too + return side === 'current' ? 'diff-inline-current' : 'diff-inline-incoming'; default: return ''; } } +function isWhitespaceOnlyLineChange(line: DiffLine): boolean { + if (line.type === 'unchanged') return false; + + if (line.inlineChanges && line.inlineChanges.length > 0) { + let hasChange = false; + for (const change of line.inlineChanges) { + if (change.type === 'unchanged') continue; + if (change.text.trim() !== '') { + return false; + } + hasChange = true; + } + return hasChange; + } + + if (line.type === 'added' || line.type === 'removed') { + return line.content.trim() === '' && line.content.length > 0; + } + + return false; +} + interface CellOutputsProps { outputs: CellOutput[]; isVisible?: boolean; diff --git a/src/web/client/ConflictResolver.tsx b/src/web/client/ConflictResolver.tsx index 9ce6c64..db30a9d 100644 --- a/src/web/client/ConflictResolver.tsx +++ b/src/web/client/ConflictResolver.tsx @@ -205,6 +205,7 @@ export function ConflictResolver({ const canUndo = history.index > 0; const canRedo = history.index < history.entries.length - 1; const enableUndoRedoHotkeys = conflict.enableUndoRedoHotkeys ?? true; + const showBaseColumn = conflict.showBaseColumn ?? false; const isMac = useMemo(() => /Mac|iPod|iPhone|iPad/.test(navigator.platform), []); const undoShortcutLabel = isMac ? 'Cmd+Z' : 'Ctrl+Z'; const redoShortcutLabel = isMac ? 'Cmd+Shift+Z' : 'Ctrl+Shift+Z'; @@ -811,20 +812,22 @@ export function ConflictResolver({
- + {showBaseColumn && ( + + )}