diff --git a/apps/desktop/src/lib/trpc/routers/changes/file-contents.ts b/apps/desktop/src/lib/trpc/routers/changes/file-contents.ts index 48b01555bea..ee2183600ca 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/file-contents.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/file-contents.ts @@ -1,5 +1,6 @@ import type { FileContents } from "shared/changes-types"; import { detectLanguage } from "shared/detect-language"; +import { getImageMimeType } from "shared/file-types"; import simpleGit from "simple-git"; import { z } from "zod"; import { publicProcedure, router } from "../.."; @@ -12,6 +13,9 @@ import { /** Maximum file size for reading (2 MiB) */ const MAX_FILE_SIZE = 2 * 1024 * 1024; +/** Maximum image file size (10 MiB) */ +const MAX_IMAGE_SIZE = 10 * 1024 * 1024; + /** Bytes to scan for binary detection */ const BINARY_CHECK_SIZE = 8192; @@ -30,6 +34,21 @@ type ReadWorkingFileResult = | "symlink-escape"; }; +/** + * Result type for readWorkingFileImage procedure + */ +type ReadWorkingFileImageResult = + | { ok: true; dataUrl: string; byteLength: number } + | { + ok: false; + reason: + | "not-found" + | "too-large" + | "not-image" + | "outside-worktree" + | "symlink-escape"; + }; + /** * Detects if a buffer contains binary content by checking for NUL bytes */ @@ -140,6 +159,53 @@ export const createFileContentsRouter = () => { return { ok: false, reason: "not-found" }; } }), + + /** + * Read an image file and return as base64 data URL. + * Used for File Viewer rendered mode for images. + */ + readWorkingFileImage: publicProcedure + .input( + z.object({ + worktreePath: z.string(), + filePath: z.string(), + }), + ) + .query(async ({ input }): Promise => { + const mimeType = getImageMimeType(input.filePath); + if (!mimeType) { + return { ok: false, reason: "not-image" }; + } + + try { + const stats = await secureFs.stat(input.worktreePath, input.filePath); + if (stats.size > MAX_IMAGE_SIZE) { + return { ok: false, reason: "too-large" }; + } + + const buffer = await secureFs.readFileBuffer( + input.worktreePath, + input.filePath, + ); + + const base64 = buffer.toString("base64"); + const dataUrl = `data:${mimeType};base64,${base64}`; + + return { + ok: true, + dataUrl, + byteLength: buffer.length, + }; + } catch (error) { + if (error instanceof PathValidationError) { + if (error.code === "SYMLINK_ESCAPE") { + return { ok: false, reason: "symlink-escape" }; + } + return { ok: false, reason: "outside-worktree" }; + } + return { ok: false, reason: "not-found" }; + } + }), }); }; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/FileViewerPane.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/FileViewerPane.tsx index 3c2e5cea3cc..12c79df0e11 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/FileViewerPane.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/FileViewerPane.tsx @@ -4,6 +4,7 @@ import type { MosaicBranch } from "react-mosaic-component"; import { useChangesStore } from "renderer/stores/changes"; import { useTabsStore } from "renderer/stores/tabs/store"; import type { Tab } from "renderer/stores/tabs/types"; +import { isImageFile, isMarkdownFile } from "shared/file-types"; import type { FileViewerMode } from "shared/tabs-types"; import { BasePaneWindow } from "../components"; import { FileViewerContent } from "./components/FileViewerContent"; @@ -97,19 +98,24 @@ export function FileViewerPane({ setIsDirty, }); - const { rawFileData, isLoadingRaw, diffData, isLoadingDiff } = useFileContent( - { - worktreePath, - filePath, - viewMode, - diffCategory, - commitHash, - oldPath, - isDirty, - originalContentRef, - originalDiffContentRef, - }, - ); + const { + rawFileData, + isLoadingRaw, + imageData, + isLoadingImage, + diffData, + isLoadingDiff, + } = useFileContent({ + worktreePath, + filePath, + viewMode, + diffCategory, + commitHash, + oldPath, + isDirty, + originalContentRef, + originalDiffContentRef, + }); const handleEditorChange = useCallback((value: string | undefined) => { if (value === undefined) return; @@ -250,10 +256,7 @@ export function FileViewerPane({ }; const fileName = filePath.split("/").pop() || filePath; - const isMarkdown = - filePath.endsWith(".md") || - filePath.endsWith(".markdown") || - filePath.endsWith(".mdx"); + const hasRenderedMode = isMarkdownFile(filePath) || isImageFile(filePath); const hasDiff = !!diffCategory; const hasDraft = draftContentRef.current !== null; const isDiffEditable = @@ -274,10 +277,11 @@ export function FileViewerPane({
; @@ -74,8 +95,10 @@ export function FileViewerContent({ viewMode, filePath, isLoadingRaw, + isLoadingImage, isLoadingDiff, rawFileData, + imageData, diffData, isDiffEditable, editorRef, @@ -99,6 +122,7 @@ export function FileViewerContent({ onMoveToTab, onMoveToNewTab, }: FileViewerContentProps) { + const isImage = isImageFile(filePath); const isMonacoReady = useMonacoReady(); const hasAppliedInitialLocationRef = useRef(false); @@ -210,6 +234,47 @@ export function FileViewerContent({ ); } + // Handle image files in rendered mode + if (viewMode === "rendered" && isImage) { + if (isLoadingImage) { + return ( +
+ + Loading image... +
+ ); + } + + if (!imageData?.ok) { + const errorMessage = + imageData?.reason === "too-large" + ? "Image is too large to preview (max 10MB)" + : imageData?.reason === "outside-worktree" + ? "File is outside worktree" + : imageData?.reason === "symlink-escape" + ? "File is a symlink pointing outside worktree" + : imageData?.reason === "not-image" + ? "Not a supported image format" + : "Image not found"; + return ( +
+ {errorMessage} +
+ ); + } + + return ( +
+ {filePath.split("/").pop() +
+ ); + } + if (isLoadingRaw) { return (
diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/FileViewerToolbar/FileViewerToolbar.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/FileViewerToolbar/FileViewerToolbar.tsx index d62851994d7..50eb9f2381f 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/FileViewerToolbar/FileViewerToolbar.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/components/FileViewerToolbar/FileViewerToolbar.tsx @@ -1,6 +1,7 @@ import { ToggleGroup, ToggleGroupItem } from "@superset/ui/toggle-group"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { cn } from "@superset/ui/utils"; +import { useState } from "react"; import { TbFold, TbLayoutSidebarRightFilled, @@ -14,11 +15,14 @@ import type { SplitOrientation } from "../../../hooks"; interface FileViewerToolbarProps { fileName: string; + filePath: string; isDirty: boolean; viewMode: FileViewerMode; /** If false, this is a preview pane (italic name, can be replaced) */ isPinned: boolean; - isMarkdown: boolean; + /** Show Rendered tab (for markdown/images) */ + hasRenderedMode: boolean; + /** Show Changes tab (when file has diff) */ hasDiff: boolean; splitOrientation: SplitOrientation; diffViewMode: DiffViewMode; @@ -34,10 +38,11 @@ interface FileViewerToolbarProps { export function FileViewerToolbar({ fileName, + filePath, isDirty, viewMode, isPinned, - isMarkdown, + hasRenderedMode, hasDiff, splitOrientation, diffViewMode, @@ -49,30 +54,34 @@ export function FileViewerToolbar({ onPin, onClosePane, }: FileViewerToolbarProps) { + const [copied, setCopied] = useState(false); + + const handleCopyPath = () => { + navigator.clipboard.writeText(filePath); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }; return (
- - {isDirty && } - {fileName} - - {!isPinned && ( - - - - preview - - - - Click again or double-click to pin - - - )} + + + + + + {copied ? "Copied!" : "Click to copy path"} + +
- {isMarkdown && ( + {hasRenderedMode && ( - Diff + Changes )} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/hooks/useFileContent/useFileContent.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/hooks/useFileContent/useFileContent.ts index 857496f4a90..1432885110c 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/hooks/useFileContent/useFileContent.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/hooks/useFileContent/useFileContent.ts @@ -1,6 +1,7 @@ import { useEffect } from "react"; import { electronTrpc } from "renderer/lib/electron-trpc"; import type { ChangeCategory } from "shared/changes-types"; +import { isImageFile } from "shared/file-types"; interface UseFileContentParams { worktreePath: string; @@ -31,11 +32,23 @@ export function useFileContent({ ); const effectiveBaseBranch = branchData?.defaultBranch ?? "main"; + const isImage = isImageFile(filePath); + const { data: rawFileData, isLoading: isLoadingRaw } = electronTrpc.changes.readWorkingFile.useQuery( { worktreePath, filePath }, { - enabled: viewMode !== "diff" && !!filePath && !!worktreePath, + enabled: + viewMode !== "diff" && !isImage && !!filePath && !!worktreePath, + }, + ); + + const { data: imageData, isLoading: isLoadingImage } = + electronTrpc.changes.readWorkingFileImage.useQuery( + { worktreePath, filePath }, + { + enabled: + viewMode === "rendered" && isImage && !!filePath && !!worktreePath, }, ); @@ -72,7 +85,9 @@ export function useFileContent({ return { rawFileData, - isLoadingRaw, + isLoadingRaw: isLoadingRaw || (isImage && isLoadingImage), + imageData, + isLoadingImage, diffData, isLoadingDiff, }; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/FilesView.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/FilesView.tsx index ddaa4561ba7..6099649f6e5 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/FilesView.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/FilesView.tsx @@ -153,7 +153,6 @@ export function FilesView() { if (!workspaceId || !worktreePath || entry.isDirectory) return; addFileViewerPane(workspaceId, { filePath: entry.relativePath, - viewMode: "raw", }); }, [workspaceId, worktreePath, addFileViewerPane], diff --git a/apps/desktop/src/renderer/stores/tabs/utils.ts b/apps/desktop/src/renderer/stores/tabs/utils.ts index 9eff58acf51..d638edb5aac 100644 --- a/apps/desktop/src/renderer/stores/tabs/utils.ts +++ b/apps/desktop/src/renderer/stores/tabs/utils.ts @@ -1,5 +1,6 @@ import type { MosaicBranch, MosaicNode } from "react-mosaic-component"; import type { ChangeCategory } from "shared/changes-types"; +import { hasRenderedPreview, isImageFile } from "shared/file-types"; import type { DiffLayout, FileViewerMode, @@ -7,8 +8,6 @@ import type { } from "shared/tabs-types"; import type { Pane, PaneType, Tab } from "./types"; -const MARKDOWN_EXTENSIONS = [".md", ".markdown", ".mdx"] as const; - export const resolveFileViewerMode = ({ filePath, diffCategory, @@ -19,10 +18,10 @@ export const resolveFileViewerMode = ({ viewMode?: FileViewerMode; }): FileViewerMode => { if (viewMode) return viewMode; + // Images always default to rendered (no meaningful diff for binary files) + if (isImageFile(filePath)) return "rendered"; if (diffCategory) return "diff"; - if (MARKDOWN_EXTENSIONS.some((ext) => filePath.endsWith(ext))) { - return "rendered"; - } + if (hasRenderedPreview(filePath)) return "rendered"; return "raw"; }; diff --git a/apps/desktop/src/shared/file-types.ts b/apps/desktop/src/shared/file-types.ts new file mode 100644 index 00000000000..cbac5fcc766 --- /dev/null +++ b/apps/desktop/src/shared/file-types.ts @@ -0,0 +1,68 @@ +/** + * Shared file type detection utilities. + * Used by both main and renderer processes. + */ + +/** Supported image extensions */ +const IMAGE_EXTENSIONS = new Set([ + "png", + "jpg", + "jpeg", + "gif", + "webp", + "svg", + "bmp", + "ico", +]); + +/** MIME types for supported image extensions */ +const IMAGE_MIME_TYPES: Record = { + png: "image/png", + jpg: "image/jpeg", + jpeg: "image/jpeg", + gif: "image/gif", + webp: "image/webp", + svg: "image/svg+xml", + bmp: "image/bmp", + ico: "image/x-icon", +}; + +/** Markdown extensions */ +const MARKDOWN_EXTENSIONS = new Set(["md", "markdown", "mdx"]); + +/** + * Gets the file extension from a path (lowercase, without dot) + */ +function getExtension(filePath: string): string { + return filePath.split(".").pop()?.toLowerCase() ?? ""; +} + +/** + * Checks if a file is an image based on extension + */ +export function isImageFile(filePath: string): boolean { + return IMAGE_EXTENSIONS.has(getExtension(filePath)); +} + +/** + * Gets the MIME type for an image file + * Returns null if not a supported image type + */ +export function getImageMimeType(filePath: string): string | null { + const ext = getExtension(filePath); + return IMAGE_MIME_TYPES[ext] ?? null; +} + +/** + * Checks if a file is markdown based on extension + */ +export function isMarkdownFile(filePath: string): boolean { + return MARKDOWN_EXTENSIONS.has(getExtension(filePath)); +} + +/** + * Checks if a file supports rendered preview (markdown or image) + */ +export function hasRenderedPreview(filePath: string): boolean { + return isMarkdownFile(filePath) || isImageFile(filePath); +}