diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 130d6c0..67e8737 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3104,6 +3104,30 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "block2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + [[package]] name = "ring" version = "0.17.14" @@ -4011,6 +4035,48 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-dialog" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.18", + "url", +] + +[[package]] +name = "tauri-plugin-fs" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371" +dependencies = [ + "anyhow", + "dunce", + "glob", + "log", + "objc2-foundation", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.18", + "toml 1.0.7+spec-1.1.0", + "url", +] + [[package]] name = "tauri-plugin-updater" version = "2.10.1" @@ -4233,6 +4299,7 @@ dependencies = [ "tauri", "tauri-build", "tauri-nspanel", + "tauri-plugin-dialog", "tauri-plugin-updater", "tempfile", "thiserror 2.0.18", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ae41a3f..0890171 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -23,6 +23,7 @@ tauri-build = { version = "2", features = [] } [dependencies] tauri = { version = "2", features = ["macos-private-api", "tray-icon", "image-png", "protocol-asset"] } tauri-plugin-updater = "2" +tauri-plugin-dialog = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" reqwest = { version = "0.13.3", features = ["json", "stream"] } diff --git a/src-tauri/src/export.rs b/src-tauri/src/export.rs new file mode 100644 index 0000000..f71c7cd --- /dev/null +++ b/src-tauri/src/export.rs @@ -0,0 +1,253 @@ +/*! + * Chat session export. + * + * The frontend serialises the active conversation to a Markdown string + * and asks this module to persist it. The native save dialog AND the + * write both live on the Rust side so the destination path is never an + * attacker-influenceable IPC argument: the renderer hands over only + * the serialised content and the suggested filename. The path + * returned by the dialog stays inside this module and is consumed by + * [`write_export`] without round-tripping through JS. + * + * This closes the trust gap that a separate "open save dialog" command + * plus "write to path the renderer chose" command would leave open: a + * compromised renderer could otherwise drive the write at any path the + * app process can reach. With dialog and write fused, the path comes + * from `NSSavePanel` exclusively. + */ + +use std::fs; +use std::path::{Path, PathBuf}; + +/// Failure modes for [`write_export`]. Carries no path strings: the +/// IPC-facing error message never leaks the destination the user +/// picked, which would otherwise surface in screenshots and screen +/// recordings. +#[derive(Debug)] +pub enum ExportError { + /// Path was empty after trimming. Treated as a cancellation-shaped + /// failure rather than something worth surfacing in detail. + EmptyPath, + /// `std::fs::write` failed. The variant captures only the OS-level + /// error kind; the user-facing message is a fixed string per kind + /// so absolute paths never appear in the banner. + Write(std::io::ErrorKind), +} + +impl std::fmt::Display for ExportError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ExportError::EmptyPath => write!(f, "Export path is empty"), + ExportError::Write(kind) => write!(f, "{}", write_error_message(*kind)), + } + } +} + +/// User-facing message for an `io::Error` kind. Kept short and concrete +/// so the banner reads as actionable rather than raw OS jargon, and +/// devoid of any filesystem path the user chose. +pub fn write_error_message(kind: std::io::ErrorKind) -> &'static str { + match kind { + std::io::ErrorKind::PermissionDenied => "Permission denied. Choose a writable location.", + std::io::ErrorKind::NotFound => "The selected location does not exist.", + std::io::ErrorKind::AlreadyExists => "A file already exists at that location.", + std::io::ErrorKind::InvalidInput => "The selected filename is invalid.", + std::io::ErrorKind::OutOfMemory => "Out of memory while writing the export.", + std::io::ErrorKind::StorageFull => "The disk is full.", + std::io::ErrorKind::ReadOnlyFilesystem => "The selected location is read-only.", + _ => "Failed to write the export.", + } +} + +/// Writes `content` to `path`, returning the resolved [`PathBuf`]. +/// +/// Trims `path` to be lenient about trailing whitespace from the dialog +/// (some macOS save sheets occasionally append a trailing newline when +/// the user typed into the filename field). An empty or whitespace-only +/// path is rejected so the file is never written to the process working +/// directory by accident. +pub fn write_export(path: &str, content: &str) -> Result { + let trimmed = path.trim(); + if trimmed.is_empty() { + return Err(ExportError::EmptyPath); + } + let target = PathBuf::from(trimmed); + write_export_path(&target, content)?; + Ok(target) +} + +/// Internal write that takes an already-resolved `Path`. Split out so +/// the dialog-driven command path can hand a `Path` straight in without +/// re-serialising to a string just to satisfy the trim guard above. +fn write_export_path(path: &Path, content: &str) -> Result<(), ExportError> { + fs::write(path, content).map_err(|e| ExportError::Write(e.kind())) +} + +/// Tauri command: opens the native save dialog with a Markdown filter, +/// then writes `content` to whichever path the user picked. Returns +/// `true` if a file was written, `false` if the user cancelled the +/// dialog, and `Err(message)` on a write failure. The destination path +/// is consumed entirely inside Rust and never crosses the IPC +/// boundary. +#[cfg(not(coverage))] +#[tauri::command] +#[cfg_attr(coverage_nightly, coverage(off))] +pub async fn prompt_and_save_chat_export( + app: tauri::AppHandle, + content: String, + default_filename: String, +) -> Result { + use tauri_plugin_dialog::DialogExt; + use tokio::sync::oneshot; + + let (tx, rx) = oneshot::channel(); + app.dialog() + .file() + .set_file_name(&default_filename) + .add_filter("Markdown", &["md"]) + .save_file(move |maybe_path| { + let _ = tx.send(maybe_path); + }); + + let maybe_path = rx + .await + .map_err(|_| "save dialog channel closed unexpectedly".to_string())?; + let Some(file_path) = maybe_path else { + return Ok(false); + }; + let path: PathBuf = file_path.into_path().map_err(|e| e.to_string())?; + write_export_path(&path, &content).map_err(|e| e.to_string())?; + Ok(true) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::tempdir; + + #[test] + fn empty_path_is_rejected() { + let err = write_export("", "hello").expect_err("empty path must error"); + assert!(matches!(err, ExportError::EmptyPath)); + } + + #[test] + fn whitespace_only_path_is_rejected() { + let err = write_export(" \t\n", "hello").expect_err("whitespace must error"); + assert!(matches!(err, ExportError::EmptyPath)); + } + + #[test] + fn empty_path_display_is_user_facing() { + assert_eq!( + format!("{}", ExportError::EmptyPath), + "Export path is empty" + ); + } + + #[test] + fn write_error_display_never_leaks_path() { + let err = ExportError::Write(std::io::ErrorKind::PermissionDenied); + let msg = format!("{err}"); + assert_eq!(msg, "Permission denied. Choose a writable location."); + assert!( + !msg.contains('/'), + "user-facing message must not include a filesystem path" + ); + } + + #[test] + fn write_error_messages_cover_known_kinds() { + assert_eq!( + write_error_message(std::io::ErrorKind::PermissionDenied), + "Permission denied. Choose a writable location." + ); + assert_eq!( + write_error_message(std::io::ErrorKind::NotFound), + "The selected location does not exist." + ); + assert_eq!( + write_error_message(std::io::ErrorKind::AlreadyExists), + "A file already exists at that location." + ); + assert_eq!( + write_error_message(std::io::ErrorKind::InvalidInput), + "The selected filename is invalid." + ); + assert_eq!( + write_error_message(std::io::ErrorKind::OutOfMemory), + "Out of memory while writing the export." + ); + assert_eq!( + write_error_message(std::io::ErrorKind::StorageFull), + "The disk is full." + ); + assert_eq!( + write_error_message(std::io::ErrorKind::ReadOnlyFilesystem), + "The selected location is read-only." + ); + assert_eq!( + write_error_message(std::io::ErrorKind::Other), + "Failed to write the export." + ); + } + + #[test] + fn valid_path_writes_content_and_returns_path() { + let dir = tempdir().expect("tempdir"); + let target = dir.path().join("export.md"); + let target_str = target.to_str().expect("utf-8"); + + let returned = write_export(target_str, "# Hello\n\nWorld").expect("write must succeed"); + + assert_eq!(returned, target); + let read_back = fs::read_to_string(&target).expect("file must exist"); + assert_eq!(read_back, "# Hello\n\nWorld"); + } + + #[test] + fn trailing_whitespace_in_path_is_trimmed() { + let dir = tempdir().expect("tempdir"); + let target = dir.path().join("trimmed.md"); + let padded = format!(" {} \n", target.to_str().expect("utf-8")); + + let returned = write_export(&padded, "content").expect("write must succeed"); + + assert_eq!(returned, target); + assert!( + target.exists(), + "file should be written to the trimmed path" + ); + } + + #[test] + fn nonexistent_directory_returns_write_error() { + let dir = tempdir().expect("tempdir"); + let target = dir.path().join("does/not/exist/export.md"); + let target_str = target.to_str().expect("utf-8"); + + let err = write_export(target_str, "x").expect_err("write must fail"); + assert!(matches!(err, ExportError::Write(_))); + } + + #[test] + fn overwrites_existing_file() { + let dir = tempdir().expect("tempdir"); + let target = dir.path().join("rewrite.md"); + fs::write(&target, "old").expect("seed"); + + write_export(target.to_str().expect("utf-8"), "new").expect("overwrite"); + let read_back = fs::read_to_string(&target).expect("file must exist"); + assert_eq!(read_back, "new"); + } + + #[test] + fn empty_content_writes_empty_file() { + let dir = tempdir().expect("tempdir"); + let target = dir.path().join("empty.md"); + write_export(target.to_str().expect("utf-8"), "").expect("empty write"); + let read_back = fs::read_to_string(&target).expect("file must exist"); + assert_eq!(read_back, ""); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b983c20..3f5947d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -18,6 +18,7 @@ pub mod commands; pub mod config; pub mod database; +pub mod export; pub mod history; pub mod images; pub mod models; @@ -818,6 +819,121 @@ fn animate_overlay_frame(app_handle: tauri::AppHandle, width: f64, height: f64, } } +/// Sets the alpha (opacity) of the main overlay NSPanel. +/// +/// Used to temporarily hide Thuki while a foreign system dialog (the +/// `NSSavePanel` invoked by the export flow) is on screen. That dialog +/// ships with its own drop-shadow and `NSVisualEffectView` vibrancy +/// backdrop, both of which bleed onto anything behind them. Thuki's +/// transparent CSS shadow margin would otherwise show through as a +/// dark "ghost" rectangle around the card. +/// +/// Driving alpha to 0 removes Thuki from the compositor for the +/// duration of the dialog without disturbing the NSPanel's state +/// machine, the activator, the trace recorder, or the React tree. +/// Restoring alpha to 1.0 paints the window again with the exact +/// same content it had before. Cheap, idempotent, and unrelated to +/// the window-resize path that the tighten-to-card approach broke. +/// +/// When `duration_ms > 0` the transition is driven through +/// `NSAnimationContext` so the alpha change overlaps the dialog's +/// own fade-in / fade-out. With `duration_ms = 0` the alpha is set +/// instantly. Hiding the panel usually wants `0` (snap out so the +/// dialog's appearance is the only motion the user reads); restoring +/// usually wants a small duration so Thuki gracefully fades back in +/// instead of popping over the dialog dismiss animation. +/// +/// Non-finite values are silently dropped and the magnitude is clamped +/// to `[0.0, 1.0]` so the IPC boundary stays forgiving. Duration is +/// clamped to `[0.0, 2000.0]` ms for the same reason. +#[tauri::command] +#[cfg_attr(coverage_nightly, coverage(off))] +fn set_overlay_alpha(app_handle: tauri::AppHandle, alpha: f64, duration_ms: f64) { + if !alpha.is_finite() { + return; + } + let alpha = alpha.clamp(0.0, 1.0); + let duration_ms = if duration_ms.is_finite() { + duration_ms.clamp(0.0, 2000.0) + } else { + 0.0 + }; + + #[cfg(target_os = "macos")] + { + use objc2::class; + use objc2::msg_send; + use objc2::runtime::AnyObject; + + let handle = app_handle.clone(); + let _ = app_handle.run_on_main_thread(move || { + let Some(window) = handle.get_webview_window("main") else { + return; + }; + let Ok(ns_window) = window.ns_window() else { + return; + }; + if ns_window.is_null() { + return; + } + let win = ns_window as *mut AnyObject; + unsafe { + if duration_ms == 0.0 { + let _: () = msg_send![win, setAlphaValue: alpha]; + } else { + let ctx_cls = class!(NSAnimationContext); + let _: () = msg_send![ctx_cls, beginGrouping]; + let ctx: *mut AnyObject = msg_send![ctx_cls, currentContext]; + let _: () = msg_send![ctx, setDuration: duration_ms / 1000.0]; + let animator: *mut AnyObject = msg_send![win, animator]; + let _: () = msg_send![animator, setAlphaValue: alpha]; + let _: () = msg_send![ctx_cls, endGrouping]; + } + } + }); + } + + #[cfg(not(target_os = "macos"))] + { + let _ = (app_handle, alpha, duration_ms); + } +} + +/// Sets the default appearance of `NSSavePanel` (and its `NSOpenPanel` +/// sibling) to the **compact** layout — no sidebar, no file browser, +/// just the Save As field, a Where popup, and the action buttons. +/// +/// macOS persists the expansion state of these panels per app under +/// the `NSNavPanelExpandedStateForSaveMode` key in `NSUserDefaults`. +/// On a fresh launch the panel inherits the system default, which is +/// the wide expanded layout most apps want. For a Spotlight-style +/// overlay like Thuki where export is a quick action invoked from a +/// floating bar, the compact layout reads as the right shape: the +/// user already picked the file in their head, they just need to +/// confirm the name and location. +/// +/// Writing the key at startup means every save dialog opens compact +/// on a fresh launch. Within a session, macOS rewrites the key when +/// the user manually toggles the disclosure triangle, so their +/// per-save preference is respected until the next launch. +#[cfg(target_os = "macos")] +#[cfg_attr(coverage_nightly, coverage(off))] +fn apply_save_panel_compact_default() { + use objc2::class; + use objc2::msg_send; + use objc2::runtime::AnyObject; + use objc2_foundation::ns_string; + + let key = ns_string!("NSNavPanelExpandedStateForSaveMode"); + unsafe { + let defaults: *mut AnyObject = msg_send![class!(NSUserDefaults), standardUserDefaults]; + if defaults.is_null() { + return; + } + let _: () = msg_send![defaults, setBool: false, forKey: key]; + } +} + /// Synchronizes the Rust-side visibility tracking when the frontend /// completes its exit animation and hides the native window. #[tauri::command] @@ -1027,6 +1143,70 @@ fn init_panel(app_handle: &tauri::AppHandle) { // different after the user clicks elsewhere. The CSS `shadow-bar` provides // a stable, focus-independent elevation effect. panel.set_has_shadow(false); + + // Three NSPanel-layer assertions to keep the overlay visually clean + // through the save-dialog flow, only one of which is strictly novel: + // + // 1. `setBackgroundColor: NSColor.clearColor` + `setOpaque: NO` - + // re-asserted because `to_panel::()` plus the + // subsequent `set_style_mask` rewrite can leave the panel with + // `NSColor.windowBackgroundColor` painted into the backing layer. + // + // 2. `setWorksWhenModal: YES` - keeps the panel receiving keyboard + // and mouse events even while an application-modal session + // (NSSavePanel from `rfd`) is up. Per Apple docs this property + // controls event routing, NOT the AppKit modal dim - which is + // hardcoded on every non-modal window of the app and cannot be + // cleanly opted out of. Still worth setting so the panel stays + // interactive across the modal. + // + // 3. `contentView.layer.cornerRadius` + `masksToBounds` - the + // load-bearing fix for the visible halo around Thuki when the + // save dialog is up. AppKit's modal dim fills the entire NSPanel + // bounds, but the CSS chrome inside the WebView only paints a + // smaller rounded-rect (Tailwind `rounded-lg`, 8 px). The dim + // bleeds out from the dark CSS chrome and shows as a slate-gray + // annular halo. Clipping the content-view layer to the same + // rounded shape the CSS draws gives the dim no pixels to land on + // outside the chrome. Normal-state rendering is untouched: there + // is nothing to clip when the overlay is not being dimmed. + // + // 8 px matches `rounded-lg` used by the chat-mode chrome - the + // only state from which the save dialog can be launched (the + // export button only renders in chat mode and the chat-header + // handler gates on `messages.length > 0`). Ask-bar mode uses + // `rounded-2xl` + // (16 px), which produces a smaller visible CSS shape than this + // 8 px content-view clip; the clip therefore has no visible + // effect in ask-bar mode (the smaller CSS shape is already + // inside the clip). + if let Ok(ns_window) = window.ns_window() { + if !ns_window.is_null() { + use objc2::rc::autoreleasepool; + use objc2::runtime::AnyObject; + use objc2::{class, msg_send}; + let win = ns_window as *mut AnyObject; + unsafe { + autoreleasepool(|_| { + let clear: *mut AnyObject = msg_send![class!(NSColor), clearColor]; + let _: () = msg_send![win, setBackgroundColor: clear]; + let _: () = msg_send![win, setOpaque: false]; + let _: () = msg_send![win, setWorksWhenModal: true]; + + let content_view: *mut AnyObject = msg_send![win, contentView]; + if !content_view.is_null() { + let _: () = msg_send![content_view, setWantsLayer: true]; + let layer: *mut AnyObject = msg_send![content_view, layer]; + if !layer.is_null() { + let radius: f64 = 8.0; + let _: () = msg_send![layer, setCornerRadius: radius]; + let _: () = msg_send![layer, setMasksToBounds: true]; + } + } + }); + } + } + } } // ─── Settings panel initialisation ────────────────────────────────────────── @@ -1331,6 +1511,7 @@ pub fn run() { builder .plugin(tauri_plugin_updater::Builder::new().build()) + .plugin(tauri_plugin_dialog::init()) .setup(|app| { #[cfg(target_os = "macos")] app.set_activation_policy(ActivationPolicy::Accessory); @@ -1343,6 +1524,12 @@ pub fn run() { #[cfg(target_os = "macos")] init_update_panel(app.app_handle()); + // Default the export save dialog to the compact layout. The + // user can still hit the disclosure triangle for a full + // file browser on any individual save. + #[cfg(target_os = "macos")] + apply_save_panel_compact_default(); + // ── System tray icon + menu ─────────────────────────────────── // Order chosen for muscle-memory parity with mac tray apps // (Bartender, CleanShot X, Rectangle): primary action at top, @@ -1689,11 +1876,14 @@ pub fn run() { screenshot::capture_full_screen_command, #[cfg(not(coverage))] ocr::extract_text_command, + #[cfg(not(coverage))] + export::prompt_and_save_chat_export, notify_overlay_hidden, set_overlay_minimized, notify_frontend_ready, set_window_frame, animate_overlay_frame, + set_overlay_alpha, #[cfg(not(coverage))] permissions::check_accessibility_permission, #[cfg(not(coverage))] diff --git a/src/App.tsx b/src/App.tsx index c072241..7e122f3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -54,6 +54,11 @@ import { SCREEN_CAPTURE_PLACEHOLDER, buildPrompt, } from './config/commands'; +import { + defaultExportFilename, + serializeForClipboard, + serializeForFile, +} from './lib/exportSerializer'; import './App.css'; const OVERLAY_VISIBILITY_EVENT = 'thuki://visibility'; @@ -356,6 +361,21 @@ function App() { /** Whether the model picker panel is currently open. Mutually exclusive with `isHistoryOpen`. */ const [isModelPickerOpen, setIsModelPickerOpen] = useState(false); + /** Whether the chat-header export popover (clipboard / file) is currently open. */ + const [isExportOpen, setIsExportOpen] = useState(false); + /** + * Ref to the export popover root. Used by the outside-click effect to + * keep the popover open while the user is clicking inside it. + */ + const exportPopoverRef = useRef(null); + // Re-entrancy guard for runFileExport. NSPanel's setWorksWhenModal:YES + // keeps the chat header clickable while the native save dialog is on + // screen, so a second export click would interleave the alpha:0/alpha:1 + // brackets and re-show the overlay behind the still-open dialog (the + // ghost-rectangle artefact the alpha bracketing is designed to prevent). + // The ref is set true at the start of runFileExport and cleared in the + // finally block; concurrent calls observe `true` and return immediately. + const isExportInFlightRef = useRef(false); /** * True when the user clicked + while an unsaved conversation is active. * Causes the history dropdown to show a SwitchConfirmation prompt instead @@ -440,6 +460,18 @@ function App() { addOcrTurn, } = useOllama(activeModel, handleTurnComplete); + /** + * Mirror of `messages` as a ref so export handlers (and any future + * callback that needs a live snapshot of the conversation) can read + * the current value without joining the streaming token cadence as a + * `useCallback` dependency. `messages` updates on every Token chunk, + * which would otherwise reallocate `runFileExport` / `runClipboardCopy` + * hundreds of times during a long generation and defeat downstream + * memoization. + */ + const messagesRef = useRef(messages); + messagesRef.current = messages; + /** * Sticky flag: once the user invokes `/search`, subsequent submits in the * same conversation route through the search pipeline automatically until @@ -472,9 +504,28 @@ function App() { /** True while waiting for images to finish processing before a deferred * submit. Drives the "waiting" UI state in the ask bar. */ const [isSubmitPending, setIsSubmitPending] = useState(false); - /** Error message from a failed /screen capture. Shown inline above the ask - * bar so the user knows capture failed rather than seeing no response. */ + /** Error message from a failed /screen capture or any other gate that + * surfaces user-facing feedback. Shown inline above the ask bar so the + * user knows the submission did not go through. Auto-clears after a + * short linger so a one-off mistake does not leave the banner up + * forever; the next submit also clears it preemptively. */ const [captureError, setCaptureError] = useState(null); + /** + * Auto-dismiss the capture-error banner after a short linger so a + * one-off mistake (empty `/extract`, OCR miss, capture failure, etc.) + * does not leave a red banner up indefinitely. Mirrors the + * `shakeAskBar` self-clearing pattern. + * + * 5 seconds reads as a deliberate auto-hide rather than a flash and + * gives the user time to read a one-line message twice. The banner is + * also cleared at the top of `handleSubmit` so a fresh submit attempt + * always starts clean regardless of timing. + */ + useEffect(() => { + if (!captureError) return; + const timer = setTimeout(() => setCaptureError(null), 5000); + return () => clearTimeout(timer); + }, [captureError]); /** * Set to true when a /screen capture is dispatched, false when it resolves * or when the user cancels. Lets the async tail in handleScreenSubmit @@ -1095,6 +1146,13 @@ function App() { if (morphPhaseRef.current !== 'idle') return; growsUpwardRef.current = false; setGrowsUpward(false); + // Dismiss any open chat-header popovers before collapsing — they + // are anchored to the chat-mode coordinate space and would otherwise + // either stay visually orphaned over the mascot or flash open again + // on restore. + setIsExportOpen(false); + setIsHistoryOpen(false); + setIsModelPickerOpen(false); // Keep the panel key for the duration of the morph. It is a // nonactivating NSPanel, so WKWebView throttles requestAnimationFrame // (and with it Framer Motion's tween/spring clock) whenever the panel is @@ -1289,10 +1347,16 @@ function App() { return () => document.removeEventListener('mousedown', handleMouseDown); }, [isModelPickerOpen]); - /** Toggles the history panel open/closed. Closes model picker (mutually exclusive). */ + /** + * Toggles the history panel open/closed. Closes the model picker AND + * the export popover so the three header popovers (anchored to the + * same `right-3 top-10` corner) stay mutually exclusive regardless of + * which one the user opens next. + */ const handleHistoryToggle = useCallback(() => { setIsHistoryOpen((prev) => !prev); setIsModelPickerOpen(false); + setIsExportOpen(false); }, []); /** @@ -1480,6 +1544,7 @@ function App() { // Load failed - current session is preserved intact. } finally { setIsHistoryOpen(false); + setIsExportOpen(false); } }, [loadConversation, loadMessages], @@ -1509,6 +1574,7 @@ function App() { // Load failed - save already committed; dismiss panel, keep current view. } finally { setIsHistoryOpen(false); + setIsExportOpen(false); } }, [save, messages, loadConversation, loadMessages, activeModel], @@ -1539,6 +1605,7 @@ function App() { reset(); resetHistory(); setIsHistoryOpen(false); + setIsExportOpen(false); setQuery(''); setAttachedImages((prev) => { for (const img of prev) URL.revokeObjectURL(img.blobUrl); @@ -1559,6 +1626,11 @@ function App() { * immediately. */ const handleNewConversation = useCallback(() => { + // Whichever branch we take below, the export popover should not + // outlive the click — either we route through SwitchConfirmation + // (history dropdown takes over the chat-header coordinate space) or + // we reset the session outright. + setIsExportOpen(false); if (!isSaved && messages.length > 0) { setPendingNewConversation(true); setIsHistoryOpen(true); @@ -2257,6 +2329,144 @@ function App() { composeCapabilityState, ]); + /** + * Serialises the current session as Markdown and asks the Rust + * backend to open the native save dialog and write to disk in one + * atomic operation. The destination path lives entirely inside Rust: + * the renderer hands over content + suggested filename and receives + * a boolean indicating whether a file was written, so a compromised + * renderer cannot direct the write at a path of its choosing. + * + * Re-entrancy: NSPanel uses `setWorksWhenModal:YES` so the chat + * header button stays clickable while the save dialog is up. A + * second click while the first export is still in flight is dropped + * via `isExportInFlightRef` so the alpha:0/alpha:1 brackets cannot + * interleave and re-show the overlay behind a still-open dialog. + * + * Errors surface via the `captureError` banner. The Rust side + * returns a fixed user-facing string per io error kind (never the + * absolute destination path), so the banner cannot leak the path + * the user picked into a screenshot or screen recording. + */ + const runFileExport = useCallback(async () => { + setIsExportOpen(false); + const snapshot = messagesRef.current; + /* v8 ignore start -- defensive: the popover only renders in chat mode */ + if (snapshot.length === 0) return; + /* v8 ignore stop */ + if (isExportInFlightRef.current) return; + isExportInFlightRef.current = true; + // Single try/catch covers BOTH the serialisation step and the + // dialog/write IPC. Serialisation runs an image-load Promise.all + // and is awaited BEFORE the overlay hides so the perceived + // "preparing export" surface stays Thuki rather than a blank + // screen. The alpha bracketing only covers the IPC window so the + // overlay is hidden for exactly the dialog + write, never the + // prep. + try { + const now = new Date(); + const content = await serializeForFile( + snapshot, + { fallbackModel: activeModel }, + now, + ); + // Hide Thuki via NSPanel alpha while the native save dialog is + // on screen. The dialog's drop-shadow and vibrancy backdrop + // would otherwise bleed onto Thuki's transparent shadow margin + // and render as a dark "ghost" rectangle around the card. + // Hide instantly — the dialog's own appear animation is the + // motion the user reads, so a snap-out keeps the transition + // crisp from Thuki → dialog. + void invoke('set_overlay_alpha', { alpha: 0, durationMs: 0 }); + await invoke('prompt_and_save_chat_export', { + content, + defaultFilename: defaultExportFilename(new Date()), + }); + } catch (err) { + setCaptureError( + `Failed to export: ${err instanceof Error ? err.message : String(err)}`, + ); + } finally { + // Fade back in over 150 ms so Thuki re-emerges in step with the + // dialog's dismiss animation instead of snapping in late. If + // serialisation threw before the alpha:0 dispatched, this is + // an alpha:1 → alpha:1 no-op rather than a wasted state change. + void invoke('set_overlay_alpha', { alpha: 1, durationMs: 150 }); + isExportInFlightRef.current = false; + } + // `messages` is read via `messagesRef.current` so a long streaming + // response does not reallocate this callback per Token chunk. + }, [activeModel]); + + /** + * Copies the current session to the system clipboard as body-only + * Markdown. Strips the YAML frontmatter (would surface as visible + * noise in chat apps) and substitutes image markers for screenshots + * (a multi-megabyte base64 payload would otherwise jam most paste + * targets). Errors surface via the `captureError` banner. + */ + const runClipboardCopy = useCallback(async () => { + setIsExportOpen(false); + const snapshot = messagesRef.current; + /* v8 ignore start -- defensive: the popover only renders in chat mode */ + if (snapshot.length === 0) return; + /* v8 ignore stop */ + try { + const content = serializeForClipboard(snapshot); + await navigator.clipboard.writeText(content); + } catch (err) { + setCaptureError( + `Failed to copy: ${err instanceof Error ? err.message : String(err)}`, + ); + } + // `messages` is read via `messagesRef.current`; see runFileExport's + // dep-list comment for why streaming-cadence reallocation is avoided. + }, []); + + /** + * Toggles the export popover from the chat-header button. Closes the + * model-picker dropdown AND the history dropdown when opening so the + * three popovers (all anchored to the same `right-3 top-10` corner + * of the chat header) never overlap. The mutual-exclusion close is + * mirrored by `handleHistoryToggle` and `handleModelPickerToggle` so + * the invariant holds regardless of which one opens. + */ + const handleExportToggle = useCallback(() => { + setIsExportOpen((open) => { + if (!open) { + setIsModelPickerOpen(false); + setIsHistoryOpen(false); + } + return !open; + }); + }, []); + + /** + * Dismisses the export popover when the user clicks outside it. The + * toggle button itself is excluded so the click that already toggled + * the popover does not also close it on the same gesture. + */ + useEffect(() => { + if (!isExportOpen) return; + const handler = (event: MouseEvent) => { + const target = event.target; + /* v8 ignore start -- happy-dom always yields a Node target */ + if (!(target instanceof Node)) return; + /* v8 ignore stop */ + const popover = exportPopoverRef.current; + /* v8 ignore start -- the ref is attached whenever the popover renders */ + if (popover === null) return; + /* v8 ignore stop */ + if (popover.contains(target)) return; + if (target instanceof Element && target.closest('[data-export-toggle]')) { + return; + } + setIsExportOpen(false); + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, [isExportOpen]); + const handleSubmit = useCallback(() => { if ( (query.trim().length === 0 && attachedImages.length === 0) || @@ -2647,6 +2857,7 @@ function App() { return opening; }); setIsHistoryOpen(false); + setIsExportOpen(false); }, [refreshModels, refreshModelCapabilities]); /** @@ -2704,10 +2915,25 @@ function App() { requestHideOverlay(); }, [requestHideOverlay]); - /** Hide window on Escape or Cmd+W (macOS) / Ctrl+W. No-op while minimized. */ + /** + * Hide window on Escape or Cmd+W (macOS) / Ctrl+W. No-op while + * minimized. When the export popover is open, Escape dismisses just + * the popover (and returns focus to its toggle button) rather than + * closing the whole overlay — this matches macOS popover convention + * and prevents the global handler from blowing away the user's + * conversation when they only meant to back out of the export menu. + */ useEffect(() => { const onKeyDown = (e: KeyboardEvent) => { if (isMinimized) return; + if (e.key === 'Escape' && isExportOpen) { + e.preventDefault(); + setIsExportOpen(false); + document + .querySelector('[data-export-toggle]') + ?.focus(); + return; + } if (((e.metaKey || e.ctrlKey) && e.key === 'w') || e.key === 'Escape') { e.preventDefault(); handleCloseOverlay(); @@ -2715,7 +2941,7 @@ function App() { }; window.addEventListener('keydown', onKeyDown); return () => window.removeEventListener('keydown', onKeyDown); - }, [handleCloseOverlay, isMinimized]); + }, [handleCloseOverlay, isMinimized, isExportOpen]); /** * Programmatic focus when the overlay is visible and in the normal (idle) @@ -3008,6 +3234,12 @@ function App() { } isModelPickerOpen={isModelPickerOpen} onMinimize={handleMinimize} + onExportToggle={ + messages.length > 0 + ? handleExportToggle + : undefined + } + isExportOpen={isExportOpen} /> ) : null} @@ -3256,6 +3488,59 @@ function App() { ) : null} + {/* Chat-mode export popover. Anchored to the same right-3 top-10 + corner as the model picker dropdown; the two never overlap + because opening one closes the other. Visual treatment mirrors + the `SwitchConfirmation` prompt (sentence-case title, plain + text rows, primary-highlighted recommended action) so the two + small popovers feel like a single language. */} + + {isChatMode && isExportOpen ? ( + +
+

+ Export chat +

+
+ + +
+
+
+ ) : null} +
+ {/* Chat-mode history dropdown - sibling of the morphing container so it is never clipped by its overflow-hidden. Positioned absolutely within this relative wrapper (same coordinate space as the diff --git a/src/__tests__/App.test.tsx b/src/__tests__/App.test.tsx index d7682bf..e3c816a 100644 --- a/src/__tests__/App.test.tsx +++ b/src/__tests__/App.test.tsx @@ -8083,4 +8083,630 @@ describe('App', () => { ); }); }); + + // ─── chat-header export button ────────────────────────────────────────────── + + describe('chat-header export button', () => { + let writeText: ReturnType; + let clipboardSpy: { mockRestore: () => void } | null = null; + + beforeEach(() => { + writeText = vi.fn().mockResolvedValue(undefined); + // happy-dom defines `navigator.clipboard` as a non-configurable + // property, so a full property redefinition throws. Spy on the + // existing `writeText` method instead. + clipboardSpy = vi + .spyOn(navigator.clipboard, 'writeText') + .mockImplementation(writeText as (data: string) => Promise); + }); + + afterEach(() => { + clipboardSpy?.mockRestore(); + clipboardSpy = null; + }); + + async function enterChatMode() { + render(); + await act(async () => {}); + await showOverlay(); + const textarea = screen.getByPlaceholderText('Ask Thuki anything...'); + act(() => { + fireEvent.change(textarea, { target: { value: 'seed' } }); + }); + act(() => { + fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false }); + }); + await act(async () => {}); + act(() => { + getLastChannel()?.simulateMessage({ type: 'Token', data: 'ok' }); + getLastChannel()?.simulateMessage({ type: 'Done' }); + }); + await act(async () => {}); + } + + async function openExportPopover() { + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Export chat' })); + }); + } + + /** + * Routes `invoke('prompt_and_save_chat_export', ...)` to a custom + * impl while leaving every other command on the channel-capture + * default. Returns the wrapped impl handle so tests can read calls + * back. Mirrors the previous `save_chat_export` override pattern. + */ + type ExportArgs = { + content: string; + defaultFilename: string; + }; + function overrideExportInvoke( + impl: (args: ExportArgs) => Promise, + ) { + const prev = invoke.getMockImplementation(); + invoke.mockImplementation(async (cmd, args) => { + if (cmd === 'prompt_and_save_chat_export') { + return await impl(args as ExportArgs); + } + return prev ? prev(cmd, args) : undefined; + }); + } + + it('renders the export button in chat mode and the popover opens on click', async () => { + await enterChatMode(); + + const exportButton = screen.getByRole('button', { name: 'Export chat' }); + expect(exportButton).toBeInTheDocument(); + expect(exportButton).toHaveAttribute('aria-expanded', 'false'); + expect(exportButton).toHaveAttribute('aria-haspopup', 'menu'); + + await act(async () => { + fireEvent.click(exportButton); + }); + + expect(exportButton).toHaveAttribute('aria-expanded', 'true'); + const popover = screen.getByRole('menu', { name: 'Export chat' }); + expect(popover).toBeInTheDocument(); + expect(popover).toHaveAttribute('aria-orientation', 'vertical'); + expect( + screen.getByRole('menuitem', { name: /Save as Markdown/i }), + ).toBeInTheDocument(); + expect( + screen.getByRole('menuitem', { name: /Copy to clipboard/i }), + ).toBeInTheDocument(); + }); + + it('does not render the export button in ask-bar mode (no messages)', async () => { + render(); + await act(async () => {}); + await showOverlay(); + + expect(screen.queryByRole('button', { name: 'Export chat' })).toBeNull(); + }); + + it('focuses the first menuitem when the popover opens', async () => { + await enterChatMode(); + await openExportPopover(); + + const firstItem = screen.getByRole('menuitem', { + name: /Save as Markdown/i, + }); + expect(document.activeElement).toBe(firstItem); + }); + + it('invokes prompt_and_save_chat_export with Markdown content when Markdown is clicked', async () => { + await enterChatMode(); + let captured: ExportArgs | null = null; + overrideExportInvoke(async (args) => { + captured = args; + return true; + }); + invoke.mockClear(); + // re-install override after mockClear (mockClear preserves impl) + overrideExportInvoke(async (args) => { + captured = args; + return true; + }); + + await openExportPopover(); + await act(async () => { + fireEvent.click( + screen.getByRole('menuitem', { name: /Save as Markdown/i }), + ); + }); + await act(async () => {}); + + await waitFor(() => { + expect(captured).not.toBeNull(); + }); + const md = captured as ExportArgs | null; + // Markdown serialiser emits YAML frontmatter at the top of the file. + expect(md?.content.startsWith('---\napp: ')).toBe(true); + expect(md?.content).toContain('## User'); + expect(md?.defaultFilename).toMatch( + /^thuki-chat-\d{4}-\d{2}-\d{2}-\d{4}\.md$/, + ); + }); + + it('silently no-ops when the Rust command reports user cancellation (returns false)', async () => { + await enterChatMode(); + overrideExportInvoke(async () => false); + invoke.mockClear(); + overrideExportInvoke(async () => false); + + await openExportPopover(); + await act(async () => { + fireEvent.click( + screen.getByRole('menuitem', { name: /Save as Markdown/i }), + ); + }); + await act(async () => {}); + + // No banner, dialog cancellation is not an error condition. + expect(screen.queryByText(/Failed to export/)).not.toBeInTheDocument(); + // The Rust command was called. + expect(invoke).toHaveBeenCalledWith( + 'prompt_and_save_chat_export', + expect.objectContaining({ content: expect.any(String) }), + ); + }); + + it('surfaces an error banner when prompt_and_save_chat_export rejects', async () => { + await enterChatMode(); + overrideExportInvoke(async () => { + throw new Error('Permission denied. Choose a writable location.'); + }); + + await openExportPopover(); + await act(async () => { + fireEvent.click( + screen.getByRole('menuitem', { name: /Save as Markdown/i }), + ); + }); + await act(async () => {}); + + await vi.waitFor(() => { + expect( + screen.getByText( + /Failed to export: Permission denied\. Choose a writable location\./, + ), + ).toBeInTheDocument(); + }); + }); + + it('falls back to String(err) when the Rust command throws a non-Error', async () => { + await enterChatMode(); + overrideExportInvoke(async () => { + throw 'rust-plain-string'; + }); + + await openExportPopover(); + await act(async () => { + fireEvent.click( + screen.getByRole('menuitem', { name: /Save as Markdown/i }), + ); + }); + await act(async () => {}); + + await vi.waitFor(() => { + expect( + screen.getByText(/Failed to export: rust-plain-string/), + ).toBeInTheDocument(); + }); + }); + + it('writes to the clipboard when the Copy to clipboard menuitem is clicked', async () => { + await enterChatMode(); + + await openExportPopover(); + await act(async () => { + fireEvent.click( + screen.getByRole('menuitem', { name: /Copy to clipboard/i }), + ); + }); + + await vi.waitFor(() => { + expect(writeText).toHaveBeenCalledWith( + expect.stringContaining('## User'), + ); + }); + }); + + it('shows an error banner when clipboard.writeText rejects', async () => { + await enterChatMode(); + writeText.mockRejectedValueOnce(new Error('clipboard denied')); + + await openExportPopover(); + await act(async () => { + fireEvent.click( + screen.getByRole('menuitem', { name: /Copy to clipboard/i }), + ); + }); + + await vi.waitFor(() => { + expect( + screen.getByText(/Failed to copy: clipboard denied/), + ).toBeInTheDocument(); + }); + }); + + it('falls back to String(err) when the clipboard writer throws a non-Error', async () => { + await enterChatMode(); + writeText.mockRejectedValueOnce('clip-plain'); + + await openExportPopover(); + await act(async () => { + fireEvent.click( + screen.getByRole('menuitem', { name: /Copy to clipboard/i }), + ); + }); + + await vi.waitFor(() => { + expect( + screen.getByText(/Failed to copy: clip-plain/), + ).toBeInTheDocument(); + }); + }); + + it('keeps the popover open when mousedown lands inside it', async () => { + await enterChatMode(); + await openExportPopover(); + + const item = screen.getByRole('menuitem', { + name: /Save as Markdown/i, + }); + await act(async () => { + fireEvent.mouseDown(item); + }); + await act(async () => {}); + + expect( + screen.queryByRole('menuitem', { name: /Save as Markdown/i }), + ).toBeInTheDocument(); + }); + + it('closes the popover when clicking outside', async () => { + await enterChatMode(); + await openExportPopover(); + expect( + screen.queryByRole('menuitem', { name: /Save as Markdown/i }), + ).toBeInTheDocument(); + + await act(async () => { + fireEvent.mouseDown(document.body); + }); + + expect( + screen.queryByRole('menuitem', { name: /Save as Markdown/i }), + ).toBeNull(); + }); + + it('toggles the popover closed when the export button is clicked a second time', async () => { + await enterChatMode(); + const exportButton = screen.getByRole('button', { name: 'Export chat' }); + + await act(async () => { + fireEvent.click(exportButton); + }); + expect(exportButton).toHaveAttribute('aria-expanded', 'true'); + + // The button has data-export-toggle so a mousedown on it does NOT + // close via the outside-click effect; the subsequent click toggles + // the state to false. + await act(async () => { + fireEvent.mouseDown(exportButton); + }); + await act(async () => { + fireEvent.click(exportButton); + }); + + expect(exportButton).toHaveAttribute('aria-expanded', 'false'); + }); + + it('auto-clears the capture-error banner after a short linger', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + try { + render(); + await act(async () => {}); + await showOverlay(); + + // /extract with no image triggers the same captureError surface + // we want to auto-dismiss. Used as the harness here because + // the chat-header export button does not render until chat mode + // (so it cannot trigger an empty-state error). + const textarea = screen.getByPlaceholderText('Ask Thuki anything...'); + act(() => { + fireEvent.change(textarea, { target: { value: '/extract' } }); + }); + await act(async () => { + fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false }); + }); + + expect( + screen.getByText( + 'Attach an image or add /screen to extract text from.', + ), + ).toBeInTheDocument(); + + // Auto-dismiss timer is 5s. Advance past it. + await act(async () => { + vi.advanceTimersByTime(5000); + }); + + expect( + screen.queryByText( + 'Attach an image or add /screen to extract text from.', + ), + ).toBeNull(); + } finally { + vi.useRealTimers(); + } + }); + + it('closes the model picker when opening the export popover', async () => { + await enterChatMode(); + + // Open model picker first. + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Choose model' })); + }); + // Then open export popover; model picker should close. + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Export chat' })); + }); + + // Export popover is open. + expect( + screen.getByRole('menuitem', { name: /Save as Markdown/i }), + ).toBeInTheDocument(); + }); + + it('closes the export popover when the user opens the history dropdown', async () => { + await enterChatMode(); + // HistoryPanel renders when the dropdown opens and iterates over + // the conversations list — stub the IPC source so it gets []. + const prev = invoke.getMockImplementation(); + invoke.mockImplementation(async (cmd, args) => { + if (cmd === 'list_conversations') return []; + return prev ? prev(cmd, args) : undefined; + }); + await openExportPopover(); + expect( + screen.getByRole('menuitem', { name: /Save as Markdown/i }), + ).toBeInTheDocument(); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Open history' })); + }); + + expect( + screen.queryByRole('menuitem', { name: /Save as Markdown/i }), + ).toBeNull(); + }); + + it('closes the export popover when the user opens the model picker', async () => { + await enterChatMode(); + await openExportPopover(); + expect( + screen.getByRole('menuitem', { name: /Save as Markdown/i }), + ).toBeInTheDocument(); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Choose model' })); + }); + + expect( + screen.queryByRole('menuitem', { name: /Save as Markdown/i }), + ).toBeNull(); + }); + + it('closes the export popover when the user minimizes the overlay', async () => { + await enterChatMode(); + await openExportPopover(); + expect( + screen.getByRole('menuitem', { name: /Save as Markdown/i }), + ).toBeInTheDocument(); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Minimize' })); + }); + + expect( + screen.queryByRole('menuitem', { name: /Save as Markdown/i }), + ).toBeNull(); + }); + + it('closes the export popover when the user starts a new conversation', async () => { + await enterChatMode(); + // The "New conversation" handler routes through HistoryPanel as + // the SwitchConfirmation host when the session is unsaved, so the + // panel may mount; stub list_conversations to be safe. + const prev = invoke.getMockImplementation(); + invoke.mockImplementation(async (cmd, args) => { + if (cmd === 'list_conversations') return []; + return prev ? prev(cmd, args) : undefined; + }); + await openExportPopover(); + expect( + screen.getByRole('menuitem', { name: /Save as Markdown/i }), + ).toBeInTheDocument(); + + await act(async () => { + fireEvent.click( + screen.getByRole('button', { name: 'New conversation' }), + ); + }); + + expect( + screen.queryByRole('menuitem', { name: /Save as Markdown/i }), + ).toBeNull(); + }); + + it('Escape dismisses the popover and returns focus to the toggle button (does not close the overlay)', async () => { + await enterChatMode(); + await openExportPopover(); + const toggle = screen.getByRole('button', { name: 'Export chat' }); + + await act(async () => { + fireEvent.keyDown(window, { key: 'Escape' }); + }); + await act(async () => {}); + + expect( + screen.queryByRole('menuitem', { name: /Save as Markdown/i }), + ).toBeNull(); + expect(document.activeElement).toBe(toggle); + // The overlay is still mounted (the export button is still there). + expect(toggle).toBeInTheDocument(); + }); + + it('drops a re-entrant export click while the first is still in flight', async () => { + await enterChatMode(); + let resolveFirst: ((v: boolean) => void) | undefined; + let calls = 0; + overrideExportInvoke( + () => + new Promise((resolve) => { + calls += 1; + if (calls === 1) { + resolveFirst = resolve; + } else { + resolve(true); + } + }), + ); + + // First click — popover closes, runFileExport is in flight. + await openExportPopover(); + await act(async () => { + fireEvent.click( + screen.getByRole('menuitem', { name: /Save as Markdown/i }), + ); + }); + await act(async () => {}); + + // Second click — reopen popover and click again. Should NOT + // dispatch a second prompt_and_save_chat_export. + await openExportPopover(); + await act(async () => { + fireEvent.click( + screen.getByRole('menuitem', { name: /Save as Markdown/i }), + ); + }); + await act(async () => {}); + + expect(calls).toBe(1); + + // Resolve the first call; verify a subsequent click then succeeds. + await act(async () => { + resolveFirst?.(true); + }); + await act(async () => {}); + + await openExportPopover(); + await act(async () => { + fireEvent.click( + screen.getByRole('menuitem', { name: /Save as Markdown/i }), + ); + }); + await act(async () => {}); + + expect(calls).toBe(2); + }); + + it('drives overlay alpha to 0 before the IPC call and back to 1 after success', async () => { + await enterChatMode(); + overrideExportInvoke(async () => true); + invoke.mockClear(); + overrideExportInvoke(async () => true); + + await openExportPopover(); + await act(async () => { + fireEvent.click( + screen.getByRole('menuitem', { name: /Save as Markdown/i }), + ); + }); + await act(async () => {}); + + await vi.waitFor(() => { + expect(invoke).toHaveBeenCalledWith('set_overlay_alpha', { + alpha: 0, + durationMs: 0, + }); + }); + await vi.waitFor(() => { + expect(invoke).toHaveBeenCalledWith('set_overlay_alpha', { + alpha: 1, + durationMs: 150, + }); + }); + + // Assert ordering: alpha:0 → prompt_and_save_chat_export → alpha:1 + // so the overlay stays hidden for exactly the dialog+write + // window and not a frame longer. + const calls = vi.mocked(invoke).mock.calls; + const alphaZeroIdx = calls.findIndex( + (call) => + call[0] === 'set_overlay_alpha' && + (call[1] as { alpha: number } | undefined)?.alpha === 0, + ); + const promptIdx = calls.findIndex( + (call) => call[0] === 'prompt_and_save_chat_export', + ); + const alphaOneIdx = calls.findIndex( + (call) => + call[0] === 'set_overlay_alpha' && + (call[1] as { alpha: number } | undefined)?.alpha === 1, + ); + expect(alphaZeroIdx).toBeGreaterThanOrEqual(0); + expect(promptIdx).toBeGreaterThan(alphaZeroIdx); + expect(alphaOneIdx).toBeGreaterThan(promptIdx); + }); + + it('restores overlay alpha to 1 when the Rust command reports cancellation', async () => { + await enterChatMode(); + overrideExportInvoke(async () => false); + invoke.mockClear(); + overrideExportInvoke(async () => false); + + await openExportPopover(); + await act(async () => { + fireEvent.click( + screen.getByRole('menuitem', { name: /Save as Markdown/i }), + ); + }); + await act(async () => {}); + + await vi.waitFor(() => { + expect(invoke).toHaveBeenCalledWith('set_overlay_alpha', { + alpha: 1, + durationMs: 150, + }); + }); + // No banner on a clean cancellation. + expect(screen.queryByText(/Failed to export/)).not.toBeInTheDocument(); + }); + + it('restores overlay alpha to 1 when the Rust command rejects', async () => { + await enterChatMode(); + overrideExportInvoke(async () => { + throw new Error('disk full'); + }); + + await openExportPopover(); + await act(async () => { + fireEvent.click( + screen.getByRole('menuitem', { name: /Save as Markdown/i }), + ); + }); + await act(async () => {}); + + await vi.waitFor(() => { + expect(invoke).toHaveBeenCalledWith('set_overlay_alpha', { + alpha: 1, + durationMs: 150, + }); + }); + }); + }); }); diff --git a/src/components/WindowControls.tsx b/src/components/WindowControls.tsx index acc7b17..a8d6a6a 100644 --- a/src/components/WindowControls.tsx +++ b/src/components/WindowControls.tsx @@ -102,6 +102,25 @@ const HISTORY_ICON = ( ); +/** Hoisted download-arrow-into-tray icon. */ +const EXPORT_ICON = ( + +); + interface WindowControlsProps { /** Triggers the overlay hide animation sequence. */ onClose: () => void; @@ -150,6 +169,16 @@ interface WindowControlsProps { * dot inert and decorative (ask-bar mode or no conversation to park). */ onMinimize?: () => void; + /** + * Called when the user clicks the export button to open the export + * options popover. Omit to hide the export button entirely. + */ + onExportToggle?: () => void; + /** + * Drives `aria-expanded` on the export button so screen readers reflect + * the popover's open state. + */ + isExportOpen?: boolean; } /** Decorative dot color for inactive buttons. */ @@ -166,6 +195,8 @@ export const WindowControls = memo(function WindowControls({ onModelPickerToggle, isModelPickerOpen = false, onMinimize, + onExportToggle, + isExportOpen = false, }: WindowControlsProps) { // Disabled only when there is nothing to save yet and the conversation hasn't // been saved. Once saved the button stays active so the user can unsave. @@ -350,6 +381,26 @@ export const WindowControls = memo(function WindowControls({ )} + + {onExportToggle !== undefined && ( + + + + )} diff --git a/src/config/tips.ts b/src/config/tips.ts index 1153685..0617dbd 100644 --- a/src/config/tips.ts +++ b/src/config/tips.ts @@ -66,4 +66,7 @@ export const TIPS: readonly Tip[] = [ text: 'Agentic search can dig deeper than a quick web lookup when the answer needs trail-following ↗', url: 'https://github.com/quiet-node/thuki/blob/main/docs/agentic-search.md', }, + 'The export icon in the chat header saves the current session as a Markdown file', + 'Click the export icon and pick Copy to clipboard to grab the whole conversation, ready to paste anywhere', + 'Exports include the model name, every message, /think reasoning, search sources, and inline screenshots', ]; diff --git a/src/lib/__tests__/exportSerializer.test.ts b/src/lib/__tests__/exportSerializer.test.ts new file mode 100644 index 0000000..4dc40ec --- /dev/null +++ b/src/lib/__tests__/exportSerializer.test.ts @@ -0,0 +1,683 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import type { Message } from '../../hooks/useOllama'; +import { + defaultExportFilename, + defaultImageLoader, + escapeMarkdownLinkText, + formatMarkdownSourceLine, + isSafeHttpUrl, + serializeForClipboard, + serializeForFile, + yamlQuote, + type FileExportContext, + type ImageLoader, +} from '../exportSerializer'; + +vi.mock('@tauri-apps/api/core', () => ({ + convertFileSrc: (path: string) => `asset://${path}`, +})); + +function makeMessage(overrides: Partial): Message { + return { + id: 'msg-id', + role: 'user', + content: '', + ...overrides, + }; +} + +const CTX: FileExportContext = { fallbackModel: 'default-model' }; + +const stubImageLoader: ImageLoader = async (path) => + `data:image/jpeg;base64,STUB(${path})`; + +describe('defaultExportFilename', () => { + it('formats local date and time with zero padding', () => { + // 2026-01-09T03:07:00 local + const filename = defaultExportFilename(new Date(2026, 0, 9, 3, 7, 0)); + expect(filename).toBe('thuki-chat-2026-01-09-0307.md'); + }); + + it('formats single-digit month and day with zero padding', () => { + const filename = defaultExportFilename(new Date(2026, 5, 4, 12, 30, 0)); + expect(filename).toBe('thuki-chat-2026-06-04-1230.md'); + }); + + it('formats midnight as 0000', () => { + const filename = defaultExportFilename(new Date(2026, 11, 31, 0, 0, 0)); + expect(filename).toBe('thuki-chat-2026-12-31-0000.md'); + }); + + it('formats double-digit hour and minute correctly', () => { + const filename = defaultExportFilename(new Date(2026, 4, 24, 14, 30, 15)); + expect(filename).toBe('thuki-chat-2026-05-24-1430.md'); + }); +}); + +describe('serializeForFile', () => { + const NOW = new Date(2026, 4, 24, 14, 30, 15); + + it('emits YAML frontmatter with model, exported_at, message_count', async () => { + const messages: Message[] = [ + makeMessage({ id: 'u1', role: 'user', content: 'hello' }), + makeMessage({ + id: 'a1', + role: 'assistant', + content: 'hi', + modelName: 'llama3.2:3b', + }), + ]; + + const result = await serializeForFile(messages, CTX, NOW, stubImageLoader); + + expect(result).toContain('---\napp: "Thuki"'); + expect(result).toContain('model: "llama3.2:3b"'); + expect(result).toMatch(/exported_at: "2026-05-24T14:30:15[+-]\d{2}:\d{2}"/); + expect(result).toContain('message_count: 2'); + }); + + it('falls back to the supplied fallbackModel when no assistant has modelName', async () => { + const messages: Message[] = [ + makeMessage({ id: 'u1', role: 'user', content: 'hello' }), + makeMessage({ id: 'a1', role: 'assistant', content: 'hi' }), + ]; + + const result = await serializeForFile(messages, CTX, NOW, stubImageLoader); + expect(result).toContain('model: "default-model"'); + }); + + it('emits "unknown" when no assistant modelName and no fallback', async () => { + const messages: Message[] = [ + makeMessage({ id: 'u1', role: 'user', content: 'hello' }), + ]; + const result = await serializeForFile( + messages, + { fallbackModel: null }, + NOW, + stubImageLoader, + ); + expect(result).toContain('model: "unknown"'); + }); + + it('emits frontmatter even when there are zero messages', async () => { + const result = await serializeForFile([], CTX, NOW, stubImageLoader); + expect(result).toContain('message_count: 0'); + // No trailing message body separator. + expect(result.split('---').length).toBe(3); + }); + + it('renders user messages with the User heading', async () => { + const messages: Message[] = [ + makeMessage({ id: 'u1', role: 'user', content: 'a question' }), + ]; + const result = await serializeForFile(messages, CTX, NOW, stubImageLoader); + expect(result).toContain('## User\n\na question'); + }); + + it('renders assistant messages with the model name in parentheses', async () => { + const messages: Message[] = [ + makeMessage({ + id: 'a1', + role: 'assistant', + content: 'an answer', + modelName: 'qwen:7b', + }), + ]; + const result = await serializeForFile(messages, CTX, NOW, stubImageLoader); + expect(result).toContain('## Assistant (qwen:7b)\n\nan answer'); + }); + + it('renders an unattributed assistant message as plain "Assistant"', async () => { + const messages: Message[] = [ + makeMessage({ id: 'a1', role: 'assistant', content: 'an answer' }), + ]; + const result = await serializeForFile(messages, CTX, NOW, stubImageLoader); + expect(result).toContain('## Assistant\n\nan answer'); + }); + + it('renders quoted text as a Markdown blockquote', async () => { + const messages: Message[] = [ + makeMessage({ + id: 'u1', + role: 'user', + content: 'follow up', + quotedText: 'first line\nsecond line', + }), + ]; + const result = await serializeForFile(messages, CTX, NOW, stubImageLoader); + expect(result).toContain('> first line\n> second line'); + }); + + it('renders thinking content inside a collapsed details block', async () => { + const messages: Message[] = [ + makeMessage({ + id: 'a1', + role: 'assistant', + content: 'final answer', + thinkingContent: 'step 1\nstep 2', + modelName: 'thinker:7b', + }), + ]; + const result = await serializeForFile(messages, CTX, NOW, stubImageLoader); + expect(result).toContain( + '
\nThinking\n\nstep 1\nstep 2\n\n
', + ); + }); + + it('renders search sources as a numbered list', async () => { + const messages: Message[] = [ + makeMessage({ + id: 'a1', + role: 'assistant', + content: 'See sources.', + searchSources: [ + { title: 'First', url: 'https://example.com/one' }, + { title: 'Second', url: 'https://example.com/two' }, + ], + }), + ]; + const result = await serializeForFile(messages, CTX, NOW, stubImageLoader); + expect(result).toContain('**Sources** (`/search`):'); + expect(result).toContain('1. [First]()'); + expect(result).toContain('2. [Second]()'); + }); + + it('uses the source URL as the link label when the title is empty', async () => { + const messages: Message[] = [ + makeMessage({ + id: 'a1', + role: 'assistant', + content: 'See', + searchSources: [{ title: '', url: 'https://nowhere.example/page' }], + }), + ]; + const result = await serializeForFile(messages, CTX, NOW, stubImageLoader); + expect(result).toContain( + '1. [https://nowhere.example/page]()', + ); + }); + + it('skips the sources section entirely when none are present', async () => { + const messages: Message[] = [ + makeMessage({ id: 'a1', role: 'assistant', content: 'no sources' }), + ]; + const result = await serializeForFile(messages, CTX, NOW, stubImageLoader); + expect(result).not.toContain('**Sources**'); + }); + + it('inlines images as data URIs from the supplied loader', async () => { + const messages: Message[] = [ + makeMessage({ + id: 'u1', + role: 'user', + content: 'look at this', + imagePaths: ['/Users/me/screen.jpg'], + }), + ]; + const result = await serializeForFile(messages, CTX, NOW, stubImageLoader); + expect(result).toContain( + '![Screenshot](data:image/jpeg;base64,STUB(/Users/me/screen.jpg))', + ); + }); + + it('renders multiple images for a single message on separate lines', async () => { + const messages: Message[] = [ + makeMessage({ + id: 'u1', + role: 'user', + content: '', + imagePaths: ['/a.jpg', '/b.jpg'], + }), + ]; + const result = await serializeForFile(messages, CTX, NOW, stubImageLoader); + expect(result).toContain( + '![Screenshot](data:image/jpeg;base64,STUB(/a.jpg))\n\n![Screenshot](data:image/jpeg;base64,STUB(/b.jpg))', + ); + }); + + it('falls back to a textual marker when an image loader rejects', async () => { + const failingLoader: ImageLoader = async () => { + throw new Error('not found'); + }; + const messages: Message[] = [ + makeMessage({ + id: 'u1', + role: 'user', + content: '', + imagePaths: ['/Users/me/missing.jpg'], + }), + ]; + const result = await serializeForFile(messages, CTX, NOW, failingLoader); + expect(result).toContain('_[Screenshot unavailable: missing.jpg]_'); + expect(result).not.toContain('data:image/jpeg'); + }); + + it('separates consecutive messages with a horizontal rule', async () => { + const messages: Message[] = [ + makeMessage({ id: 'u1', role: 'user', content: 'first' }), + makeMessage({ id: 'a1', role: 'assistant', content: 'second' }), + ]; + const result = await serializeForFile(messages, CTX, NOW, stubImageLoader); + expect(result).toContain('first\n\n---\n\n## Assistant'); + }); + + it('uses a "+" sign in exported_at for timezones east of UTC', async () => { + // getTimezoneOffset is NEGATIVE east of UTC (e.g., JST = -540). + const spy = vi + .spyOn(Date.prototype, 'getTimezoneOffset') + .mockReturnValue(-540); + try { + const result = await serializeForFile( + [makeMessage({ role: 'user', content: 'hi' })], + CTX, + new Date(2026, 4, 24, 14, 30, 15), + stubImageLoader, + ); + expect(result).toMatch(/exported_at: "2026-05-24T14:30:15\+09:00"/); + } finally { + spy.mockRestore(); + } + }); + + it('uses a "-" sign in exported_at for timezones west of UTC', async () => { + // getTimezoneOffset is POSITIVE west of UTC (e.g., EST = +300). + const spy = vi + .spyOn(Date.prototype, 'getTimezoneOffset') + .mockReturnValue(300); + try { + const result = await serializeForFile( + [makeMessage({ role: 'user', content: 'hi' })], + CTX, + new Date(2026, 4, 24, 14, 30, 15), + stubImageLoader, + ); + expect(result).toMatch(/exported_at: "2026-05-24T14:30:15-05:00"/); + } finally { + spy.mockRestore(); + } + }); + + it('handles a Windows-style image path in basename fallback', async () => { + const failingLoader: ImageLoader = async () => { + throw new Error('boom'); + }; + const messages: Message[] = [ + makeMessage({ + id: 'u1', + role: 'user', + content: '', + imagePaths: ['C:\\Users\\me\\shot.png'], + }), + ]; + const result = await serializeForFile(messages, CTX, NOW, failingLoader); + expect(result).toContain('_[Screenshot unavailable: shot.png]_'); + }); + + it('uses the bare path when no slash is present for the basename fallback', async () => { + const failingLoader: ImageLoader = async () => { + throw new Error('boom'); + }; + const messages: Message[] = [ + makeMessage({ + id: 'u1', + role: 'user', + content: '', + imagePaths: ['orphan.png'], + }), + ]; + const result = await serializeForFile(messages, CTX, NOW, failingLoader); + expect(result).toContain('_[Screenshot unavailable: orphan.png]_'); + }); +}); + +describe('serializeForClipboard', () => { + it('omits the YAML frontmatter entirely', () => { + const messages: Message[] = [ + makeMessage({ id: 'u1', role: 'user', content: 'hi' }), + ]; + const result = serializeForClipboard(messages); + expect(result).not.toContain('---\napp:'); + expect(result).not.toContain('exported_at'); + }); + + it('renders role-labelled blocks identical to the file output (text only)', () => { + const messages: Message[] = [ + makeMessage({ id: 'u1', role: 'user', content: 'hi' }), + makeMessage({ + id: 'a1', + role: 'assistant', + content: 'hello', + modelName: 'qwen:7b', + }), + ]; + const result = serializeForClipboard(messages); + expect(result).toContain('## User\n\nhi'); + expect(result).toContain('## Assistant (qwen:7b)\n\nhello'); + }); + + it('replaces images with a textual marker (no base64 payload)', () => { + const messages: Message[] = [ + makeMessage({ + id: 'u1', + role: 'user', + content: 'look', + imagePaths: ['/Users/me/photo.jpg'], + }), + ]; + const result = serializeForClipboard(messages); + expect(result).toContain('_[Screenshot: photo.jpg]_'); + expect(result).not.toContain('data:image'); + }); + + it('renders multiple image markers on separate lines', () => { + const messages: Message[] = [ + makeMessage({ + id: 'u1', + role: 'user', + content: '', + imagePaths: ['/a.jpg', '/b.jpg'], + }), + ]; + const result = serializeForClipboard(messages); + expect(result).toContain('_[Screenshot: a.jpg]_\n\n_[Screenshot: b.jpg]_'); + }); + + it('keeps thinking blocks and search sources', () => { + const messages: Message[] = [ + makeMessage({ + id: 'a1', + role: 'assistant', + content: 'answer', + thinkingContent: 'considering', + searchSources: [{ title: 'Doc', url: 'https://example.com' }], + modelName: 'qwen:7b', + }), + ]; + const result = serializeForClipboard(messages); + expect(result).toContain('
'); + expect(result).toContain('1. [Doc]()'); + }); + + it('returns an empty string when there are zero messages', () => { + expect(serializeForClipboard([])).toBe(''); + }); + + it('keeps quoted blockquotes', () => { + const messages: Message[] = [ + makeMessage({ + id: 'u1', + role: 'user', + content: 'context', + quotedText: 'line one', + }), + ]; + const result = serializeForClipboard(messages); + expect(result).toContain('> line one'); + }); +}); + +// `defaultImageLoader` is the production image loader that screenshots flow +// through when the export is triggered for real. It reads an asset-protocol +// URL via `fetch` and base64-encodes the resulting Blob through a FileReader. +// happy-dom ships a real FileReader implementation, so the success path can +// run end-to-end with only `fetch` stubbed. The failure paths swap in a +// fake FileReader because the real implementation never produces a +// non-string result and never fires `onerror` on a freshly fetched Blob. + +describe('defaultImageLoader', () => { + const originalFetch = globalThis.fetch; + const originalFileReader = globalThis.FileReader; + + afterEach(() => { + globalThis.fetch = originalFetch; + globalThis.FileReader = originalFileReader; + }); + + it('reads a file path and returns a data URI', async () => { + const blob = new Blob(['data'], { type: 'image/png' }); + globalThis.fetch = vi.fn(async () => { + return { + blob: async () => blob, + } as unknown as Response; + }); + + const result = await defaultImageLoader('/path/img.png'); + expect(result).toMatch(/^data:image\/png/); + expect(globalThis.fetch).toHaveBeenCalledWith('asset:///path/img.png'); + }); + + it('rejects when FileReader yields a non-string result', async () => { + class NonStringResultFileReader { + result: ArrayBuffer | null = null; + onload: ((this: NonStringResultFileReader) => void) | null = null; + onerror: ((this: NonStringResultFileReader) => void) | null = null; + error: DOMException | null = null; + readAsDataURL() { + this.result = new ArrayBuffer(2); + queueMicrotask(() => this.onload?.call(this)); + } + } + globalThis.FileReader = + NonStringResultFileReader as unknown as typeof FileReader; + const blob = new Blob(['data'], { type: 'image/png' }); + globalThis.fetch = vi.fn( + async () => ({ blob: async () => blob }) as unknown as Response, + ); + + await expect(defaultImageLoader('/x')).rejects.toThrow( + 'FileReader did not return a string data URI', + ); + }); + + it('rejects with the underlying FileReader error when onerror fires', async () => { + const readerError = new Error('read failed') as unknown as DOMException; + class FailingFileReader { + result: string | null = null; + onload: ((this: FailingFileReader) => void) | null = null; + onerror: ((this: FailingFileReader) => void) | null = null; + error: DOMException | null = readerError; + readAsDataURL() { + queueMicrotask(() => this.onerror?.call(this)); + } + } + globalThis.FileReader = FailingFileReader as unknown as typeof FileReader; + const blob = new Blob(['data'], { type: 'image/png' }); + globalThis.fetch = vi.fn( + async () => ({ blob: async () => blob }) as unknown as Response, + ); + + await expect(defaultImageLoader('/x')).rejects.toThrow('read failed'); + }); + + it('rejects with a generic FileReader error when reader.error is null', async () => { + class NullErrorFileReader { + result: string | null = null; + onload: ((this: NullErrorFileReader) => void) | null = null; + onerror: ((this: NullErrorFileReader) => void) | null = null; + error: DOMException | null = null; + readAsDataURL() { + queueMicrotask(() => this.onerror?.call(this)); + } + } + globalThis.FileReader = NullErrorFileReader as unknown as typeof FileReader; + const blob = new Blob(['data'], { type: 'image/png' }); + globalThis.fetch = vi.fn( + async () => ({ blob: async () => blob }) as unknown as Response, + ); + + await expect(defaultImageLoader('/x')).rejects.toThrow('FileReader error'); + }); +}); + +describe('yamlQuote', () => { + it('wraps a plain string in double quotes', () => { + expect(yamlQuote('Thuki')).toBe('"Thuki"'); + }); + + it('escapes embedded double quotes', () => { + expect(yamlQuote('he said "hi"')).toBe('"he said \\"hi\\""'); + }); + + it('escapes backslashes', () => { + expect(yamlQuote('a\\b')).toBe('"a\\\\b"'); + }); + + it('escapes newline / carriage return / tab as YAML escape sequences', () => { + expect(yamlQuote('a\nb')).toBe('"a\\nb"'); + expect(yamlQuote('a\rb')).toBe('"a\\rb"'); + expect(yamlQuote('a\tb')).toBe('"a\\tb"'); + }); + + it('encodes ASCII control characters as \\xHH', () => { + expect(yamlQuote('\x00\x01\x1f')).toBe('"\\x00\\x01\\x1f"'); + }); + + it('encodes DEL (0x7f) as \\x7f', () => { + expect(yamlQuote('\x7f')).toBe('"\\x7f"'); + }); + + it('neutralises a YAML-injection attempt via embedded "---" + newline', () => { + const malicious = 'my-model\n---\ninjected: true'; + const quoted = yamlQuote(malicious); + expect(quoted).toBe('"my-model\\n---\\ninjected: true"'); + expect(quoted).not.toContain('\n'); + }); + + it('preserves printable ASCII verbatim', () => { + expect(yamlQuote('abc 123 :-/')).toBe('"abc 123 :-/"'); + }); +}); + +describe('escapeMarkdownLinkText', () => { + it('escapes backslashes', () => { + expect(escapeMarkdownLinkText('a\\b')).toBe('a\\\\b'); + }); + + it('escapes square brackets', () => { + expect(escapeMarkdownLinkText('foo[bar]baz')).toBe('foo\\[bar\\]baz'); + }); + + it('escapes nested ](', () => { + expect(escapeMarkdownLinkText('] (')).toBe('\\] ('); + }); + + it('leaves a plain string untouched', () => { + expect(escapeMarkdownLinkText('clean title')).toBe('clean title'); + }); +}); + +describe('isSafeHttpUrl', () => { + it('accepts http and https URLs', () => { + expect(isSafeHttpUrl('http://example.com')).toBe(true); + expect(isSafeHttpUrl('https://example.com/page?q=1')).toBe(true); + expect(isSafeHttpUrl('HTTPS://EXAMPLE.COM')).toBe(true); + }); + + it('rejects javascript:, data:, file:, mailto:, vbscript: URLs', () => { + expect(isSafeHttpUrl('javascript:alert(1)')).toBe(false); + expect(isSafeHttpUrl('data:text/html,')).toBe(false); + expect(isSafeHttpUrl('file:///etc/passwd')).toBe(false); + expect(isSafeHttpUrl('mailto:x@y.z')).toBe(false); + expect(isSafeHttpUrl('vbscript:msgbox(1)')).toBe(false); + }); + + it('rejects URLs containing control characters', () => { + expect(isSafeHttpUrl('https://example.com/\n')).toBe(false); + expect(isSafeHttpUrl('https://example.com/\r')).toBe(false); + expect(isSafeHttpUrl('https://example.com/\t')).toBe(false); + }); + + it('rejects bare strings without a scheme', () => { + expect(isSafeHttpUrl('example.com')).toBe(false); + expect(isSafeHttpUrl('')).toBe(false); + }); +}); + +describe('formatMarkdownSourceLine', () => { + it('renders a safe https URL as an angle-bracketed Markdown link', () => { + expect( + formatMarkdownSourceLine(1, 'Example', 'https://example.com/a?b=c'), + ).toBe('1. [Example]()'); + }); + + it('escapes link-text metacharacters in the title', () => { + expect( + formatMarkdownSourceLine(2, 'Has ] bracket', 'https://example.com'), + ).toBe('2. [Has \\] bracket]()'); + }); + + it('degrades a javascript: URL to a non-clickable line that preserves the raw URL', () => { + const line = formatMarkdownSourceLine( + 3, + 'Click here', + 'javascript:alert(1)', + ); + expect(line).toBe('3. Click here (link omitted: javascript:alert(1))'); + expect(line).not.toContain('[Click here]'); + expect(line).not.toMatch(/\]\(javascript:/); + }); + + it('degrades a URL containing angle brackets even with an http scheme', () => { + const line = formatMarkdownSourceLine( + 4, + 'Bad', + 'https://example.com/