Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions apps/desktop/src/lib/trpc/routers/changes/file-contents.ts
Original file line number Diff line number Diff line change
@@ -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 "../..";
Expand All @@ -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;

Expand All @@ -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
*/
Expand Down Expand Up @@ -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<ReadWorkingFileImageResult> => {
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" };
}
}),
});
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 =
Expand All @@ -274,10 +277,11 @@ export function FileViewerPane({
<div className="flex h-full w-full">
<FileViewerToolbar
fileName={fileName}
filePath={filePath}
isDirty={isDirty}
viewMode={viewMode}
isPinned={isPinned}
isMarkdown={isMarkdown}
hasRenderedMode={hasRenderedMode}
hasDiff={hasDiff}
splitOrientation={handlers.splitOrientation}
diffViewMode={diffViewMode}
Expand All @@ -296,8 +300,10 @@ export function FileViewerPane({
viewMode={viewMode}
filePath={filePath}
isLoadingRaw={isLoadingRaw}
isLoadingImage={isLoadingImage}
isLoadingDiff={isLoadingDiff}
rawFileData={rawFileData}
imageData={imageData}
diffData={diffData}
isDiffEditable={isDiffEditable}
editorRef={editorRef}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
import type { Tab } from "renderer/stores/tabs/types";
import type { DiffViewMode } from "shared/changes-types";
import { detectLanguage } from "shared/detect-language";
import { isImageFile } from "shared/file-types";
import type { FileViewerMode } from "shared/tabs-types";
import { DiffViewer } from "../../../../../../ChangesContent/components/DiffViewer";
import { registerCopyPathLineAction } from "../../../../../components/EditorContextMenu";
Expand All @@ -34,6 +35,24 @@ interface RawFileError {

type RawFileResult = RawFileData | RawFileError | undefined;

interface ImageData {
ok: true;
dataUrl: string;
byteLength: number;
}

interface ImageError {
ok: false;
reason:
| "too-large"
| "not-image"
| "outside-worktree"
| "symlink-escape"
| "not-found";
}

type ImageResult = ImageData | ImageError | undefined;

interface DiffData {
original: string;
modified: string;
Expand All @@ -44,8 +63,10 @@ interface FileViewerContentProps {
viewMode: FileViewerMode;
filePath: string;
isLoadingRaw: boolean;
isLoadingImage?: boolean;
isLoadingDiff: boolean;
rawFileData: RawFileResult;
imageData?: ImageResult;
diffData: DiffData | undefined;
isDiffEditable: boolean;
editorRef: MutableRefObject<Monaco.editor.IStandaloneCodeEditor | null>;
Expand Down Expand Up @@ -74,8 +95,10 @@ export function FileViewerContent({
viewMode,
filePath,
isLoadingRaw,
isLoadingImage,
isLoadingDiff,
rawFileData,
imageData,
diffData,
isDiffEditable,
editorRef,
Expand All @@ -99,6 +122,7 @@ export function FileViewerContent({
onMoveToTab,
onMoveToNewTab,
}: FileViewerContentProps) {
const isImage = isImageFile(filePath);
const isMonacoReady = useMonacoReady();
const hasAppliedInitialLocationRef = useRef(false);

Expand Down Expand Up @@ -210,6 +234,47 @@ export function FileViewerContent({
);
}

// Handle image files in rendered mode
if (viewMode === "rendered" && isImage) {
if (isLoadingImage) {
return (
<div className="flex items-center justify-center h-full text-muted-foreground">
<LuLoader className="w-4 h-4 animate-spin mr-2" />
<span>Loading image...</span>
</div>
);
}

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 (
<div className="flex items-center justify-center h-full text-muted-foreground">
{errorMessage}
</div>
);
}

return (
<div className="flex items-center justify-center h-full overflow-auto p-4 bg-[#0d0d0d]">
<img
src={imageData.dataUrl}
alt={filePath.split("/").pop() || "Image"}
className="max-w-full max-h-full object-contain"
style={{ imageRendering: "auto" }}
/>
</div>
);
}

if (isLoadingRaw) {
return (
<div className="flex items-center justify-center h-full text-muted-foreground">
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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;
Expand All @@ -34,10 +38,11 @@ interface FileViewerToolbarProps {

export function FileViewerToolbar({
fileName,
filePath,
isDirty,
viewMode,
isPinned,
isMarkdown,
hasRenderedMode,
hasDiff,
splitOrientation,
diffViewMode,
Expand All @@ -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 (
<div className="flex h-full w-full items-center justify-between px-3">
<div className="flex min-w-0 items-center gap-2">
<span
className={cn(
"truncate text-xs text-muted-foreground",
!isPinned && "italic",
)}
>
{isDirty && <span className="text-amber-500 mr-1">●</span>}
{fileName}
</span>
{!isPinned && (
<Tooltip>
<TooltipTrigger asChild>
<span className="text-[10px] text-muted-foreground/50 cursor-default">
preview
</span>
</TooltipTrigger>
<TooltipContent side="bottom" showArrow={false}>
Click again or double-click to pin
</TooltipContent>
</Tooltip>
)}
<Tooltip open={copied ? true : undefined}>
<TooltipTrigger asChild>
<button
type="button"
onClick={handleCopyPath}
className={cn(
"truncate text-xs text-muted-foreground hover:text-foreground transition-colors text-left",
!isPinned && "italic",
)}
>
{isDirty && <span className="text-amber-500 mr-1">●</span>}
{fileName}
</button>
</TooltipTrigger>
<TooltipContent side="bottom" showArrow={false}>
{copied ? "Copied!" : "Click to copy path"}
</TooltipContent>
</Tooltip>
</div>
<div className="flex items-center gap-1">
<ToggleGroup
Expand All @@ -82,7 +91,7 @@ export function FileViewerToolbar({
size="sm"
className="h-5 bg-muted/50 rounded-md"
>
{isMarkdown && (
{hasRenderedMode && (
<ToggleGroupItem
value="rendered"
className="h-5 px-1.5 text-[10px] text-muted-foreground data-[state=on]:bg-background data-[state=on]:text-foreground data-[state=on]:shadow-sm"
Expand All @@ -101,7 +110,7 @@ export function FileViewerToolbar({
value="diff"
className="h-5 px-1.5 text-[10px] text-muted-foreground data-[state=on]:bg-background data-[state=on]:text-foreground data-[state=on]:shadow-sm"
>
Diff
Changes
</ToggleGroupItem>
)}
</ToggleGroup>
Expand Down
Loading
Loading