webui: implement multi-file diff view with continuous scrolling
Replace file selector with GitHub PR-style continuous scrolling through
multiple files in a single view, improving diff navigation experience
while maintaining all Monaco editor features.
Problem Analysis:
The existing diff view required users to navigate between files using a
dropdown selector and Previous/Next buttons. This created friction when
reviewing multi-file changes and broke the natural scrolling flow that
users expect from GitHub PR views or other modern diff interfaces.
The limitation was that Monaco doesn't provide a built-in multi-file diff
widget, requiring custom implementation with multiple IStandaloneDiffEditor
instances properly configured for stacking and auto-sizing.
Implementation Changes:
1. Multi-File Layout (sketch-diff2-view.ts):
- Replaced sketch-diff-file-picker with simple file count display
- Implemented renderFileDiff() to create separate diff sections per file
- Added renderFileHeader() with status badges and path information
- Created multi-file-diff-container with vertical stacking layout
- Added loadAllFileContents() for parallel content loading
- Replaced single originalCode/modifiedCode with Map<string, FileContent>
2. Monaco Auto-Sizing (sketch-monaco-view.ts):
- Configured diff editors with hidden scrollbars per Monaco ≥0.49 pattern
- Added setupAutoSizing() with content height calculation
- Implemented fitEditorToContent() using getContentHeight() callbacks
- Set automaticLayout: false for manual size control
- Added scrollbar: { vertical: 'hidden', horizontal: 'hidden', handleMouseWheel: false }
- Enabled minimap: false and scrollBeyondLastLine: false
3. CSS Styling (sketch-diff2-view.ts):
- Added file-diff-section with bottom borders for visual separation
- Implemented sticky file headers with proper z-index
- Created status badges (added, modified, deleted, renamed) with color coding
- Added file-count display replacing old file picker interface
- Configured diff-container with overflow: auto for outer scrolling
4. Content Management:
- Parallel loading of all file contents with error handling
- Maintains editability detection per file based on commit range
- Preserves comment and save functionality for individual files
- Updated toggleHideUnchangedRegions to apply to all editors
Technical Details:
- Uses Monaco's getContentHeight() and onDidContentSizeChange() for auto-sizing
- Each diff editor sized to Math.max(originalHeight, modifiedHeight) + 18px padding
- Outer container handles all scrolling while inner editors are sized to content
- File headers show status (Added/Modified/Deleted/Renamed) with appropriate styling
- Sticky positioning keeps file context visible during scrolling
- Maintains all existing features: editing, commenting, expand/collapse toggles
Benefits:
- Natural scrolling workflow similar to GitHub PR reviews
- Eliminates need for dropdown navigation between files
- Better visual context with file headers and status indicators
- Continuous viewing experience for multi-file changes
- Preserves all advanced Monaco features (editing, commenting, etc.)
- Improved performance with parallel content loading
Testing:
- Verified multi-file diff display with various commit ranges
- Tested scrolling behavior between files works smoothly
- Confirmed auto-sizing works correctly for different file sizes
- Validated file headers show correct status and change counts
- Ensured editing and commenting functionality preserved
- Tested expand/collapse toggles apply to all editors
This implementation follows the Monaco ≥0.49 multi-file diff pattern with
disabled inner scrollbars, auto-sizing to content, and outer scroll container,
providing a modern diff experience while maintaining full editor functionality.
Co-Authored-By: sketch <hello@sketch.dev>
Change-ID: s0724a00944669c80k
diff --git a/webui/src/web-components/sketch-monaco-view.ts b/webui/src/web-components/sketch-monaco-view.ts
index f472058..aaf186f 100644
--- a/webui/src/web-components/sketch-monaco-view.ts
+++ b/webui/src/web-components/sketch-monaco-view.ts
@@ -231,23 +231,21 @@
--editor-width: 100%;
--editor-height: 100%;
display: flex;
- flex: 1;
+ flex: none; /* Don't grow/shrink - size is determined by content */
min-height: 0; /* Critical for flex layout */
position: relative; /* Establish positioning context */
- height: 100%; /* Take full height */
width: 100%; /* Take full width */
+ /* Height will be set dynamically by setupAutoSizing */
}
main {
- width: var(--editor-width);
- height: var(--editor-height);
+ width: 100%;
+ height: 100%; /* Fill the host element completely */
border: 1px solid #e0e0e0;
- flex: 1;
- min-height: 300px; /* Ensure a minimum height for the editor */
- position: absolute; /* Absolute positioning to take full space */
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
+ flex: none; /* Size determined by parent */
+ min-height: 200px; /* Ensure a minimum height for the editor */
+ /* Remove absolute positioning - use normal block layout */
+ position: relative;
+ display: block;
}
/* Comment indicator and box styles */
@@ -582,6 +580,10 @@
setOptions(value: monaco.editor.IDiffEditorConstructionOptions) {
if (this.editor) {
this.editor.updateOptions(value);
+ // Re-fit content after options change
+ if (this.fitEditorToContent) {
+ setTimeout(() => this.fitEditorToContent!(), 50);
+ }
}
}
@@ -598,6 +600,10 @@
revealLineCount: 10,
},
});
+ // Re-fit content after toggling
+ if (this.fitEditorToContent) {
+ setTimeout(() => this.fitEditorToContent!(), 100);
+ }
}
}
@@ -619,15 +625,21 @@
// First time initialization
if (!this.editor) {
- // Create the diff editor only once
+ // Create the diff editor with auto-sizing configuration
this.editor = monaco.editor.createDiffEditor(this.container.value!, {
- automaticLayout: true,
- // Make it read-only by default
- // We'll adjust individual editor settings after creation
+ automaticLayout: false, // We'll resize manually
readOnly: true,
theme: "vs", // Always use light mode
renderSideBySide: true,
ignoreTrimWhitespace: false,
+ scrollbar: {
+ vertical: 'hidden',
+ horizontal: 'hidden',
+ handleMouseWheel: false, // Let outer scroller eat the wheel
+ },
+ minimap: { enabled: false },
+ overviewRulerLanes: 0,
+ scrollBeyondLastLine: false,
// Focus on the differences by hiding unchanged regions
hideUnchangedRegions: {
enabled: true, // Enable the feature
@@ -650,6 +662,9 @@
this.editor.getModifiedEditor().updateOptions({ readOnly: false });
}
+ // Set up auto-sizing
+ this.setupAutoSizing();
+
// Add Monaco editor to debug global
this.addToDebugGlobal();
}
@@ -660,21 +675,19 @@
this.setupContentChangeListener();
// Fix cursor positioning issues by ensuring fonts are loaded
- // This addresses the common Monaco editor cursor offset problem
document.fonts.ready.then(() => {
if (this.editor) {
monaco.editor.remeasureFonts();
- this.editor.layout();
+ this.fitEditorToContent();
}
});
// Force layout recalculation after a short delay
- // This ensures the editor renders properly, especially with single files
setTimeout(() => {
if (this.editor) {
- this.editor.layout();
+ this.fitEditorToContent();
}
- }, 50);
+ }, 100);
} catch (error) {
console.error("Error initializing Monaco editor:", error);
}
@@ -1067,6 +1080,21 @@
original: this.originalModel,
modified: this.modifiedModel,
});
+
+ // Set initial hideUnchangedRegions state (default to enabled/collapsed)
+ this.editor.updateOptions({
+ hideUnchangedRegions: {
+ enabled: true, // Default to collapsed state
+ contextLineCount: 3,
+ minimumLineCount: 3,
+ revealLineCount: 10,
+ },
+ });
+
+ // Fit content after setting new models
+ if (this.fitEditorToContent) {
+ setTimeout(() => this.fitEditorToContent!(), 50);
+ }
}
this.setupContentChangeListener();
} catch (error) {
@@ -1086,12 +1114,13 @@
if (this.editor) {
this.updateModels();
- // Force layout recalculation after model updates
+ // Force auto-sizing after model updates
+ // Use a slightly longer delay to ensure layout is stable
setTimeout(() => {
- if (this.editor) {
- this.editor.layout();
+ if (this.fitEditorToContent) {
+ this.fitEditorToContent();
}
- }, 50);
+ }, 100);
} else {
// If the editor isn't initialized yet but we received content,
// initialize it now
@@ -1100,27 +1129,72 @@
}
}
+ // Set up auto-sizing for multi-file diff view
+ private setupAutoSizing() {
+ if (!this.editor) return;
+
+ const fitContent = () => {
+ try {
+ const originalEditor = this.editor!.getOriginalEditor();
+ const modifiedEditor = this.editor!.getModifiedEditor();
+
+ const originalHeight = originalEditor.getContentHeight();
+ const modifiedHeight = modifiedEditor.getContentHeight();
+
+ // Use the maximum height of both editors, plus some padding
+ const maxHeight = Math.max(originalHeight, modifiedHeight) + 18; // 1 blank line bottom padding
+
+ // Set both container and host height to enable proper scrolling
+ if (this.container.value) {
+ // Set explicit heights on both container and host
+ this.container.value.style.height = `${maxHeight}px`;
+ this.style.height = `${maxHeight}px`; // Update host element height
+
+ // Emit the height change event BEFORE calling layout
+ // This ensures parent containers resize first
+ this.dispatchEvent(new CustomEvent('monaco-height-changed', {
+ detail: { height: maxHeight },
+ bubbles: true,
+ composed: true
+ }));
+
+ // Layout after both this component and parents have updated
+ setTimeout(() => {
+ if (this.editor && this.container.value) {
+ // Use explicit dimensions to ensure Monaco uses full available space
+ const width = this.container.value.offsetWidth;
+ this.editor.layout({
+ width: width,
+ height: maxHeight
+ });
+ }
+ }, 10);
+ }
+ } catch (error) {
+ console.error('Error in fitContent:', error);
+ }
+ };
+
+ // Store the fit function for external access
+ this.fitEditorToContent = fitContent;
+
+ // Set up listeners for content size changes
+ this.editor.getOriginalEditor().onDidContentSizeChange(fitContent);
+ this.editor.getModifiedEditor().onDidContentSizeChange(fitContent);
+
+ // Initial fit
+ fitContent();
+ }
+
+ private fitEditorToContent: (() => void) | null = null;
+
// Add resize observer to ensure editor resizes when container changes
firstUpdated() {
// Initialize the editor
this.initializeEditor();
- // Create a ResizeObserver to monitor container size changes
- if (window.ResizeObserver) {
- const resizeObserver = new ResizeObserver(() => {
- if (this.editor) {
- this.editor.layout();
- }
- });
-
- // Start observing the container
- if (this.container.value) {
- resizeObserver.observe(this.container.value);
- }
-
- // Store the observer for cleanup
- this._resizeObserver = resizeObserver;
- }
+ // For multi-file diff, we don't use ResizeObserver since we control the size
+ // Instead, we rely on auto-sizing based on content
// If editable, set up edit mode and content change listener
if (this.editableRight && this.editor) {
@@ -1209,11 +1283,14 @@
this.modifiedModel = undefined;
}
- // Clean up resize observer
+ // Clean up resize observer (if any)
if (this._resizeObserver) {
this._resizeObserver.disconnect();
this._resizeObserver = null;
}
+
+ // Clear the fit function reference
+ this.fitEditorToContent = null;
// Remove document click handler if set
if (this._documentClickHandler) {