diff --git a/src/lib/components/Markdown.svelte b/src/lib/components/Markdown.svelte index 6786210..c0fa9ae 100644 --- a/src/lib/components/Markdown.svelte +++ b/src/lib/components/Markdown.svelte @@ -2,8 +2,9 @@ import { marked } from "marked"; import hljs from "highlight.js"; import { onMount } from "svelte"; - import { openUrl } from "@tauri-apps/plugin-opener"; + import { openUrl, openPath } from "@tauri-apps/plugin-opener"; import { clipboardStore } from "$lib/stores/clipboard"; + import { linkifyFilePaths } from "$lib/utils/filePaths"; interface Props { content: string; @@ -113,7 +114,8 @@ let parsedHtml = $derived.by(() => { try { const html = marked.parse(content) as string; - return processSpoilers(html); + const withSpoilers = processSpoilers(html); + return linkifyFilePaths(withSpoilers); } catch { return content; } @@ -140,9 +142,18 @@ function handleLinkClick(event: MouseEvent) { const target = event.target as HTMLElement; const anchor = target.closest("a"); - if (anchor?.href) { - event.preventDefault(); - openUrl(anchor.href); + if (!anchor) return; + + event.preventDefault(); + + const filePath = anchor.dataset.filepath; + if (filePath) { + void openPath(filePath); + return; + } + + if (anchor.href) { + void openUrl(anchor.href); } } @@ -453,4 +464,27 @@ border-radius: 2px; padding: 0 2px; } + + .markdown-content :global(.file-link) { + display: inline-flex; + align-items: center; + gap: 0.25em; + color: var(--accent-primary, #f472b6); + text-decoration: none; + border: 1px solid color-mix(in srgb, var(--accent-primary) 30%, transparent); + background: color-mix(in srgb, var(--accent-primary) 8%, transparent); + border-radius: 4px; + padding: 0.1em 0.4em; + font-family: "JetBrains Mono", "Fira Code", monospace; + font-size: 0.875em; + cursor: pointer; + transition: all 0.15s ease; + word-break: break-all; + } + + .markdown-content :global(.file-link:hover) { + background: color-mix(in srgb, var(--accent-primary) 18%, transparent); + border-color: color-mix(in srgb, var(--accent-primary) 60%, transparent); + color: var(--accent-secondary, #e879f9); + } diff --git a/src/lib/utils/filePaths.test.ts b/src/lib/utils/filePaths.test.ts new file mode 100644 index 0000000..e9f19d8 --- /dev/null +++ b/src/lib/utils/filePaths.test.ts @@ -0,0 +1,270 @@ +import { describe, it, expect } from "vitest"; +import { + BINARY_FILE_EXTENSIONS, + getFileExtension, + getFileTypeIcon, + isBinaryFilePath, + linkifyFilePaths, +} from "./filePaths"; + +describe("getFileExtension", () => { + it("returns the lowercase extension of a simple path", () => { + expect(getFileExtension("/tmp/report.pdf")).toBe("pdf"); + }); + + it("returns the lowercase extension for uppercase file names", () => { + expect(getFileExtension("/tmp/AUDIO.MP3")).toBe("mp3"); + }); + + it("returns the extension for a path with multiple dots", () => { + expect(getFileExtension("/tmp/my.file.docx")).toBe("docx"); + }); + + it("returns an empty string when there is no extension", () => { + expect(getFileExtension("/tmp/noextension")).toBe(""); + }); + + it("returns an empty string for an empty string input", () => { + expect(getFileExtension("")).toBe(""); + }); + + it("returns the extension for a home-relative path", () => { + expect(getFileExtension("~/downloads/track.wav")).toBe("wav"); + }); +}); + +describe("getFileTypeIcon", () => { + it("returns the PDF icon for .pdf files", () => { + expect(getFileTypeIcon("/tmp/doc.pdf")).toBe("📄"); + }); + + it("returns the Word icon for .docx files", () => { + expect(getFileTypeIcon("/tmp/report.docx")).toBe("📝"); + }); + + it("returns the Word icon for .doc files", () => { + expect(getFileTypeIcon("/tmp/old.doc")).toBe("📝"); + }); + + it("returns the spreadsheet icon for .xlsx files", () => { + expect(getFileTypeIcon("/tmp/data.xlsx")).toBe("📊"); + }); + + it("returns the spreadsheet icon for .xls files", () => { + expect(getFileTypeIcon("/tmp/data.xls")).toBe("📊"); + }); + + it("returns the presentation icon for .pptx files", () => { + expect(getFileTypeIcon("/tmp/slides.pptx")).toBe("📽️"); + }); + + it("returns the presentation icon for .ppt files", () => { + expect(getFileTypeIcon("/tmp/slides.ppt")).toBe("📽️"); + }); + + it("returns the audio icon for .mp3 files", () => { + expect(getFileTypeIcon("/tmp/song.mp3")).toBe("🎵"); + }); + + it("returns the audio icon for .wav files", () => { + expect(getFileTypeIcon("/tmp/sound.wav")).toBe("🎵"); + }); + + it("returns the audio icon for .ogg files", () => { + expect(getFileTypeIcon("/tmp/audio.ogg")).toBe("🎵"); + }); + + it("returns the audio icon for .flac files", () => { + expect(getFileTypeIcon("/tmp/lossless.flac")).toBe("🎵"); + }); + + it("returns the audio icon for .aac files", () => { + expect(getFileTypeIcon("/tmp/compressed.aac")).toBe("🎵"); + }); + + it("returns the audio icon for .m4a files", () => { + expect(getFileTypeIcon("/tmp/itunes.m4a")).toBe("🎵"); + }); + + it("returns the video icon for .mp4 files", () => { + expect(getFileTypeIcon("/tmp/video.mp4")).toBe("🎬"); + }); + + it("returns the video icon for .avi files", () => { + expect(getFileTypeIcon("/tmp/old.avi")).toBe("🎬"); + }); + + it("returns the video icon for .mov files", () => { + expect(getFileTypeIcon("/tmp/clip.mov")).toBe("🎬"); + }); + + it("returns the video icon for .mkv files", () => { + expect(getFileTypeIcon("/tmp/film.mkv")).toBe("🎬"); + }); + + it("returns the video icon for .webm files", () => { + expect(getFileTypeIcon("/tmp/stream.webm")).toBe("🎬"); + }); + + it("returns the archive icon for .zip files", () => { + expect(getFileTypeIcon("/tmp/bundle.zip")).toBe("📦"); + }); + + it("returns the archive icon for .tar files", () => { + expect(getFileTypeIcon("/tmp/archive.tar")).toBe("📦"); + }); + + it("returns the archive icon for .gz files", () => { + expect(getFileTypeIcon("/tmp/compressed.gz")).toBe("📦"); + }); + + it("returns the disk icon for .bin files", () => { + expect(getFileTypeIcon("/tmp/firmware.bin")).toBe("💿"); + }); + + it("returns the disk icon for .iso files", () => { + expect(getFileTypeIcon("/tmp/image.iso")).toBe("💿"); + }); + + it("returns the generic folder icon for an unknown extension", () => { + expect(getFileTypeIcon("/tmp/file.unknown")).toBe("📁"); + }); + + it("returns the generic folder icon for a file with no extension", () => { + expect(getFileTypeIcon("/tmp/noext")).toBe("📁"); + }); +}); + +describe("isBinaryFilePath", () => { + it("returns true for a PDF path", () => { + expect(isBinaryFilePath("/tmp/report.pdf")).toBe(true); + }); + + it("returns true for an audio path", () => { + expect(isBinaryFilePath("/tmp/song.mp3")).toBe(true); + }); + + it("returns true for a video path", () => { + expect(isBinaryFilePath("/tmp/clip.mp4")).toBe(true); + }); + + it("returns true for a document path", () => { + expect(isBinaryFilePath("/tmp/doc.docx")).toBe(true); + }); + + it("returns false for a TypeScript file", () => { + expect(isBinaryFilePath("/src/index.ts")).toBe(false); + }); + + it("returns false for a text file", () => { + expect(isBinaryFilePath("/tmp/output.txt")).toBe(false); + }); + + it("returns false for a path with no extension", () => { + expect(isBinaryFilePath("/tmp/file")).toBe(false); + }); +}); + +describe("BINARY_FILE_EXTENSIONS", () => { + it("includes pdf", () => { + expect(BINARY_FILE_EXTENSIONS).toContain("pdf"); + }); + + it("includes common audio extensions", () => { + expect(BINARY_FILE_EXTENSIONS).toContain("mp3"); + expect(BINARY_FILE_EXTENSIONS).toContain("wav"); + }); + + it("includes common video extensions", () => { + expect(BINARY_FILE_EXTENSIONS).toContain("mp4"); + }); + + it("includes common document extensions", () => { + expect(BINARY_FILE_EXTENSIONS).toContain("docx"); + expect(BINARY_FILE_EXTENSIONS).toContain("xlsx"); + }); +}); + +describe("linkifyFilePaths", () => { + it("converts a PDF path in plain text to a file link", () => { + const html = "
Saved to /tmp/report.pdf successfully.
"; + const result = linkifyFilePaths(html); + expect(result).toContain('data-filepath="/tmp/report.pdf"'); + expect(result).toContain("📄"); + expect(result).toContain('class="file-link"'); + }); + + it("converts an audio path to a file link", () => { + const html = "Audio saved to /tmp/output.mp3
"; + const result = linkifyFilePaths(html); + expect(result).toContain('data-filepath="/tmp/output.mp3"'); + expect(result).toContain("🎵"); + }); + + it("does not linkify paths inside code blocks", () => { + const html = "Example:
/tmp/file.pdf";
+ const result = linkifyFilePaths(html);
+ expect(result).not.toContain('data-filepath="/tmp/file.pdf"');
+ expect(result).toContain("/tmp/file.pdf");
+ });
+
+ it("does not linkify paths inside inline code", () => {
+ const html = "Use /tmp/file.pdf to open it.
Hello, this is regular text with /tmp/script.sh
"; + const result = linkifyFilePaths(html); + expect(result).toBe(html); + }); + + it("does not linkify text file paths", () => { + const html = "Saved to /tmp/output.txt
"; + const result = linkifyFilePaths(html); + expect(result).not.toContain("data-filepath"); + }); + + it("handles a home-relative path", () => { + const html = "Saved to ~/downloads/audio.flac
"; + const result = linkifyFilePaths(html); + expect(result).toContain('data-filepath="~/downloads/audio.flac"'); + expect(result).toContain("🎵"); + }); + + it("handles multiple file paths in the same HTML", () => { + const html = "Files: /tmp/a.pdf and /tmp/b.mp3
"; + const result = linkifyFilePaths(html); + expect(result).toContain('data-filepath="/tmp/a.pdf"'); + expect(result).toContain('data-filepath="/tmp/b.mp3"'); + }); + + it("does not linkify paths that contain double quotes (invalid path character)", () => { + // Double quotes are excluded from path chars so the path is not matched + const html = `Saved to /tmp/my"file.pdf
`; + const result = linkifyFilePaths(html); + expect(result).not.toContain("data-filepath"); + }); + + it("preserves existing HTML tags and attributes", () => { + const html = 'Saved to /tmp/report.pdf
'; + const result = linkifyFilePaths(html); + expect(result).toContain('class="foo"'); + expect(result).toContain('data-filepath="/tmp/report.pdf"'); + }); + + it("does not double-linkify a path already inside an anchor tag", () => { + const html = '/tmp/file.pdf'; + const result = linkifyFilePaths(html); + // The href is inside a tag (placeholder), the text content IS linkified + // but the href itself should not be modified + const hrefMatches = result.match(/href="[^"]*\/tmp\/file\.pdf[^"]*"/g) ?? []; + expect(hrefMatches.length).toBe(1); + }); + + it("returns the input unchanged when html is empty", () => { + expect(linkifyFilePaths("")).toBe(""); + }); +}); diff --git a/src/lib/utils/filePaths.ts b/src/lib/utils/filePaths.ts new file mode 100644 index 0000000..07bb6d9 --- /dev/null +++ b/src/lib/utils/filePaths.ts @@ -0,0 +1,133 @@ +/** + * Utility functions for detecting and rendering binary file paths + * saved to disk by MCP tools via the Claude Code CLI. + */ + +export const BINARY_FILE_EXTENSIONS = [ + // Documents + "pdf", + "docx", + "doc", + "xlsx", + "xls", + "pptx", + "ppt", + // Audio + "mp3", + "wav", + "ogg", + "flac", + "aac", + "m4a", + // Video + "mp4", + "avi", + "mov", + "mkv", + "webm", + // Archives + "zip", + "tar", + "gz", + // Other binaries + "bin", + "iso", +] as const; + +export type BinaryFileExtension = (typeof BINARY_FILE_EXTENSIONS)[number]; + +export function getFileExtension(filePath: string): string { + const lastDot = filePath.lastIndexOf("."); + if (lastDot === -1) return ""; + return filePath.slice(lastDot + 1).toLowerCase(); +} + +export function getFileTypeIcon(filePath: string): string { + const ext = getFileExtension(filePath); + switch (ext) { + case "pdf": + return "📄"; + case "docx": + case "doc": + return "📝"; + case "xlsx": + case "xls": + return "📊"; + case "pptx": + case "ppt": + return "📽️"; + case "mp3": + case "wav": + case "ogg": + case "flac": + case "aac": + case "m4a": + return "🎵"; + case "mp4": + case "avi": + case "mov": + case "mkv": + case "webm": + return "🎬"; + case "zip": + case "tar": + case "gz": + return "📦"; + case "bin": + case "iso": + return "💿"; + default: + return "📁"; + } +} + +export function isBinaryFilePath(filePath: string): boolean { + const ext = getFileExtension(filePath); + return (BINARY_FILE_EXTENSIONS as readonly string[]).includes(ext); +} + +/** + * Post-processes HTML content to convert binary file paths into clickable + * anchor elements with file-type icons. Skips content inside code blocks + * and existing HTML tags so it doesn't double-linkify or corrupt attributes. + */ +export function linkifyFilePaths(html: string): string { + const codeBlockPlaceholders: string[] = []; + + // Temporarily replace code blocks and inline code with placeholders + let processed = html.replace(/<(pre|code)[^>]*>[\s\S]*?<\/\1>/gi, (match) => { + codeBlockPlaceholders.push(match); + return `__FILEPATH_CODE_${codeBlockPlaceholders.length - 1}__`; + }); + + // Temporarily replace all HTML tags with placeholders + const tagPlaceholders: string[] = []; + processed = processed.replace(/<[^>]+>/g, (match) => { + tagPlaceholders.push(match); + return `__FILEPATH_TAG_${tagPlaceholders.length - 1}__`; + }); + + // Now replace binary file paths in the remaining plain text + const extensions = BINARY_FILE_EXTENSIONS.join("|"); + // No lookahead needed — the greedy character class naturally backtracks to the + // shortest match ending with a recognised extension, terminating before any + // character excluded by the class (spaces, HTML-unsafe chars, tag placeholders). + const filePathRegex = new RegExp(`((?:~/|/)[^\\s<>"'\`]+\\.(?:${extensions}))`, "gi"); + processed = processed.replace(filePathRegex, (_, filePath: string) => { + const icon = getFileTypeIcon(filePath); + const escaped = filePath.replace(/"/g, """); + return `${icon} ${filePath}`; + }); + + // Restore HTML tags + processed = processed.replace(/__FILEPATH_TAG_(\d+)__/g, (_, index) => { + return tagPlaceholders[parseInt(index, 10)]; + }); + + // Restore code blocks + processed = processed.replace(/__FILEPATH_CODE_(\d+)__/g, (_, index) => { + return codeBlockPlaceholders[parseInt(index, 10)]; + }); + + return processed; +}