diff --git a/v2/src-tauri/src/lib.rs b/v2/src-tauri/src/lib.rs index 3100bc7..91f08cd 100644 --- a/v2/src-tauri/src/lib.rs +++ b/v2/src-tauri/src/lib.rs @@ -552,6 +552,19 @@ async fn remote_pty_kill(state: State<'_, AppState>, machine_id: String, id: Str state.remote_manager.pty_kill(&machine_id, &id).await } +#[tauri::command] +fn open_url(url: String) -> Result<(), String> { + // Only allow http/https URLs + if !url.starts_with("http://") && !url.starts_with("https://") { + return Err("Only http/https URLs are allowed".into()); + } + std::process::Command::new("xdg-open") + .arg(&url) + .spawn() + .map_err(|e| format!("Failed to open URL: {e}"))?; + Ok(()) +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { // Force dark GTK theme for native dialogs (file chooser, etc.) @@ -616,6 +629,7 @@ pub fn run() { project_agent_state_load, cli_get_group, pick_directory, + open_url, frontend_log, ]) .plugin(tauri_plugin_updater::Builder::new().build()) diff --git a/v2/src/lib/components/Markdown/MarkdownPane.svelte b/v2/src/lib/components/Markdown/MarkdownPane.svelte index 45874df..0d03a03 100644 --- a/v2/src/lib/components/Markdown/MarkdownPane.svelte +++ b/v2/src/lib/components/Markdown/MarkdownPane.svelte @@ -8,9 +8,10 @@ filePath: string; paneId: string; onExit?: () => void; + onNavigate?: (absolutePath: string) => void; } - let { filePath, paneId, onExit }: Props = $props(); + let { filePath, paneId, onExit, onNavigate }: Props = $props(); let renderedHtml = $state(''); let error = $state(''); @@ -72,13 +73,59 @@ unlisten?.(); unwatchFile(paneId).catch(() => {}); }); + + function handleLinkClick(event: MouseEvent) { + const anchor = (event.target as HTMLElement).closest('a'); + if (!anchor) return; + + const href = anchor.getAttribute('href'); + if (!href) return; + + // Anchor links — scroll within page + if (href.startsWith('#')) return; + + event.preventDefault(); + + // External URLs — open in system browser + if (/^https?:\/\//.test(href)) { + import('@tauri-apps/api/core').then(({ invoke }) => { + invoke('open_url', { url: href }).catch(() => { + // Fallback: do nothing (no shell plugin) + }); + }); + return; + } + + // Relative file link — resolve against current file's directory + if (onNavigate) { + const dir = filePath.replace(/\/[^/]*$/, ''); + const resolved = resolveRelativePath(dir, href); + onNavigate(resolved); + } + } + + function resolveRelativePath(base: string, relative: string): string { + // Strip any anchor or query from the link + const cleanRelative = relative.split('#')[0].split('?')[0]; + const parts = base.split('/'); + for (const segment of cleanRelative.split('/')) { + if (segment === '..') { + parts.pop(); + } else if (segment !== '.' && segment !== '') { + parts.push(segment); + } + } + return parts.join('/'); + }