diff --git a/src/components/files/file-workspace-panel.tsx b/src/components/files/file-workspace-panel.tsx index e4abeb3..5972ad1 100644 --- a/src/components/files/file-workspace-panel.tsx +++ b/src/components/files/file-workspace-panel.tsx @@ -5,6 +5,7 @@ import dynamic from "next/dynamic" import { ChevronDown, ChevronRight, FileCode2, FileIcon } from "lucide-react" import type { editor as MonacoEditorNs } from "monaco-editor" import { useTranslations } from "next-intl" +import { useFolderContext } from "@/contexts/folder-context" import { useWorkspaceContext } from "@/contexts/workspace-context" import { DiffViewer } from "@/components/diff/diff-viewer" import { UnifiedDiffPreview } from "@/components/diff/unified-diff-preview" @@ -14,9 +15,178 @@ import { ContextMenuItem, ContextMenuTrigger, } from "@/components/ui/context-menu" +import { cjk } from "@streamdown/cjk" +import { code } from "@streamdown/code" +import { math } from "@streamdown/math" +import { mermaid } from "@streamdown/mermaid" +import { Streamdown } from "streamdown" +import { readFileBase64 } from "@/lib/tauri" import { defineMonacoThemes, useMonacoThemeSync } from "@/lib/monaco-themes" import "@/lib/monaco-local" +const previewPlugins = { cjk, code, math, mermaid } + +function resolveRelativePath(base: string, relative: string): string { + // Strip URL fragment (e.g. #gh-light-mode-only) and query string + const cleaned = relative.replace(/[#?].*$/, "") + // Preserve leading "/" for absolute paths, filter empty segments + const isAbsolute = base.startsWith("/") + const parts = base.split("/").filter(Boolean) + for (const seg of cleaned.split("/")) { + if (seg === "..") { + if (parts.length > 0) parts.pop() + } else if (seg !== "." && seg !== "") { + parts.push(seg) + } + } + return (isAbsolute ? "/" : "") + parts.join("/") +} + +/** + * Pre-resolve relative paths in markdown image/link syntax before Streamdown. + * + * rehype-harden resolves "../foo" via `new URL("../foo", "http://example.com")` + * which loses directory context (e.g. "../images/a.png" from "docs/readme/" + * becomes "/images/a.png" instead of "/docs/images/a.png"). + * + * This function resolves relative paths against the file's directory BEFORE + * Streamdown processes them, using "./" prefix so rehype-harden preserves them. + */ +function preprocessMarkdownPaths( + content: string, + relativeFileDir: string +): string { + const resolveUrl = (url: string): string => { + // Skip absolute URLs, anchors, and already-root-relative paths + if (/^https?:\/\/|^data:|^blob:|^#|^\//.test(url)) return url + // Separate fragment/query from path + const fragIdx = url.search(/[#?]/) + const pathPart = fragIdx >= 0 ? url.slice(0, fragIdx) : url + const fragment = fragIdx >= 0 ? url.slice(fragIdx) : "" + // Resolve relative to file directory within project + const parts = relativeFileDir.split("/").filter(Boolean) + for (const seg of pathPart.split("/")) { + if (seg === "..") { + if (parts.length > 0) parts.pop() + } else if (seg !== "." && seg !== "") { + parts.push(seg) + } + } + // "./" prefix ensures rehype-harden recognizes it as relative + return "./" + parts.join("/") + fragment + } + + // Pre-resolve image paths: ![alt](url) or ![alt](url "title") + let result = content.replace( + /!\[([^\]]*)\]\(([^)\s"']+)([^)]*)\)/g, + (match, alt, url, rest) => { + const resolved = resolveUrl(url) + if (resolved === url) return match + return `![${alt}](${resolved}${rest})` + } + ) + + // Pre-resolve image-wrapped link paths: [![alt](img)](url) + result = result.replace( + /\[(!\[[^\]]*\]\([^)]*\))\]\(([^)\s"']+)([^)]*)\)/g, + (match, imgPart, url, rest) => { + const resolved = resolveUrl(url) + if (resolved === url) return match + return `[${imgPart}](${resolved}${rest})` + } + ) + + // Pre-resolve link paths: [text](url) — negative lookbehind to skip images + result = result.replace( + /(? { + const resolved = resolveUrl(url) + if (resolved === url) return match + return `[${text}](${resolved}${rest})` + } + ) + + // Pre-resolve HTML and tags + result = result.replace( + /<(a\s[^>]*?href|img\s[^>]*?src)=(["'])([^"']+)\2/gi, + (match, prefix, quote, url) => { + const resolved = resolveUrl(url) + if (resolved === url) return match + return `<${prefix}=${quote}${resolved}${quote}` + } + ) + + return result +} + +const MIME_BY_EXT: Record = { + png: "image/png", + jpg: "image/jpeg", + jpeg: "image/jpeg", + gif: "image/gif", + svg: "image/svg+xml", + webp: "image/webp", + bmp: "image/bmp", + ico: "image/x-icon", +} + +function useLocalImageSrc( + src: string | undefined, + fileDir: string | null, + folderPath: string | null +): string | undefined { + const [dataUrl, setDataUrl] = useState(undefined) + + const isLocal = src && fileDir && !/^https?:\/\/|^data:|^blob:/.test(src) + + useEffect(() => { + if (!isLocal || !src || !fileDir) return + let cancelled = false + // rehype-harden resolves "../foo" to "/foo" via new URL(src, "http://example.com") + // Root-relative paths (starting with "/") should resolve against folderPath + const absPath = + src.startsWith("/") && folderPath + ? resolveRelativePath(folderPath, src) + : resolveRelativePath(fileDir, src) + const ext = absPath.split(".").pop()?.toLowerCase() ?? "" + const mime = MIME_BY_EXT[ext] ?? "image/png" + + readFileBase64(absPath) + .then((b64) => { + if (!cancelled) { + setDataUrl(`data:${mime};base64,${b64}`) + } + }) + .catch((err) => { + console.error( + `[PreviewImage] readFileBase64 failed for "${absPath}":`, + typeof err === "object" ? JSON.stringify(err) : err + ) + }) + return () => { + cancelled = true + } + }, [isLocal, src, fileDir, folderPath]) + + if (!isLocal) return src + return dataUrl +} + +function PreviewImage({ + fileDir, + folderPath, + ...props +}: React.ComponentProps<"img"> & { + fileDir: string | null + folderPath: string | null +}) { + const src = typeof props.src === "string" ? props.src : undefined + const resolvedSrc = useLocalImageSrc(src, fileDir, folderPath) + + // eslint-disable-next-line @next/next/no-img-element, jsx-a11y/alt-text + return +} + const AUTO_SAVE_DELAY_MS = 5000 function buildMonacoModelPath(path: string | null, id: string): string { @@ -585,9 +755,12 @@ export function FileWorkspacePanel() { openCommitDiff, openFilePreview, openWorkingTreeDiff, + previewFileTabIds, saveActiveFile, updateActiveFileContent, } = useWorkspaceContext() + const { folder } = useFolderContext() + const folderPath = folder?.path ?? null const activeScope = activeFileTab?.id ?? "__default__" const editorRef = useRef(null) const cursorListenerRef = useRef<{ dispose: () => void } | null>(null) @@ -1104,6 +1277,13 @@ export function FileWorkspacePanel() { ) } + // Preview mode for markdown files + const isPreviewMode = + isFileTab && + activeFileTab && + previewFileTabIds.has(activeFileTab.id) && + activeFileTab.language === "markdown" + // Diff overview list view (commit / directory) if (diffListContext && diffOutline) { const badge = @@ -1157,6 +1337,86 @@ export function FileWorkspacePanel() { ) } + if (isPreviewMode && activeFileTab) { + const absFilePath = + activeFileTab.path && folderPath + ? `${folderPath}/${activeFileTab.path}` + : null + const fileDir = absFilePath + ? absFilePath.replace(/\/[^/]*$/, "") + : folderPath + // Pre-resolve relative paths before Streamdown/rehype-harden mangles them + const relativeFileDir = activeFileTab.path?.includes("/") + ? activeFileTab.path.replace(/\/[^/]*$/, "") + : "" + const preprocessedContent = preprocessMarkdownPaths( + renderedContent, + relativeFileDir + ) + + return ( +
+ {activeFileTab.loading && ( +
+ {t("loading")} +
+ )} +
+ ( + + ), + // eslint-disable-next-line @typescript-eslint/no-unused-vars + a: ({ node, href, children, ...aProps }) => { + const isRelative = + href && !/^https?:\/\/|^mailto:|^#/.test(href) + if (isRelative && href) { + return ( + { + e.preventDefault() + // After preprocessing + rehype-harden, paths are + // root-relative like "/docs/images/foo.png" + const clean = href.replace(/[#?].*$/, "") + const target = clean + .replace(/^\/+/, "") + .replace(/\/\/+/g, "/") + openFilePreview(target) + }} + > + {children} + + ) + } + return ( + + {children} + + ) + }, + }} + > + {preprocessedContent} + +
+
+ ) + } + return (
{activeFileTab.loading && ( diff --git a/src/components/files/file-workspace-tab-bar.tsx b/src/components/files/file-workspace-tab-bar.tsx index 686f599..b6070df 100644 --- a/src/components/files/file-workspace-tab-bar.tsx +++ b/src/components/files/file-workspace-tab-bar.tsx @@ -2,8 +2,10 @@ import { useCallback, useEffect, useRef, useState } from "react" import { Reorder } from "motion/react" -import { FileText, GitCompare, X } from "lucide-react" +import { Code, Eye, ExternalLink, FileText, GitCompare, X } from "lucide-react" import { useTranslations } from "next-intl" +import { openPath } from "@tauri-apps/plugin-opener" +import { useFolderContext } from "@/contexts/folder-context" import { useWorkspaceContext } from "@/contexts/workspace-context" import { useShortcutSettings } from "@/hooks/use-shortcut-settings" import { matchShortcutEvent } from "@/lib/keyboard-shortcuts" @@ -28,7 +30,10 @@ export function FileWorkspaceTabBar() { closeOtherFileTabs, closeAllFileTabs, reorderFileTabs, + previewFileTabIds, + toggleFileTabPreview, } = useWorkspaceContext() + const { folder } = useFolderContext() const { shortcuts } = useShortcutSettings() const scrollRef = useRef(null) const [isHovered, setIsHovered] = useState(false) @@ -79,6 +84,16 @@ export function FileWorkspaceTabBar() { shortcuts.close_current_tab, ]) + const activeTab = fileTabs.find((tab) => tab.id === activeFileTabId) + const canPreview = + activeTab?.kind === "file" && activeTab.language === "markdown" + const canOpenInBrowser = + activeTab?.kind === "file" && activeTab.language === "html" + const isPreviewActive = + canPreview && activeFileTabId + ? previewFileTabIds.has(activeFileTabId) + : false + if (fileTabs.length === 0) { return (
@@ -88,101 +103,132 @@ export function FileWorkspaceTabBar() { } return ( - setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} - className={cn( - "h-10 pt-1.5 px-1.5 flex items-stretch gap-1.5 border-b border-border", - "overflow-x-scroll", - isHovered - ? [ - "pb-0.5", - "[&::-webkit-scrollbar]:h-1", - "[&::-webkit-scrollbar-track]:bg-transparent", - "[&::-webkit-scrollbar-thumb]:rounded-full", - "[&::-webkit-scrollbar-thumb]:bg-border", - ] - : ["pb-1.5", "[&::-webkit-scrollbar]:h-0"] - )} - > - {fileTabs.map((tab) => { - const active = tab.id === activeFileTabId - const isDiff = tab.kind === "diff" || tab.kind === "rich-diff" - const isDirty = tab.kind === "file" && Boolean(tab.isDirty) +
+ setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + className={cn( + "h-10 pt-1.5 px-1.5 flex-1 min-w-0 flex items-stretch gap-1.5", + "overflow-x-scroll", + isHovered + ? [ + "pb-0.5", + "[&::-webkit-scrollbar]:h-1", + "[&::-webkit-scrollbar-track]:bg-transparent", + "[&::-webkit-scrollbar-thumb]:rounded-full", + "[&::-webkit-scrollbar-thumb]:bg-border", + ] + : ["pb-1.5", "[&::-webkit-scrollbar]:h-0"] + )} + > + {fileTabs.map((tab) => { + const active = tab.id === activeFileTabId + const isDiff = tab.kind === "diff" || tab.kind === "rich-diff" + const isDirty = tab.kind === "file" && Boolean(tab.isDirty) - return ( - - - -
switchFileTab(tab.id)} - className={cn( - "group/filetab relative flex items-center h-full gap-1.5 px-3 text-xs rounded-full", - "cursor-pointer select-none shrink-0 hover:bg-primary/8 transition-colors", - active - ? "bg-primary/10 text-foreground" - : "text-muted-foreground" - )} - title={tab.description ?? tab.title} - > - {isDiff ? ( - - ) : ( - - )} - - {tab.title} - {isDirty ? " *" : ""} - - -
-
- - closeFileTab(tab.id)}> - {t("close")} - - closeOtherFileTabs(tab.id)}> - {t("closeOthers")} - - - - {t("closeAll")} - - -
-
- ) - })} -
+ {isDiff ? ( + + ) : ( + + )} + + {tab.title} + {isDirty ? " *" : ""} + + +
+ + + closeFileTab(tab.id)}> + {t("close")} + + closeOtherFileTabs(tab.id)}> + {t("closeOthers")} + + + + {t("closeAll")} + + + + + ) + })} +
+ {canPreview && activeFileTabId && ( + + )} + {canOpenInBrowser && activeTab?.path && folder?.path && ( + + )} +
) } diff --git a/src/contexts/workspace-context.tsx b/src/contexts/workspace-context.tsx index d52b207..32f4da2 100644 --- a/src/contexts/workspace-context.tsx +++ b/src/contexts/workspace-context.tsx @@ -110,6 +110,8 @@ interface WorkspaceContextValue { updateActiveFileContent: (content: string) => void saveActiveFile: (options?: { force?: boolean }) => Promise reloadActiveFile: () => Promise + previewFileTabIds: Set + toggleFileTabPreview: (tabId: string) => void } const WorkspaceContext = createContext(null) @@ -197,6 +199,9 @@ export function WorkspaceProvider({ children }: WorkspaceProviderProps) { path: string line: number } | null>(null) + const [previewFileTabIds, setPreviewFileTabIds] = useState>( + new Set() + ) const fileTabsRef = useRef([]) const fileRevealRequestIdRef = useRef(0) @@ -933,6 +938,13 @@ export function WorkspaceProvider({ children }: WorkspaceProviderProps) { return next[nextIdx].id }) + setPreviewFileTabIds((prev) => { + if (!prev.has(tabId)) return prev + const updated = new Set(prev) + updated.delete(tabId) + return updated + }) + return next }) }, @@ -967,6 +979,7 @@ export function WorkspaceProvider({ children }: WorkspaceProviderProps) { } setActiveFileTabId(null) + setPreviewFileTabIds(new Set()) activateConversationPane() return [] }) @@ -983,6 +996,18 @@ export function WorkspaceProvider({ children }: WorkspaceProviderProps) { const activeFilePath = activeFileTab?.path ?? null + const toggleFileTabPreview = useCallback((tabId: string) => { + setPreviewFileTabIds((prev) => { + const next = new Set(prev) + if (next.has(tabId)) { + next.delete(tabId) + } else { + next.add(tabId) + } + return next + }) + }, []) + const value = useMemo( () => ({ mode, @@ -1011,6 +1036,8 @@ export function WorkspaceProvider({ children }: WorkspaceProviderProps) { updateActiveFileContent, saveActiveFile, reloadActiveFile, + previewFileTabIds, + toggleFileTabPreview, }), [ mode, @@ -1039,6 +1066,8 @@ export function WorkspaceProvider({ children }: WorkspaceProviderProps) { updateActiveFileContent, saveActiveFile, reloadActiveFile, + previewFileTabIds, + toggleFileTabPreview, ] ) diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 02bfc56..887bf1c 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -730,7 +730,9 @@ "closeFileTab": "إغلاق تبويب الملف", "close": "إغلاق", "closeOthers": "إغلاق البقية", - "closeAll": "إغلاق الكل" + "closeAll": "إغلاق الكل", + "preview": "معاينة", + "editSource": "تحرير المصدر" }, "terminal": { "rename": "إعادة تسمية", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index d23f2cd..d8b3547 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -730,7 +730,9 @@ "closeFileTab": "Dateitab schließen", "close": "Schließen", "closeOthers": "Andere schließen", - "closeAll": "Alle schließen" + "closeAll": "Alle schließen", + "preview": "Vorschau", + "editSource": "Quelle bearbeiten" }, "terminal": { "rename": "Umbenennen", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 42e821e..59f2f2a 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -730,7 +730,9 @@ "closeFileTab": "Close file tab", "close": "Close", "closeOthers": "Close Others", - "closeAll": "Close All" + "closeAll": "Close All", + "preview": "Preview", + "editSource": "Edit Source" }, "terminal": { "rename": "Rename", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index cca383e..02631af 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -730,7 +730,9 @@ "closeFileTab": "Cerrar pestaña de archivo", "close": "Cerrar", "closeOthers": "Cerrar otros", - "closeAll": "Cerrar todo" + "closeAll": "Cerrar todo", + "preview": "Vista previa", + "editSource": "Editar fuente" }, "terminal": { "rename": "Renombrar", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 0ab828e..6cdf8ef 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -730,7 +730,9 @@ "closeFileTab": "Fermer l’onglet fichier", "close": "Fermer", "closeOthers": "Fermer les autres", - "closeAll": "Tout fermer" + "closeAll": "Tout fermer", + "preview": "Aperçu", + "editSource": "Modifier la source" }, "terminal": { "rename": "Renommer", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index 8bce0d7..ae8c71c 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -730,7 +730,9 @@ "closeFileTab": "ファイルタブを閉じる", "close": "閉じる", "closeOthers": "他を閉じる", - "closeAll": "すべて閉じる" + "closeAll": "すべて閉じる", + "preview": "プレビュー", + "editSource": "ソースを編集" }, "terminal": { "rename": "名前を変更", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index a4577a3..6c58dd9 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -730,7 +730,9 @@ "closeFileTab": "파일 탭 닫기", "close": "닫기", "closeOthers": "다른 항목 닫기", - "closeAll": "모두 닫기" + "closeAll": "모두 닫기", + "preview": "미리보기", + "editSource": "소스 편집" }, "terminal": { "rename": "이름 변경", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 5e5d51d..6dbed2e 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -730,7 +730,9 @@ "closeFileTab": "Fechar aba de arquivo", "close": "Fechar", "closeOthers": "Fechar outros", - "closeAll": "Fechar tudo" + "closeAll": "Fechar tudo", + "preview": "Visualizar", + "editSource": "Editar fonte" }, "terminal": { "rename": "Renomear", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index cf4a7a2..312778a 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -730,7 +730,9 @@ "closeFileTab": "关闭文件标签", "close": "关闭", "closeOthers": "关闭其它", - "closeAll": "关闭所有" + "closeAll": "关闭所有", + "preview": "预览", + "editSource": "编辑源码" }, "terminal": { "rename": "重命名", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 55ec5a5..515dec1 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -730,7 +730,9 @@ "closeFileTab": "關閉檔案分頁", "close": "關閉", "closeOthers": "關閉其它", - "closeAll": "關閉所有" + "closeAll": "關閉所有", + "preview": "預覽", + "editSource": "編輯原始碼" }, "terminal": { "rename": "重新命名", diff --git a/src/lib/language-detect.ts b/src/lib/language-detect.ts index c017f79..2ed38d0 100644 --- a/src/lib/language-detect.ts +++ b/src/lib/language-detect.ts @@ -31,6 +31,7 @@ export function languageFromPath(path: string): string { case "css": return "css" case "html": + case "htm": return "html" case "sh": return "shell"