feat(markdown): intercept links in MarkdownPane — relative files navigate in Files tab, external URLs open in browser

This commit is contained in:
Hibryda 2026-03-10 00:58:46 +01:00
parent 91aa711ef3
commit cd438c2cf3
3 changed files with 75 additions and 3 deletions

View file

@ -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())

View file

@ -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('/');
}
</script>
<div class="markdown-pane">
{#if error}
<div class="error">{error}</div>
{:else}
<div class="markdown-pane-scroll">
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="markdown-pane-scroll" onclick={handleLinkClick}>
<div class="markdown-body">
{@html renderedHtml}
</div>

View file

@ -17,6 +17,17 @@
loadFiles(cwd);
});
function handleNavigate(absolutePath: string) {
// If the file is in our discovered list, select it directly
const match = files.find(f => f.path === absolutePath);
if (match) {
selectedPath = absolutePath;
} else {
// File not in sidebar — set it directly (MarkdownPane handles loading)
selectedPath = absolutePath;
}
}
async function loadFiles(dir: string) {
loading = true;
try {
@ -58,7 +69,7 @@
<main class="doc-content">
{#if selectedPath}
<MarkdownPane paneId="pf-{projectName}" filePath={selectedPath} />
<MarkdownPane paneId="pf-{projectName}" filePath={selectedPath} onNavigate={handleNavigate} />
{:else}
<div class="state-msg full">Select a file</div>
{/if}