diff --git a/src/features/files/components/FilePreviewPopover.tsx b/src/features/files/components/FilePreviewPopover.tsx index acc347f7f..2a8efd2d9 100644 --- a/src/features/files/components/FilePreviewPopover.tsx +++ b/src/features/files/components/FilePreviewPopover.tsx @@ -9,6 +9,8 @@ type FilePreviewPopoverProps = { absolutePath: string; content: string; truncated: boolean; + previewKind?: "text" | "image"; + imageSrc?: string | null; selection: { start: number; end: number } | null; onSelectLine: (index: number, event: MouseEvent) => void; onClearSelection: () => void; @@ -24,6 +26,8 @@ export function FilePreviewPopover({ absolutePath, content, truncated, + previewKind = "text", + imageSrc = null, selection, onSelectLine, onClearSelection, @@ -33,18 +37,26 @@ export function FilePreviewPopover({ isLoading = false, error = null, }: FilePreviewPopoverProps) { - const lines = useMemo(() => content.split("\n"), [content]); + const isImagePreview = previewKind === "image"; + const lines = useMemo( + () => (isImagePreview ? [] : content.split("\n")), + [content, isImagePreview], + ); const language = useMemo(() => languageFromPath(path), [path]); const selectionLabel = selection ? `Lines ${selection.start + 1}-${selection.end + 1}` - : "No selection"; + : isImagePreview + ? "Image preview" + : "No selection"; const highlightedLines = useMemo( () => - lines.map((line) => { - const html = highlightLine(line, language); - return html || " "; - }), - [lines, language], + isImagePreview + ? [] + : lines.map((line) => { + const html = highlightLine(line, language); + return html || " "; + }), + [lines, language, isImagePreview], ); return ( @@ -70,6 +82,24 @@ export function FilePreviewPopover({
Loading file...
) : error ? (
{error}
+ ) : isImagePreview ? ( +
+
+ {selectionLabel} +
+ +
+
+ {imageSrc ? ( +
+ {path} +
+ ) : ( +
+ Image preview unavailable. +
+ )} +
) : (
diff --git a/src/features/files/components/FileTreePanel.tsx b/src/features/files/components/FileTreePanel.tsx index 9d5ae2bd1..059ce4aaf 100644 --- a/src/features/files/components/FileTreePanel.tsx +++ b/src/features/files/components/FileTreePanel.tsx @@ -8,6 +8,7 @@ import { } from "react"; import type { MouseEvent } from "react"; import { createPortal } from "react-dom"; +import { convertFileSrc } from "@tauri-apps/api/core"; import { Menu, MenuItem } from "@tauri-apps/api/menu"; import { LogicalPosition } from "@tauri-apps/api/dpi"; import { getCurrentWindow } from "@tauri-apps/api/window"; @@ -157,7 +158,12 @@ function getFileIcon(name: string) { case "gif": case "svg": case "webp": + case "avif": + case "bmp": case "heic": + case "heif": + case "tif": + case "tiff": return FileImage; case "mp4": case "mov": @@ -186,6 +192,26 @@ function getFileIcon(name: string) { } } +const imageExtensions = new Set([ + "png", + "jpg", + "jpeg", + "gif", + "svg", + "webp", + "avif", + "bmp", + "heic", + "heif", + "tif", + "tiff", +]); + +function isImagePath(path: string) { + const ext = path.split(".").pop()?.toLowerCase() ?? ""; + return imageExtensions.has(ext); +} + export function FileTreePanel({ workspaceId, workspacePath, @@ -216,6 +242,10 @@ export function FileTreePanel({ const showLoading = isLoading && files.length === 0; const deferredQuery = useDeferredValue(query); const normalizedQuery = deferredQuery.trim().toLowerCase(); + const previewKind = useMemo( + () => (previewPath && isImagePath(previewPath) ? "image" : "text"), + [previewPath], + ); const filteredFiles = useMemo(() => { if (!normalizedQuery) { @@ -328,6 +358,17 @@ export function FileTreePanel({ [workspacePath], ); + const previewImageSrc = useMemo(() => { + if (!previewPath || previewKind !== "image") { + return null; + } + try { + return convertFileSrc(resolvePath(previewPath)); + } catch { + return null; + } + }, [previewPath, previewKind, resolvePath]); + const openPreview = useCallback((path: string, target: HTMLElement) => { const rect = target.getBoundingClientRect(); const estimatedWidth = 640; @@ -356,6 +397,15 @@ export function FileTreePanel({ return; } let cancelled = false; + if (previewKind === "image") { + setPreviewContent(""); + setPreviewTruncated(false); + setPreviewError(null); + setPreviewLoading(false); + return () => { + cancelled = true; + }; + } setPreviewLoading(true); setPreviewError(null); readWorkspaceFile(workspaceId, previewPath) @@ -380,7 +430,7 @@ export function FileTreePanel({ return () => { cancelled = true; }; - }, [previewPath, workspaceId]); + }, [previewKind, previewPath, workspaceId]); const handleSelectLine = useCallback( (index: number, event: MouseEvent) => { @@ -397,7 +447,7 @@ export function FileTreePanel({ ); const handleAddSelection = useCallback(() => { - if (!previewPath || !previewSelection || !onInsertText) { + if (previewKind !== "text" || !previewPath || !previewSelection || !onInsertText) { return; } const lines = previewContent.split("\n"); @@ -410,7 +460,14 @@ export function FileTreePanel({ const snippet = `${previewPath}:${rangeLabel}\n${fence}\n${selected.join("\n")}\n\`\`\``; onInsertText(snippet); closePreview(); - }, [previewContent, previewPath, previewSelection, onInsertText, closePreview]); + }, [ + previewContent, + previewKind, + previewPath, + previewSelection, + onInsertText, + closePreview, + ]); const showFileMenu = useCallback( async (event: MouseEvent, relativePath: string) => { @@ -557,6 +614,8 @@ export function FileTreePanel({ absolutePath={resolvePath(previewPath)} content={previewContent} truncated={previewTruncated} + previewKind={previewKind} + imageSrc={previewImageSrc} selection={previewSelection} onSelectLine={handleSelectLine} onClearSelection={() => setPreviewSelection(null)} diff --git a/src/styles/file-tree.css b/src/styles/file-tree.css index 6fdd81655..b5c9c3181 100644 --- a/src/styles/file-tree.css +++ b/src/styles/file-tree.css @@ -258,6 +258,29 @@ max-height: 70vh; } +.file-preview-body--image { + gap: 12px; +} + +.file-preview-image { + display: flex; + align-items: center; + justify-content: center; + padding: 12px; + border-radius: 10px; + background: var(--surface-command); + overflow: auto; + max-height: 60vh; +} + +.file-preview-image img { + max-width: 100%; + max-height: 58vh; + object-fit: contain; + border-radius: 8px; + box-shadow: 0 12px 30px rgba(0, 0, 0, 0.25); +} + .file-preview-toolbar { display: flex; align-items: center;