generated from nhcarrigan/template
feat: CLI v2.1.68–v2.1.74 compatibility updates #221
@@ -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);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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 = "<p>Saved to /tmp/report.pdf successfully.</p>";
|
||||
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 = "<p>Audio saved to /tmp/output.mp3</p>";
|
||||
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 = "<p>Example:</p><pre><code>/tmp/file.pdf</code></pre>";
|
||||
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 = "<p>Use <code>/tmp/file.pdf</code> to open it.</p>";
|
||||
const result = linkifyFilePaths(html);
|
||||
expect(result).not.toContain('data-filepath="/tmp/file.pdf"');
|
||||
expect(result).toContain("/tmp/file.pdf");
|
||||
});
|
||||
|
||||
it("does not modify HTML that has no binary file paths", () => {
|
||||
const html = "<p>Hello, this is regular text with /tmp/script.sh</p>";
|
||||
const result = linkifyFilePaths(html);
|
||||
expect(result).toBe(html);
|
||||
});
|
||||
|
||||
it("does not linkify text file paths", () => {
|
||||
const html = "<p>Saved to /tmp/output.txt</p>";
|
||||
const result = linkifyFilePaths(html);
|
||||
expect(result).not.toContain("data-filepath");
|
||||
});
|
||||
|
||||
it("handles a home-relative path", () => {
|
||||
const html = "<p>Saved to ~/downloads/audio.flac</p>";
|
||||
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 = "<p>Files: /tmp/a.pdf and /tmp/b.mp3</p>";
|
||||
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 = `<p>Saved to /tmp/my"file.pdf</p>`;
|
||||
const result = linkifyFilePaths(html);
|
||||
expect(result).not.toContain("data-filepath");
|
||||
});
|
||||
|
||||
it("preserves existing HTML tags and attributes", () => {
|
||||
const html = '<p class="foo">Saved to /tmp/report.pdf</p>';
|
||||
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 = '<a href="/tmp/file.pdf">/tmp/file.pdf</a>';
|
||||
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("");
|
||||
});
|
||||
});
|
||||
@@ -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 `<a class="file-link" href="#" data-filepath="${escaped}">${icon} ${filePath}</a>`;
|
||||
});
|
||||
|
||||
// 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;
|
||||
}
|
||||
Reference in New Issue
Block a user