Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions src-tauri/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,24 @@ pub enum Theme {
Dark,
}

/// Native webview background color matching the frontend's `--bg-main`.
/// Windows-only: WebView2 paints its default white before content renders,
/// which flashes on window-open and shows as a blank strip at the growing
/// edges during live resize. macOS ignores `background_color` (Tauri no-op)
/// and Linux/WebKitGTK never had the flash, so this is gated to Windows at the
/// call sites.
///
/// System resolves to light: dark mode is an opt-in `[data-theme="dark"]`
/// override, so light is today's effective default. Light `--bg-main` is
/// `#fafaf9`; dark is `#1d1b16`.
#[cfg(windows)]
pub fn window_background_color(theme: Theme) -> tauri::webview::Color {
match theme {
Theme::Dark => tauri::webview::Color(0x1d, 0x1b, 0x16, 0xff),
Theme::Light | Theme::System => tauri::webview::Color(0xfa, 0xfa, 0xf9, 0xff),
}
}

/// Application configuration stored in config.json.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
Expand Down Expand Up @@ -607,6 +625,26 @@ mod tests {
assert_eq!(config.theme, Theme::System);
}

#[cfg(windows)]
#[test]
fn window_background_matches_css_bg_main() {
// Must match `--bg-main` in src/styles/tokens.css, or WebView2 flashes
// its default background on open/resize. System resolves to light
// because dark is an opt-in [data-theme="dark"] override.
assert_eq!(
window_background_color(Theme::Light),
tauri::webview::Color(0xfa, 0xfa, 0xf9, 0xff)
);
assert_eq!(
window_background_color(Theme::System),
window_background_color(Theme::Light)
);
assert_eq!(
window_background_color(Theme::Dark),
tauri::webview::Color(0x1d, 0x1b, 0x16, 0xff)
);
}

#[test]
fn config_deserializes_without_theme() {
// Old configs without theme field should default to System
Expand Down
5 changes: 5 additions & 0 deletions src-tauri/src/excalidraw_window.rs
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,11 @@ pub async fn open_excalidraw_window(
.inner_size(width, height)
.min_inner_size(600.0, 400.0)
.visible(false);
// Windows-only: solid themed bg so WebView2 doesn't flash white.
#[cfg(windows)]
let b = b.background_color(crate::config::window_background_color(
crate::config::load_config().theme,
));
#[cfg(target_os = "macos")]
let b = b
.title_bar_style(tauri::TitleBarStyle::Overlay)
Expand Down
5 changes: 5 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,11 @@ pub fn run(state: AppState, context: tauri::Context, json_output: bool) {
.title("annot")
.inner_size(1000.0, 700.0)
.visible(false); // Will be shown after content loads
// Windows-only: paint a solid themed bg so WebView2 doesn't
// flash its default white on open/resize (see config helper).
#[cfg(windows)]
let b = b
.background_color(config::window_background_color(config::load_config().theme));
#[cfg(target_os = "macos")]
let b = b
.title_bar_style(tauri::TitleBarStyle::Overlay)
Expand Down
5 changes: 5 additions & 0 deletions src-tauri/src/mcp/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,11 @@ fn run_session_with_state(
.title("annot")
.inner_size(1000.0, 700.0)
.visible(false); // Will be shown after content loads
// Windows-only: solid themed bg so WebView2 doesn't flash white.
#[cfg(windows)]
let b = b.background_color(crate::config::window_background_color(
crate::config::load_config().theme,
));
#[cfg(target_os = "macos")]
let b = b
.title_bar_style(tauri::TitleBarStyle::Overlay)
Expand Down
5 changes: 5 additions & 0 deletions src-tauri/src/mermaid_window.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,11 @@ pub async fn open_mermaid_window(
.inner_size(600.0, 500.0)
.min_inner_size(300.0, 200.0)
.visible(false);
// Windows-only: solid themed bg so WebView2 doesn't flash white.
#[cfg(windows)]
let b = b.background_color(crate::config::window_background_color(
crate::config::load_config().theme,
));
#[cfg(target_os = "macos")]
let b = b
.title_bar_style(tauri::TitleBarStyle::Overlay)
Expand Down
10 changes: 9 additions & 1 deletion src/lib/AnnotationEditor.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -108,13 +108,14 @@
annotationEntries?: Record<string, AnnotationEntry>;
allowsImagePaste?: boolean;
onImagePasteBlocked?: () => void;
onFileRefCopied?: (path: string) => void;
onRequestCreateTag?: (text: string, from: number, to: number) => void;
pendingTagInsertion?: { from: number; to: number; tag: Tag } | null;
rangeKey?: string; // Annotation line range key like "45-52"
getOriginalLines?: () => string; // Returns original lines content for /replace
}

let { content, onUpdate, sealed = false, onUnseal, onDismiss, tags = [], annotationEntries = {}, allowsImagePaste = false, onImagePasteBlocked, onRequestCreateTag, pendingTagInsertion, rangeKey = '', getOriginalLines }: Props = $props();
let { content, onUpdate, sealed = false, onUnseal, onDismiss, tags = [], annotationEntries = {}, allowsImagePaste = false, onImagePasteBlocked, onFileRefCopied, onRequestCreateTag, pendingTagInsertion, rangeKey = '', getOriginalLines }: Props = $props();

// Get zoom level from context for floating elements
const ctx = getAnnotContext();
Expand Down Expand Up @@ -341,8 +342,14 @@
openExcalidrawEdit(detail.nodeId, detail.elements);
};

const handleFileRefCopied = (e: Event) => {
const detail = (e as CustomEvent).detail as { path: string };
onFileRefCopied?.(detail.path);
};

element?.addEventListener('excalidraw-create', handleExcalidrawCreate);
element?.addEventListener('excalidraw-edit', handleExcalidrawEdit);
element?.addEventListener('file-ref-copied', handleFileRefCopied);

// Listen for placeholder destruction (dispatched on document)
document.addEventListener('excalidraw-placeholder-destroyed', handlePlaceholderDestroyed);
Expand Down Expand Up @@ -392,6 +399,7 @@
return () => {
element?.removeEventListener('excalidraw-create', handleExcalidrawCreate);
element?.removeEventListener('excalidraw-edit', handleExcalidrawEdit);
element?.removeEventListener('file-ref-copied', handleFileRefCopied);
document.removeEventListener('excalidraw-placeholder-destroyed', handlePlaceholderDestroyed);
};
});
Expand Down
3 changes: 3 additions & 0 deletions src/lib/components/AnnotationSlot.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
onDismiss: () => void;
onRequestCreateTag: (rangeKey: string, text: string, from: number, to: number) => void;
onImagePasteBlocked: () => void;
onFileRefCopied?: (path: string) => void;
}
</script>

Expand All @@ -38,6 +39,7 @@
onDismiss,
onRequestCreateTag,
onImagePasteBlocked,
onFileRefCopied,
}: AnnotationSlotProps = $props();

const ctx = getAnnotContext();
Expand All @@ -58,6 +60,7 @@
annotationEntries={ctx.annotations.allEntries()}
allowsImagePaste={ctx.allowsImagePaste}
{onImagePasteBlocked}
{onFileRefCopied}
onRequestCreateTag={(text, from, to) => onRequestCreateTag(rangeKey, text, from, to)}
pendingTagInsertion={pendingTagInsertion?.editorKey === rangeKey
? { from: pendingTagInsertion.from, to: pendingTagInsertion.to, tag: pendingTagInsertion.tag }
Expand Down
5 changes: 4 additions & 1 deletion src/lib/components/SessionEditor.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
onClose: () => void;
onRequestCreateTag: (text: string, from: number, to: number) => void;
onImagePasteBlocked: () => void;
onFileRefCopied?: (path: string) => void;
}

let {
Expand All @@ -26,7 +27,8 @@
onOpen,
onClose,
onRequestCreateTag,
onImagePasteBlocked
onImagePasteBlocked,
onFileRefCopied
}: Props = $props();

const ctx = getAnnotContext();
Expand All @@ -44,6 +46,7 @@
annotationEntries={ctx.annotations.allEntries()}
allowsImagePaste={ctx.allowsImagePaste}
{onImagePasteBlocked}
{onFileRefCopied}
{onRequestCreateTag}
{pendingTagInsertion}
/>
Expand Down
5 changes: 4 additions & 1 deletion src/lib/tiptap/nodeviews/FileRefChip.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,16 @@
return parts.length > 1 ? parts[parts.length - 2] : null;
});

let chipEl: HTMLSpanElement;

async function copyPath() {
await navigator.clipboard.writeText(path);
// TODO: show toast via event
chipEl?.dispatchEvent(new CustomEvent('file-ref-copied', { bubbles: true, detail: { path } }));
}
</script>

<span
bind:this={chipEl}
class="file-ref-chip"
title={path}
onclick={copyPath}
Expand Down
35 changes: 34 additions & 1 deletion src/routes/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,31 @@
let contentEl: HTMLDivElement | null = $state(null);
let scrollRafId: number | null = null;

// While the window is GROWING, suspend rendering of the content tree
// (content-visibility: hidden via the `.resizing` class). On a 10k-line file
// the reflow can't fill the newly-exposed area fast enough, so content
// visibly trails the growing edge; freezing the last-painted frame until the
// drag settles hides that entirely.
//
// Only on grow, not shrink: shrinking has no trailing edge (existing content
// already covers the smaller window), and freezing a fast shrink outran the
// browser's cached paint and flashed blank. Live content on shrink is fine.
//
// No per-frame resize signal exists, so we debounce: set on a resize that
// grew either dimension, clear ~120ms after the last resize event.
let resizing = $state(false);
let resizeTimer: number | null = null;
let lastW = globalThis.innerWidth;
let lastH = globalThis.innerHeight;
function handleWindowResize() {
const grew = globalThis.innerWidth > lastW || globalThis.innerHeight > lastH;
lastW = globalThis.innerWidth;
lastH = globalThis.innerHeight;
if (grew && !resizing) resizing = true;
if (resizeTimer) clearTimeout(resizeTimer);
resizeTimer = window.setTimeout(() => { resizing = false; resizeTimer = null; }, 120);
}

// Current file/hunk derived from indices (diff mode)
let currentFile = $derived.by(() => {
if (!diffMetadata || diffMetadata.files.length === 0) return null;
Expand Down Expand Up @@ -438,6 +463,11 @@
showToast('Image paste is only supported in MCP mode');
}

function handleFileRefCopied(path: string) {
const alreadyShowing = toastMessage != null;
showToast(alreadyShowing ? `New copied path: "${path}"` : `Copied: "${path}"`);
}

// Handle reporting a mermaid syntax error as an annotation
async function handleReportMermaidError(displayRange: Range, errorMessage: string) {
// Check if annotation already exists at this range
Expand Down Expand Up @@ -557,6 +587,7 @@
onDismiss: closeCurrentEditor,
onRequestCreateTag: handleRequestCreateTag,
onImagePasteBlocked: handleImagePasteBlocked,
onFileRefCopied: handleFileRefCopied,
});

// Keyboard handling (composable)
Expand Down Expand Up @@ -700,7 +731,7 @@
});
</script>

<svelte:window onkeydown={keyboard.handleKeyDown} onkeyup={keyboard.handleKeyUp} />
<svelte:window onkeydown={keyboard.handleKeyDown} onkeyup={keyboard.handleKeyUp} onresize={handleWindowResize} />

<WindowResizeHandles />

Expand Down Expand Up @@ -748,12 +779,14 @@
onClose={closeSessionEditor}
onRequestCreateTag={(text, from, to) => handleRequestCreateTag('session', text, from, to)}
onImagePasteBlocked={handleImagePasteBlocked}
onFileRefCopied={handleFileRefCopied}
/>
</div>
</div>

<div
class="content"
class:resizing={resizing}
class:shift-held={interaction.isShiftHeld}
class:phase-idle={interaction.phase === 'idle'}
class:phase-selecting={interaction.phase === 'selecting'}
Expand Down
30 changes: 30 additions & 0 deletions src/styles/components/code-viewer.css
Original file line number Diff line number Diff line change
Expand Up @@ -121,12 +121,42 @@ header.header {
transform-origin: top left;
}

/* While the window is GROWING, suspend rendering of the content tree. On a
10k-line file the reflow can't fill the newly-exposed area fast enough so
content visibly trails the growing edge; content-visibility: hidden skips
layout/paint/style for the whole subtree (~7x cheaper per frame, measured)
and keeps the last-painted frame. The `.resizing` class is set on a grow
and cleared ~120ms after the last resize event (see +page.svelte); shrink
stays live (freezing it flashed blank on fast drags).

We hide the inner (whose frozen width can't fill a grown window) but keep the
outer scroller `.content` at full flex size with a solid themed background,
so growing shows themed empty space — not a stale scrollbar or torn content
— and the real content snaps back in on release. */
.content.resizing {
background: var(--bg-main);
}
.content.resizing .content-inner {
content-visibility: hidden;
}

/* Code lines - use div.line to avoid matching syntect's .line class on spans */
div.line {
position: relative;
display: flex;
white-space: pre-wrap;
tab-size: 4;
/* Skip layout/paint for off-screen lines. On large files (10k+ lines) a
window resize otherwise re-wraps every line every frame, so content
visibly trails the resize. content-visibility lets the engine skip
off-screen rows; contain-intrinsic-size keeps their reserved height
stable so the scrollbar doesn't jump. (Continuous resize is handled
separately by the `.resizing` freeze above.) */
content-visibility: auto;
/* Match the real single-row line-height (.code/.gutter = 22px) so the
reserved height for never-yet-rendered off-screen lines is accurate;
`auto` then remembers each line's true (possibly wrapped) height. */
contain-intrinsic-size: auto 22px;
}

/* Hover highlight — CSS-driven so mouse-move never re-renders 10k lines.
Expand Down
6 changes: 4 additions & 2 deletions src/styles/components/status-bar.css
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@
justify-content: space-between;
gap: 24px;
padding: 6px 16px;
/* Background is opaque (bg-main has no alpha), so a backdrop-filter blurs
pixels that are then fully painted over — zero visual effect, but a
full-width blur pass every resize frame that made the status bar visibly
trail the window edge. Dropped it. */
background: color-mix(in srgb, var(--mode-color, var(--bg-main)) 15%, var(--bg-main));
backdrop-filter: blur(16px) saturate(150%);
-webkit-backdrop-filter: blur(16px) saturate(150%);
border-top: 1px solid color-mix(in srgb, var(--mode-color, transparent) 20%, rgba(0, 0, 0, 0.06));
font-size: 12px;
z-index: 10;
Expand Down
Loading