From b039068b721662fd1043ef84c5ac57cf84fe091e Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Sun, 28 Jun 2026 22:12:39 -0500 Subject: [PATCH 01/13] Handle binary and video file changes --- .../routers/changes/utils/apply-numstat.ts | 1 + .../changes/utils/parse-status.test.ts | 72 +++++++++++++++---- .../routers/changes/utils/parse-status.ts | 13 +++- .../changes/workers/git-task-handlers.ts | 27 ++++++- .../components/FilePane/registry/allViews.ts | 2 + .../registry/views/VideoView/VideoView.tsx | 39 ++++++++++ .../registry/views/VideoView/index.ts | 12 ++++ .../fileDocumentStore/fileDocumentStore.ts | 5 +- .../FileDiffSection/FileDiffSection.tsx | 34 +++++++-- .../getEstimatedFileDiffSectionHeight.test.ts | 18 +++++ .../getEstimatedFileDiffSectionHeight.ts | 9 +++ apps/desktop/src/shared/changes-types.ts | 1 + apps/desktop/src/shared/file-types.test.ts | 16 +++++ apps/desktop/src/shared/file-types.ts | 43 +++++++++-- 14 files changed, 263 insertions(+), 29 deletions(-) create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/VideoView/VideoView.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/VideoView/index.ts diff --git a/apps/desktop/src/lib/trpc/routers/changes/utils/apply-numstat.ts b/apps/desktop/src/lib/trpc/routers/changes/utils/apply-numstat.ts index 5c6b6c81334..9894c4a1546 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/utils/apply-numstat.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/utils/apply-numstat.ts @@ -18,6 +18,7 @@ export async function applyNumstatToFiles( if (fileStat) { file.additions = fileStat.additions; file.deletions = fileStat.deletions; + file.isBinary = fileStat.isBinary; } } } catch {} diff --git a/apps/desktop/src/lib/trpc/routers/changes/utils/parse-status.test.ts b/apps/desktop/src/lib/trpc/routers/changes/utils/parse-status.test.ts index e9a481ca4cc..07b3285e20c 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/utils/parse-status.test.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/utils/parse-status.test.ts @@ -98,8 +98,16 @@ describe("parseDiffNumstat", () => { const stats = parseDiffNumstat(numstatOutput); - expect(stats.get("src/file1.ts")).toEqual({ additions: 10, deletions: 5 }); - expect(stats.get("src/file2.ts")).toEqual({ additions: 20, deletions: 3 }); + expect(stats.get("src/file1.ts")).toEqual({ + additions: 10, + deletions: 5, + isBinary: false, + }); + expect(stats.get("src/file2.ts")).toEqual({ + additions: 20, + deletions: 3, + isBinary: false, + }); }); test("handles binary files with dash markers", () => { @@ -108,8 +116,16 @@ describe("parseDiffNumstat", () => { const stats = parseDiffNumstat(numstatOutput); - expect(stats.get("image.png")).toEqual({ additions: 0, deletions: 0 }); - expect(stats.get("src/code.ts")).toEqual({ additions: 10, deletions: 5 }); + expect(stats.get("image.png")).toEqual({ + additions: 0, + deletions: 0, + isBinary: true, + }); + expect(stats.get("src/code.ts")).toEqual({ + additions: 10, + deletions: 5, + isBinary: false, + }); }); test("handles renamed files with arrow format", () => { @@ -118,8 +134,16 @@ describe("parseDiffNumstat", () => { const stats = parseDiffNumstat(numstatOutput); // Should be accessible by both old and new paths - expect(stats.get("new/path.ts")).toEqual({ additions: 5, deletions: 2 }); - expect(stats.get("old/path.ts")).toEqual({ additions: 5, deletions: 2 }); + expect(stats.get("new/path.ts")).toEqual({ + additions: 5, + deletions: 2, + isBinary: false, + }); + expect(stats.get("old/path.ts")).toEqual({ + additions: 5, + deletions: 2, + isBinary: false, + }); }); test("handles copied files with arrow format", () => { @@ -127,8 +151,16 @@ describe("parseDiffNumstat", () => { const stats = parseDiffNumstat(numstatOutput); - expect(stats.get("copy.ts")).toEqual({ additions: 0, deletions: 0 }); - expect(stats.get("source.ts")).toEqual({ additions: 0, deletions: 0 }); + expect(stats.get("copy.ts")).toEqual({ + additions: 0, + deletions: 0, + isBinary: false, + }); + expect(stats.get("source.ts")).toEqual({ + additions: 0, + deletions: 0, + isBinary: false, + }); }); test("handles paths with spaces in rename format", () => { @@ -136,8 +168,16 @@ describe("parseDiffNumstat", () => { const stats = parseDiffNumstat(numstatOutput); - expect(stats.get("new file.ts")).toEqual({ additions: 3, deletions: 1 }); - expect(stats.get("old file.ts")).toEqual({ additions: 3, deletions: 1 }); + expect(stats.get("new file.ts")).toEqual({ + additions: 3, + deletions: 1, + isBinary: false, + }); + expect(stats.get("old file.ts")).toEqual({ + additions: 3, + deletions: 1, + isBinary: false, + }); }); test("returns empty map for empty input", () => { @@ -153,7 +193,11 @@ describe("parseDiffNumstat", () => { const stats = parseDiffNumstat(numstatOutput); expect(stats.size).toBe(1); - expect(stats.get("valid/path.ts")).toEqual({ additions: 20, deletions: 3 }); + expect(stats.get("valid/path.ts")).toEqual({ + additions: 20, + deletions: 3, + isBinary: false, + }); }); test("handles non-numeric additions/deletions gracefully", () => { @@ -161,7 +205,11 @@ describe("parseDiffNumstat", () => { const stats = parseDiffNumstat(numstatOutput); - expect(stats.get("file.ts")).toEqual({ additions: 0, deletions: 0 }); + expect(stats.get("file.ts")).toEqual({ + additions: 0, + deletions: 0, + isBinary: false, + }); }); }); diff --git a/apps/desktop/src/lib/trpc/routers/changes/utils/parse-status.ts b/apps/desktop/src/lib/trpc/routers/changes/utils/parse-status.ts index 598f6676252..cd643188900 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/utils/parse-status.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/utils/parse-status.ts @@ -118,8 +118,11 @@ export function parseGitLog(logOutput: string): CommitInfo[] { export function parseDiffNumstat( numstatOutput: string, -): Map { - const stats = new Map(); +): Map { + const stats = new Map< + string, + { additions: number; deletions: number; isBinary: boolean } + >(); for (const line of numstatOutput.trim().split("\n")) { if (!line.trim()) continue; @@ -132,7 +135,11 @@ export function parseDiffNumstat( const additions = addStr === "-" ? 0 : Number.parseInt(addStr, 10) || 0; const deletions = delStr === "-" ? 0 : Number.parseInt(delStr, 10) || 0; - const statEntry = { additions, deletions }; + const statEntry = { + additions, + deletions, + isBinary: addStr === "-" && delStr === "-", + }; const renameMatch = rawPath.match(/^(.+) => (.+)$/); if (renameMatch) { diff --git a/apps/desktop/src/lib/trpc/routers/changes/workers/git-task-handlers.ts b/apps/desktop/src/lib/trpc/routers/changes/workers/git-task-handlers.ts index 1b8ca618cd8..70cd49e4a4c 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/workers/git-task-handlers.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/workers/git-task-handlers.ts @@ -1,6 +1,7 @@ -import { readFile, realpath, stat } from "node:fs/promises"; +import { open, readFile, realpath, stat } from "node:fs/promises"; import { isAbsolute, relative, resolve, sep } from "node:path"; import type { ChangedFile, GitChangesStatus } from "shared/changes-types"; +import { isVideoFile } from "shared/file-types"; import type { SimpleGit, StatusResult } from "simple-git"; import { getStatusNoLock } from "../../workspaces/utils/git"; import { getSimpleGitWithShellPath } from "../../workspaces/utils/git-client"; @@ -30,6 +31,7 @@ interface TrackingStatus { } const MAX_LINE_COUNT_SIZE = 1 * 1024 * 1024; +const BINARY_SNIFF_BYTES = 8192; const WORKER_DEBUG = process.env.SUPERSET_WORKER_DEBUG === "1"; function logWorkerWarning(message: string, error: unknown): void { @@ -89,7 +91,17 @@ async function applyUntrackedLineCount( if (!isPathWithinWorktree(worktreeReal, fileReal)) continue; const stats = await stat(fileReal); - if (!stats.isFile() || stats.size > MAX_LINE_COUNT_SIZE) continue; + if (!stats.isFile()) continue; + + const sample = await readBinarySniffSample(fileReal); + if (sample.includes(0) || isVideoFile(file.path)) { + file.isBinary = true; + file.additions = 0; + file.deletions = 0; + continue; + } + + if (stats.size > MAX_LINE_COUNT_SIZE) continue; const content = await readFile(fileReal, "utf-8"); const lineCount = @@ -109,6 +121,17 @@ async function applyUntrackedLineCount( } } +async function readBinarySniffSample(filePath: string): Promise { + const fileHandle = await open(filePath, "r"); + try { + const buffer = Buffer.alloc(BINARY_SNIFF_BYTES); + const { bytesRead } = await fileHandle.read(buffer, 0, buffer.length, 0); + return buffer.subarray(0, bytesRead); + } finally { + await fileHandle.close(); + } +} + async function getBranchComparison( git: SimpleGit, defaultBranch: string, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/allViews.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/allViews.ts index ed0e9f4e8df..07404007538 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/allViews.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/allViews.ts @@ -3,11 +3,13 @@ import { binaryWarningView } from "./views/BinaryWarningView"; import { codeView } from "./views/CodeView"; import { imageView } from "./views/ImageView"; import { markdownPreviewView } from "./views/MarkdownPreviewView"; +import { videoView } from "./views/VideoView"; // Order is preserved as a stable tiebreaker for equal-priority views. // Exclusives (image, binary-warning) short-circuit resolution when matched. export const ALL_VIEWS: FileView[] = [ imageView, + videoView, binaryWarningView, markdownPreviewView, codeView, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/VideoView/VideoView.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/VideoView/VideoView.tsx new file mode 100644 index 00000000000..2bbfcc040a5 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/VideoView/VideoView.tsx @@ -0,0 +1,39 @@ +import { useEffect, useState } from "react"; +import { getBaseName } from "renderer/lib/pathBasename"; +import { getVideoMimeType } from "shared/file-types"; +import type { ViewProps } from "../../types"; + +export function VideoView({ document, filePath }: ViewProps) { + const [objectUrl, setObjectUrl] = useState(null); + + useEffect(() => { + if (document.content.kind !== "bytes") { + setObjectUrl(null); + return; + } + const mimeType = getVideoMimeType(filePath) ?? "video/mp4"; + const url = URL.createObjectURL( + new Blob([document.content.value as BlobPart], { type: mimeType }), + ); + setObjectUrl(url); + return () => URL.revokeObjectURL(url); + }, [document.content, filePath]); + + if (!objectUrl) { + return null; + } + + return ( +
+ +
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/VideoView/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/VideoView/index.ts new file mode 100644 index 00000000000..34e50dd7011 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/VideoView/index.ts @@ -0,0 +1,12 @@ +import { isVideoFile } from "shared/file-types"; +import type { FileView } from "../../types"; +import { VideoView } from "./VideoView"; + +export const videoView: FileView = { + id: "video", + label: "Video", + match: (filePath) => isVideoFile(filePath), + priority: "exclusive", + documentKind: "bytes", + Renderer: VideoView, +}; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/fileDocumentStore.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/fileDocumentStore.ts index 7e1120d7c70..f36ba3603c9 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/fileDocumentStore.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/fileDocumentStore/fileDocumentStore.ts @@ -1,6 +1,6 @@ import type { workspaceTrpc } from "@superset/workspace-client"; import type { FsWatchEvent } from "@superset/workspace-fs/client"; -import { isImageFile } from "shared/file-types"; +import { isImageFile, isVideoFile } from "shared/file-types"; import type { ConflictResolution, ConflictState, @@ -91,7 +91,8 @@ async function loadEntry( options: { unlimited?: boolean } = {}, ): Promise { const client = entry.trpcClient; - const readAsBinary = isImageFile(entry.absolutePath); + const readAsBinary = + isImageFile(entry.absolutePath) || isVideoFile(entry.absolutePath); const maxBytes = options.unlimited ? undefined : DEFAULT_MAX_BYTES; try { const result = await client.filesystem.readFile.query({ diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/FileDiffSection/FileDiffSection.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/FileDiffSection/FileDiffSection.tsx index 8d920e73a5b..696f0329020 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/FileDiffSection/FileDiffSection.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/FileDiffSection/FileDiffSection.tsx @@ -11,6 +11,7 @@ import { useChangesStore } from "renderer/stores/changes"; import { toAbsoluteWorkspacePath } from "shared/absolute-paths"; import type { ChangeCategory, ChangedFile } from "shared/changes-types"; import { detectLanguage } from "shared/detect-language"; +import { isVideoFile } from "shared/file-types"; import { getStatusColor, getStatusIndicator, @@ -123,9 +124,11 @@ export function FileDiffSection({ }); const totalChanges = file.additions + file.deletions; + const isVideo = isVideoFile(file.path); + const isBinaryFile = file.isBinary === true || isVideo; const isLargeDiff = totalChanges > LARGE_DIFF_THRESHOLD; const isGenerated = isGeneratedFile(file.path); - const isHiddenByDefault = isLargeDiff || isGenerated; + const isHiddenByDefault = !isBinaryFile && (isLargeDiff || isGenerated); const fileKey = createFileKey(file, category, commitHash, worktreePath); const isViewed = viewedFiles.has(fileKey); @@ -241,9 +244,12 @@ export function FileDiffSection({ const statusBadgeColor = getStatusColor(file.status); const statusIndicator = getStatusIndicator(file.status); - const showStats = file.additions > 0 || file.deletions > 0; + const showStats = !isBinaryFile && (file.additions > 0 || file.deletions > 0); const canShowDiffBody = - isExpanded && (!isHiddenByDefault || loadHiddenDiff) && !!worktreePath; + isExpanded && + !isBinaryFile && + (!isHiddenByDefault || loadHiddenDiff) && + !!worktreePath; const shouldLoadDiff = canShowDiffBody && hasBeenVisible && (isInLoadRange || isEditing); @@ -406,6 +412,22 @@ export function FileDiffSection({ ); + const binaryFilePreview = ( +
+ + {isVideo + ? "Video file — cannot display diff" + : "Binary file — cannot display diff"} + + + Use the file header to open this file outside the diff viewer. + +
+ ); + return (
- {isHiddenByDefault && !loadHiddenDiff ? ( + {isBinaryFile ? ( + binaryFilePreview + ) : isHiddenByDefault && !loadHiddenDiff ? (

diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/VirtualizedFileList/utils/getEstimatedFileDiffSectionHeight.test.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/VirtualizedFileList/utils/getEstimatedFileDiffSectionHeight.test.ts index e33ca423e61..e37089a7b63 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/VirtualizedFileList/utils/getEstimatedFileDiffSectionHeight.test.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/VirtualizedFileList/utils/getEstimatedFileDiffSectionHeight.test.ts @@ -61,4 +61,22 @@ describe("getEstimatedFileDiffSectionHeight", () => { }), ).toBe(420); }); + + it("uses the unsupported diff placeholder for binary files", () => { + expect( + getEstimatedFileDiffSectionHeight({ + file: createFile("assets/logo.png", { isBinary: true }), + isCollapsed: false, + }), + ).toBe(340); + }); + + it("uses the unsupported diff placeholder for videos", () => { + expect( + getEstimatedFileDiffSectionHeight({ + file: createFile("assets/demo.mp4", { isBinary: true }), + isCollapsed: false, + }), + ).toBe(340); + }); }); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/VirtualizedFileList/utils/getEstimatedFileDiffSectionHeight.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/VirtualizedFileList/utils/getEstimatedFileDiffSectionHeight.ts index b5280523c08..fcf583da576 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/VirtualizedFileList/utils/getEstimatedFileDiffSectionHeight.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/VirtualizedFileList/utils/getEstimatedFileDiffSectionHeight.ts @@ -1,4 +1,5 @@ import type { ChangedFile } from "shared/changes-types"; +import { isVideoFile } from "shared/file-types"; import { FILE_DIFF_SECTION_COLLAPSED_HEIGHT, FILE_DIFF_SECTION_PLACEHOLDER_HEIGHT, @@ -42,6 +43,14 @@ function isGeneratedFile(filePath: string): boolean { function getPlaceholderHeight(file: ChangedFile): number { const changedLineCount = file.additions + file.deletions; + if (isVideoFile(file.path)) { + return LARGE_PLACEHOLDER_HEIGHT; + } + + if (file.isBinary) { + return LARGE_PLACEHOLDER_HEIGHT; + } + if (isGeneratedFile(file.path)) { return XL_PLACEHOLDER_HEIGHT; } diff --git a/apps/desktop/src/shared/changes-types.ts b/apps/desktop/src/shared/changes-types.ts index 8318d4cc7fc..b8c4bc2d130 100644 --- a/apps/desktop/src/shared/changes-types.ts +++ b/apps/desktop/src/shared/changes-types.ts @@ -25,6 +25,7 @@ export interface ChangedFile { status: FileStatus; additions: number; deletions: number; + isBinary?: boolean; } /** A commit summary for the committed changes section */ diff --git a/apps/desktop/src/shared/file-types.test.ts b/apps/desktop/src/shared/file-types.test.ts index b96852ae227..eee2e564f4c 100644 --- a/apps/desktop/src/shared/file-types.test.ts +++ b/apps/desktop/src/shared/file-types.test.ts @@ -2,6 +2,8 @@ import { describe, expect, test } from "bun:test"; import { getImageExtensionFromMimeType, getImageMimeType, + getVideoMimeType, + isVideoFile, parseBase64DataUrl, } from "./file-types"; @@ -23,6 +25,20 @@ describe("file-types", () => { expect(getImageExtensionFromMimeType("image/avif")).toBeNull(); }); + test("detects supported video file paths", () => { + expect(isVideoFile("demo.mp4")).toBe(true); + expect(isVideoFile("clips/intro.webm")).toBe(true); + expect(isVideoFile("clips/INTRO.MOV")).toBe(true); + expect(isVideoFile("archive.zip")).toBe(false); + }); + + test("maps video file paths to MIME types", () => { + expect(getVideoMimeType("demo.mp4")).toBe("video/mp4"); + expect(getVideoMimeType("demo.webm")).toBe("video/webm"); + expect(getVideoMimeType("demo.mov")).toBe("video/quicktime"); + expect(getVideoMimeType("demo.avi")).toBeNull(); + }); + test("parses base64 data URLs with extra MIME parameters", () => { expect( parseBase64DataUrl( diff --git a/apps/desktop/src/shared/file-types.ts b/apps/desktop/src/shared/file-types.ts index ac6bfe3f71a..b7cf3550464 100644 --- a/apps/desktop/src/shared/file-types.ts +++ b/apps/desktop/src/shared/file-types.ts @@ -27,6 +27,18 @@ const IMAGE_MIME_TYPES: Record = { ico: "image/x-icon", }; +/** Supported browser-playable video extensions */ +const VIDEO_EXTENSIONS = new Set(["mp4", "m4v", "mov", "ogv", "webm"]); + +/** MIME types for supported video extensions */ +const VIDEO_MIME_TYPES: Record = { + mp4: "video/mp4", + m4v: "video/mp4", + mov: "video/quicktime", + ogv: "video/ogg", + webm: "video/webm", +}; + /** Extensions for supported image MIME types */ const IMAGE_MIME_TYPE_EXTENSIONS: Record = { "image/png": "png", @@ -46,15 +58,20 @@ 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() ?? ""; +export function getFileExtension(filePath: string): string { + const fileName = filePath.split(/[\\/]/).pop() ?? filePath; + const dotIndex = fileName.lastIndexOf("."); + if (dotIndex <= 0 || dotIndex === fileName.length - 1) { + return ""; + } + return fileName.slice(dotIndex + 1).toLowerCase(); } /** * Checks if a file is an image based on extension */ export function isImageFile(filePath: string): boolean { - return IMAGE_EXTENSIONS.has(getExtension(filePath)); + return IMAGE_EXTENSIONS.has(getFileExtension(filePath)); } /** @@ -62,10 +79,26 @@ export function isImageFile(filePath: string): boolean { * Returns null if not a supported image type */ export function getImageMimeType(filePath: string): string | null { - const ext = getExtension(filePath); + const ext = getFileExtension(filePath); return IMAGE_MIME_TYPES[ext] ?? null; } +/** + * Checks if a file is a browser-previewable video based on extension + */ +export function isVideoFile(filePath: string): boolean { + return VIDEO_EXTENSIONS.has(getFileExtension(filePath)); +} + +/** + * Gets the MIME type for a video file + * Returns null if not a supported video type + */ +export function getVideoMimeType(filePath: string): string | null { + const ext = getFileExtension(filePath); + return VIDEO_MIME_TYPES[ext] ?? null; +} + /** * Gets the preferred file extension for an image MIME type. * Returns null if not a supported image type. @@ -102,7 +135,7 @@ export function parseBase64DataUrl(dataUrl: string): { * Checks if a file is markdown based on extension */ export function isMarkdownFile(filePath: string): boolean { - return MARKDOWN_EXTENSIONS.has(getExtension(filePath)); + return MARKDOWN_EXTENSIONS.has(getFileExtension(filePath)); } /** From 38cf6c3f3cd4f202c44562f656b738b70ec12fa2 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Sun, 28 Jun 2026 23:14:01 -0500 Subject: [PATCH 02/13] Broaden media detection for file changes --- .../changes/workers/git-task-handlers.ts | 8 +++- .../registry/views/VideoView/index.ts | 4 +- apps/desktop/src/shared/file-types.test.ts | 18 +++++++++ apps/desktop/src/shared/file-types.ts | 38 ++++++++++++++++++- 4 files changed, 62 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/lib/trpc/routers/changes/workers/git-task-handlers.ts b/apps/desktop/src/lib/trpc/routers/changes/workers/git-task-handlers.ts index 70cd49e4a4c..cfef5367d42 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/workers/git-task-handlers.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/workers/git-task-handlers.ts @@ -1,7 +1,7 @@ import { open, readFile, realpath, stat } from "node:fs/promises"; import { isAbsolute, relative, resolve, sep } from "node:path"; import type { ChangedFile, GitChangesStatus } from "shared/changes-types"; -import { isVideoFile } from "shared/file-types"; +import { isImageFile, isVideoFile } from "shared/file-types"; import type { SimpleGit, StatusResult } from "simple-git"; import { getStatusNoLock } from "../../workspaces/utils/git"; import { getSimpleGitWithShellPath } from "../../workspaces/utils/git-client"; @@ -94,7 +94,11 @@ async function applyUntrackedLineCount( if (!stats.isFile()) continue; const sample = await readBinarySniffSample(fileReal); - if (sample.includes(0) || isVideoFile(file.path)) { + if ( + sample.includes(0) || + isImageFile(file.path) || + isVideoFile(file.path) + ) { file.isBinary = true; file.additions = 0; file.deletions = 0; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/VideoView/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/VideoView/index.ts index 34e50dd7011..00eadd2c8a0 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/VideoView/index.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/FilePane/registry/views/VideoView/index.ts @@ -1,11 +1,11 @@ -import { isVideoFile } from "shared/file-types"; +import { isPreviewableVideoFile } from "shared/file-types"; import type { FileView } from "../../types"; import { VideoView } from "./VideoView"; export const videoView: FileView = { id: "video", label: "Video", - match: (filePath) => isVideoFile(filePath), + match: (filePath) => isPreviewableVideoFile(filePath), priority: "exclusive", documentKind: "bytes", Renderer: VideoView, diff --git a/apps/desktop/src/shared/file-types.test.ts b/apps/desktop/src/shared/file-types.test.ts index eee2e564f4c..188b9786ce8 100644 --- a/apps/desktop/src/shared/file-types.test.ts +++ b/apps/desktop/src/shared/file-types.test.ts @@ -3,6 +3,8 @@ import { getImageExtensionFromMimeType, getImageMimeType, getVideoMimeType, + isImageFile, + isPreviewableVideoFile, isVideoFile, parseBase64DataUrl, } from "./file-types"; @@ -13,9 +15,16 @@ describe("file-types", () => { test("maps image file paths to MIME types", () => { expect(getImageMimeType("logo.svg")).toBe("image/svg+xml"); expect(getImageMimeType("logo.ico")).toBe("image/x-icon"); + expect(getImageMimeType("logo.tiff")).toBe("image/tiff"); expect(getImageMimeType("logo.unknown")).toBeNull(); }); + test("detects supported image file paths", () => { + expect(isImageFile("sample.bmp")).toBe(true); + expect(isImageFile("sample.tiff")).toBe(true); + expect(isImageFile("sample.txt")).toBe(false); + }); + test("maps image MIME types to preferred extensions", () => { expect(getImageExtensionFromMimeType("image/jpeg")).toBe("jpg"); expect(getImageExtensionFromMimeType("image/vnd.microsoft.icon")).toBe( @@ -29,9 +38,18 @@ describe("file-types", () => { expect(isVideoFile("demo.mp4")).toBe(true); expect(isVideoFile("clips/intro.webm")).toBe(true); expect(isVideoFile("clips/INTRO.MOV")).toBe(true); + expect(isVideoFile("clips/intro.avi")).toBe(true); + expect(isVideoFile("clips/intro.mkv")).toBe(true); expect(isVideoFile("archive.zip")).toBe(false); }); + test("detects browser-previewable video file paths", () => { + expect(isPreviewableVideoFile("demo.mp4")).toBe(true); + expect(isPreviewableVideoFile("demo.webm")).toBe(true); + expect(isPreviewableVideoFile("demo.avi")).toBe(false); + expect(isPreviewableVideoFile("demo.mkv")).toBe(false); + }); + test("maps video file paths to MIME types", () => { expect(getVideoMimeType("demo.mp4")).toBe("video/mp4"); expect(getVideoMimeType("demo.webm")).toBe("video/webm"); diff --git a/apps/desktop/src/shared/file-types.ts b/apps/desktop/src/shared/file-types.ts index b7cf3550464..df65983c446 100644 --- a/apps/desktop/src/shared/file-types.ts +++ b/apps/desktop/src/shared/file-types.ts @@ -13,6 +13,8 @@ const IMAGE_EXTENSIONS = new Set([ "svg", "bmp", "ico", + "tif", + "tiff", ]); /** MIME types for supported image extensions */ @@ -25,10 +27,34 @@ const IMAGE_MIME_TYPES: Record = { svg: "image/svg+xml", bmp: "image/bmp", ico: "image/x-icon", + tif: "image/tiff", + tiff: "image/tiff", }; -/** Supported browser-playable video extensions */ -const VIDEO_EXTENSIONS = new Set(["mp4", "m4v", "mov", "ogv", "webm"]); +/** Common video extensions */ +const VIDEO_EXTENSIONS = new Set([ + "3g2", + "3gp", + "avi", + "m4v", + "mkv", + "mov", + "mp4", + "mpeg", + "mpg", + "ogv", + "webm", + "wmv", +]); + +/** Browser-playable video extensions */ +const PREVIEWABLE_VIDEO_EXTENSIONS = new Set([ + "m4v", + "mov", + "mp4", + "ogv", + "webm", +]); /** MIME types for supported video extensions */ const VIDEO_MIME_TYPES: Record = { @@ -48,6 +74,7 @@ const IMAGE_MIME_TYPE_EXTENSIONS: Record = { "image/webp": "webp", "image/svg+xml": "svg", "image/bmp": "bmp", + "image/tiff": "tiff", "image/x-icon": "ico", "image/vnd.microsoft.icon": "ico", }; @@ -90,6 +117,13 @@ export function isVideoFile(filePath: string): boolean { return VIDEO_EXTENSIONS.has(getFileExtension(filePath)); } +/** + * Checks if a video file can be previewed by the browser video element. + */ +export function isPreviewableVideoFile(filePath: string): boolean { + return PREVIEWABLE_VIDEO_EXTENSIONS.has(getFileExtension(filePath)); +} + /** * Gets the MIME type for a video file * Returns null if not a supported video type From 138d4e8a7af36dabfe0d17ae86f8df672e8cd907 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Sun, 28 Jun 2026 23:15:46 -0500 Subject: [PATCH 03/13] Unify binary diff placeholder copy --- .../components/FileDiffSection/FileDiffSection.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/FileDiffSection/FileDiffSection.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/FileDiffSection/FileDiffSection.tsx index 696f0329020..862eb21ab03 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/FileDiffSection/FileDiffSection.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ChangesContent/components/FileDiffSection/FileDiffSection.tsx @@ -418,9 +418,7 @@ export function FileDiffSection({ style={{ minHeight: FILE_DIFF_SECTION_PLACEHOLDER_HEIGHT }} > - {isVideo - ? "Video file — cannot display diff" - : "Binary file — cannot display diff"} + Binary file — cannot display diff Use the file header to open this file outside the diff viewer. From e69b68e4ae1e8d1a0e15573260f5d852868cbd0a Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Sun, 28 Jun 2026 23:21:42 -0500 Subject: [PATCH 04/13] Show binary placeholders in v2 changes pane --- .../$workspaceId/hooks/useChangeset/types.ts | 1 + .../hooks/useChangeset/useChangeset.ts | 6 ++ .../useDiffCodeViewItems.ts | 54 ++++++++++--- .../useDiffCodeViewScroll.ts | 3 +- .../host-service/src/trpc/router/git/types.ts | 1 + .../trpc/router/git/utils/git-helpers.test.ts | 81 ++++++++++++++++--- .../src/trpc/router/git/utils/git-helpers.ts | 72 +++++++++++++++-- .../src/trpc/router/git/utils/git-status.ts | 4 + 8 files changed, 192 insertions(+), 30 deletions(-) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useChangeset/types.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useChangeset/types.ts index 69200b2e0a6..42453c90264 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useChangeset/types.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useChangeset/types.ts @@ -17,5 +17,6 @@ export interface ChangesetFile { status: FileStatus; additions: number; deletions: number; + isBinary?: boolean; source: DiffFileSource; } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useChangeset/useChangeset.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useChangeset/useChangeset.ts index d2636b5da56..cbf54f85c5f 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useChangeset/useChangeset.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useChangeset/useChangeset.ts @@ -43,6 +43,7 @@ export function useChangeset({ status: file.status as FileStatus, additions: file.additions, deletions: file.deletions, + isBinary: file.isBinary, source: { kind: "commit", commitHash: ref.commitHash, @@ -62,6 +63,7 @@ export function useChangeset({ status: file.status as FileStatus, additions: file.additions, deletions: file.deletions, + isBinary: file.isBinary, source: { kind: "unstaged" }, })), ...status.staged.map((file) => ({ @@ -70,6 +72,7 @@ export function useChangeset({ status: file.status as FileStatus, additions: file.additions, deletions: file.deletions, + isBinary: file.isBinary, source: { kind: "staged" }, })), ]; @@ -86,6 +89,7 @@ export function useChangeset({ status: file.status as FileStatus, additions: file.additions, deletions: file.deletions, + isBinary: file.isBinary, source: { kind: "unstaged" }, }); } @@ -97,6 +101,7 @@ export function useChangeset({ status: file.status as FileStatus, additions: file.additions, deletions: file.deletions, + isBinary: file.isBinary, source: { kind: "staged" }, }); } @@ -108,6 +113,7 @@ export function useChangeset({ status: file.status as FileStatus, additions: file.additions, deletions: file.deletions, + isBinary: file.isBinary, source: { kind: "against-base", baseBranch: ref.baseBranch }, }); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/hooks/useDiffCodeViewItems/useDiffCodeViewItems.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/hooks/useDiffCodeViewItems/useDiffCodeViewItems.ts index 58c76099796..5da3f2ed4c8 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/hooks/useDiffCodeViewItems/useDiffCodeViewItems.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/hooks/useDiffCodeViewItems/useDiffCodeViewItems.ts @@ -49,10 +49,12 @@ export function useDiffCodeViewItems({ const diffRequests = useMemo( () => - files.map((file) => ({ - file, - input: createGetDiffInput(workspaceId, file), - })), + files + .filter((file) => !file.isBinary) + .map((file) => ({ + file, + input: createGetDiffInput(workspaceId, file), + })), [files, workspaceId], ); @@ -84,14 +86,44 @@ export function useDiffCodeViewItems({ const items = useMemo[]>(() => { const nextItems: CodeViewItem[] = []; + const queryByItemId = new Map( + diffRequests.map((request, index) => [ + getDiffItemId(request.file), + diffQueries[index], + ]), + ); - for (let index = 0; index < diffRequests.length; index++) { - const request = diffRequests[index]; - const query = diffQueries[index]; - if (!request || !query?.data) continue; - - const { file } = request; + for (const file of files) { const itemId = getDiffItemId(file); + const collapsed = collapsedSet.has(file.path); + + if (file.isBinary) { + nextItems.push({ + id: itemId, + type: "file", + file: { + name: file.path, + contents: "Binary file — cannot display diff", + }, + collapsed, + version: hashString( + [ + file.path, + file.oldPath ?? "", + file.status, + file.additions, + file.deletions, + "binary", + collapsed ? "1" : "0", + ].join("\0"), + ), + }); + continue; + } + + const query = queryByItemId.get(itemId); + if (!query?.data) continue; + const baseAnnotations = getAnnotationsForFile(annotationsByPath, file); const extra = extraAnnotationsByItemId?.get(itemId); const annotations = @@ -108,7 +140,6 @@ export function useDiffCodeViewItems({ name: file.path, }, ); - const collapsed = collapsedSet.has(file.path); const version = hashString( [ query.dataUpdatedAt, @@ -134,6 +165,7 @@ export function useDiffCodeViewItems({ return nextItems; }, [ + files, diffRequests, diffQueries, annotationsByPath, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/hooks/useDiffCodeViewScroll/useDiffCodeViewScroll.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/hooks/useDiffCodeViewScroll/useDiffCodeViewScroll.ts index 104e099c21e..dd64a77f981 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/hooks/useDiffCodeViewScroll/useDiffCodeViewScroll.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/hooks/useDiffCodeViewScroll/useDiffCodeViewScroll.ts @@ -56,8 +56,9 @@ export function useDiffCodeViewScroll({ ].join(":"); if (lastScrollTargetRef.current === scrollKey) return; + const targetItem = itemById.get(targetItemId); const target: CodeViewScrollTarget = - data.focusLine != null + data.focusLine != null && targetItem?.type === "diff" ? { type: "line", id: targetItemId, diff --git a/packages/host-service/src/trpc/router/git/types.ts b/packages/host-service/src/trpc/router/git/types.ts index 90a80e558d8..7a6ddf78bda 100644 --- a/packages/host-service/src/trpc/router/git/types.ts +++ b/packages/host-service/src/trpc/router/git/types.ts @@ -122,6 +122,7 @@ export interface ChangedFile { status: FileStatus; additions: number; deletions: number; + isBinary?: boolean; } export interface Commit { diff --git a/packages/host-service/src/trpc/router/git/utils/git-helpers.test.ts b/packages/host-service/src/trpc/router/git/utils/git-helpers.test.ts index 4ce2ecc6b12..e98129c9c51 100644 --- a/packages/host-service/src/trpc/router/git/utils/git-helpers.test.ts +++ b/packages/host-service/src/trpc/router/git/utils/git-helpers.test.ts @@ -5,28 +5,56 @@ describe("parseNumstat", () => { test("regular file entry", () => { const raw = "5\t2\tsrc/foo.ts\0"; const result = parseNumstat(raw); - expect(result.get("src/foo.ts")).toEqual({ additions: 5, deletions: 2 }); + expect(result.get("src/foo.ts")).toEqual({ + additions: 5, + deletions: 2, + isBinary: false, + }); }); test("multiple regular entries", () => { const raw = "5\t2\tsrc/foo.ts\x003\t0\tsrc/bar.ts\x00"; const result = parseNumstat(raw); - expect(result.get("src/foo.ts")).toEqual({ additions: 5, deletions: 2 }); - expect(result.get("src/bar.ts")).toEqual({ additions: 3, deletions: 0 }); + expect(result.get("src/foo.ts")).toEqual({ + additions: 5, + deletions: 2, + isBinary: false, + }); + expect(result.get("src/bar.ts")).toEqual({ + additions: 3, + deletions: 0, + isBinary: false, + }); }); test("exact rename with edits indexes both paths", () => { const raw = "4\t3\t\x00src/old.ts\x00src/new.ts\x00"; const result = parseNumstat(raw); - expect(result.get("src/new.ts")).toEqual({ additions: 4, deletions: 3 }); - expect(result.get("src/old.ts")).toEqual({ additions: 4, deletions: 3 }); + expect(result.get("src/new.ts")).toEqual({ + additions: 4, + deletions: 3, + isBinary: false, + }); + expect(result.get("src/old.ts")).toEqual({ + additions: 4, + deletions: 3, + isBinary: false, + }); }); test("pure rename with zero line changes", () => { const raw = "0\t0\t\x00src/old.ts\x00src/new.ts\x00"; const result = parseNumstat(raw); - expect(result.get("src/new.ts")).toEqual({ additions: 0, deletions: 0 }); - expect(result.get("src/old.ts")).toEqual({ additions: 0, deletions: 0 }); + expect(result.get("src/new.ts")).toEqual({ + additions: 0, + deletions: 0, + isBinary: false, + }); + expect(result.get("src/old.ts")).toEqual({ + additions: 0, + deletions: 0, + isBinary: false, + }); }); test("binary file with dash markers", () => { @@ -35,6 +63,7 @@ describe("parseNumstat", () => { expect(result.get("assets/image.png")).toEqual({ additions: 0, deletions: 0, + isBinary: true, }); }); @@ -44,12 +73,25 @@ describe("parseNumstat", () => { "4\t3\t\x00src/old.ts\x00src/new.ts\x00" + "-\t-\tassets/image.png\x00"; const result = parseNumstat(raw); - expect(result.get("src/foo.ts")).toEqual({ additions: 5, deletions: 2 }); - expect(result.get("src/new.ts")).toEqual({ additions: 4, deletions: 3 }); - expect(result.get("src/old.ts")).toEqual({ additions: 4, deletions: 3 }); + expect(result.get("src/foo.ts")).toEqual({ + additions: 5, + deletions: 2, + isBinary: false, + }); + expect(result.get("src/new.ts")).toEqual({ + additions: 4, + deletions: 3, + isBinary: false, + }); + expect(result.get("src/old.ts")).toEqual({ + additions: 4, + deletions: 3, + isBinary: false, + }); expect(result.get("assets/image.png")).toEqual({ additions: 0, deletions: 0, + isBinary: true, }); }); @@ -63,20 +105,33 @@ describe("parseNumstat", () => { expect(result.get("weird\tpath.ts")).toEqual({ additions: 1, deletions: 1, + isBinary: false, }); }); test("rename where both paths contain tabs", () => { const raw = "2\t1\t\x00weird\told.ts\x00weird\tnew.ts\x00"; const result = parseNumstat(raw); - expect(result.get("weird\told.ts")).toEqual({ additions: 2, deletions: 1 }); - expect(result.get("weird\tnew.ts")).toEqual({ additions: 2, deletions: 1 }); + expect(result.get("weird\told.ts")).toEqual({ + additions: 2, + deletions: 1, + isBinary: false, + }); + expect(result.get("weird\tnew.ts")).toEqual({ + additions: 2, + deletions: 1, + isBinary: false, + }); }); test("non-ASCII path (raw UTF-8)", () => { const raw = "3\t1\tsrc/日本語.ts\0"; const result = parseNumstat(raw); - expect(result.get("src/日本語.ts")).toEqual({ additions: 3, deletions: 1 }); + expect(result.get("src/日本語.ts")).toEqual({ + additions: 3, + deletions: 1, + isBinary: false, + }); }); }); diff --git a/packages/host-service/src/trpc/router/git/utils/git-helpers.ts b/packages/host-service/src/trpc/router/git/utils/git-helpers.ts index 93b71bc436e..57eb5b0f593 100644 --- a/packages/host-service/src/trpc/router/git/utils/git-helpers.ts +++ b/packages/host-service/src/trpc/router/git/utils/git-helpers.ts @@ -23,6 +23,30 @@ const MAX_UNTRACKED_LINE_COUNT_SIZE = 1 * 1024 * 1024; // doesn't exhaust the process file-descriptor limit. const UNTRACKED_IO_CONCURRENCY = 64; +const BINARY_MEDIA_EXTENSIONS = new Set([ + "3g2", + "3gp", + "avi", + "bmp", + "gif", + "ico", + "jpeg", + "jpg", + "m4v", + "mkv", + "mov", + "mp4", + "mpeg", + "mpg", + "ogv", + "png", + "tif", + "tiff", + "webm", + "webp", + "wmv", +]); + async function mapWithConcurrency( items: T[], limit: number, @@ -72,8 +96,11 @@ export function mapGitStatus(code: string): FileStatus { */ export function parseNumstat( raw: string, -): Map { - const result = new Map(); +): Map { + const result = new Map< + string, + { additions: number; deletions: number; isBinary: boolean } + >(); const entries = raw.split("\0"); for (let i = 0; i < entries.length; i++) { const entry = entries[i]; @@ -87,6 +114,7 @@ export function parseNumstat( const stats = { additions: add === "-" ? 0 : Number.parseInt(add || "0", 10), deletions: del === "-" ? 0 : Number.parseInt(del || "0", 10), + isBinary: add === "-" && del === "-", }; if (pathMaybe === "") { const oldPath = entries[++i] ?? ""; @@ -100,6 +128,19 @@ export function parseNumstat( return result; } +function getFileExtension(filePath: string): string { + const fileName = filePath.split(/[\\/]/).pop() ?? filePath; + const dotIndex = fileName.lastIndexOf("."); + if (dotIndex <= 0 || dotIndex === fileName.length - 1) { + return ""; + } + return fileName.slice(dotIndex + 1).toLowerCase(); +} + +function isBinaryMediaFile(filePath: string): boolean { + return BINARY_MEDIA_EXTENSIONS.has(getFileExtension(filePath)); +} + /** * Parse `git diff --name-status -z`. Each record is the status letter * followed by one path (regular) or two paths (rename/copy), with NUL @@ -266,7 +307,14 @@ export async function countUntrackedFileLines( if (!isPathWithinWorktree(worktreeReal, fileReal)) return; const stats = await stat(fileReal); - if (!stats.isFile() || stats.size > MAX_UNTRACKED_LINE_COUNT_SIZE) { + if (!stats.isFile()) { + return; + } + + if (isBinaryMediaFile(file.path)) { + file.isBinary = true; + file.additions = 0; + file.deletions = 0; return; } @@ -276,7 +324,16 @@ export async function countUntrackedFileLines( const buf = await readFile(fileReal); const sniffEnd = Math.min(buf.length, 8192); for (let i = 0; i < sniffEnd; i++) { - if (buf[i] === 0) return; + if (buf[i] === 0) { + file.isBinary = true; + file.additions = 0; + file.deletions = 0; + return; + } + } + + if (stats.size > MAX_UNTRACKED_LINE_COUNT_SIZE) { + return; } const content = buf.toString("utf-8"); @@ -354,7 +411,11 @@ export async function detectUnstagedRenames( if (!entry.oldPath) continue; const code = entry.status[0]; if (code !== "R") continue; - const stats = numstat.get(entry.path) ?? { additions: 0, deletions: 0 }; + const stats = numstat.get(entry.path) ?? { + additions: 0, + deletions: 0, + isBinary: false, + }; result.push({ oldPath: entry.oldPath, newPath: entry.path, @@ -395,6 +456,7 @@ export async function getChangedFilesForDiff( status: mapGitStatus(f.status), additions: (numstat.get(f.path) ?? { additions: 0 }).additions, deletions: (numstat.get(f.path) ?? { deletions: 0 }).deletions, + isBinary: (numstat.get(f.path) ?? { isBinary: false }).isBinary, })); } catch { return []; diff --git a/packages/host-service/src/trpc/router/git/utils/git-status.ts b/packages/host-service/src/trpc/router/git/utils/git-status.ts index 071bf222eaa..fcbc42bc9fa 100644 --- a/packages/host-service/src/trpc/router/git/utils/git-status.ts +++ b/packages/host-service/src/trpc/router/git/utils/git-status.ts @@ -77,6 +77,7 @@ export async function getGitStatusSnapshot({ const stats = stagedNumstat.get(file.path) ?? { additions: 0, deletions: 0, + isBinary: false, }; staged.push({ path: file.path, @@ -84,6 +85,7 @@ export async function getGitStatusSnapshot({ status: mapGitStatus(idx), additions: stats.additions, deletions: stats.deletions, + isBinary: stats.isBinary, }); } } @@ -108,12 +110,14 @@ export async function getGitStatusSnapshot({ const stats = unstagedNumstat.get(file.path) ?? { additions: 0, deletions: 0, + isBinary: false, }; unstaged.push({ path: file.path, status: mapGitStatus(wd), additions: stats.additions, deletions: stats.deletions, + isBinary: stats.isBinary, }); } } From c854beb1eeaae59f1c1777562c9a1fe1cccd7576 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Sun, 28 Jun 2026 23:35:55 -0500 Subject: [PATCH 05/13] Add binary preview action in changes pane --- .../DiffHeaderMetadata/DiffHeaderMetadata.tsx | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/components/DiffHeaderMetadata/DiffHeaderMetadata.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/components/DiffHeaderMetadata/DiffHeaderMetadata.tsx index 1edd11a97c9..423e7fca55a 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/components/DiffHeaderMetadata/DiffHeaderMetadata.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/components/DiffHeaderMetadata/DiffHeaderMetadata.tsx @@ -88,6 +88,7 @@ export function DiffHeaderMetadata({ }, [discardMutation, workspaceId, file.path]); const isDeleteAction = file.status === "untracked" || file.status === "added"; const basename = file.path.split("/").pop() ?? file.path; + const openLabel = file.isBinary ? "Open preview" : "Open in file viewer"; return ( <> @@ -97,14 +98,22 @@ export function DiffHeaderMetadata({ - {policy.hint} + {file.isBinary ? openLabel : policy.hint} From 64bd073cc9462c5a06280adf7c0f1f62981c3579 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Sun, 28 Jun 2026 23:41:35 -0500 Subject: [PATCH 06/13] Show binary changes as hidden placeholder --- .../components/DiffPane/DiffPane.tsx | 39 ++++++++++++++++++- .../DiffHeaderMetadata/DiffHeaderMetadata.tsx | 17 ++------ .../useDiffAnnotations/useDiffAnnotations.ts | 3 +- .../useDiffCodeViewItems.ts | 7 ++++ 4 files changed, 51 insertions(+), 15 deletions(-) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/DiffPane.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/DiffPane.tsx index c879b25c199..d96bde0fba8 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/DiffPane.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/DiffPane.tsx @@ -5,7 +5,9 @@ import type { } from "@pierre/diffs"; import { CodeView, type CodeViewHandle } from "@pierre/diffs/react"; import type { RendererContext } from "@superset/panes"; +import { Button } from "@superset/ui/button"; import { useCallback, useMemo, useRef } from "react"; +import { LuFileCode } from "react-icons/lu"; import type { DiffPaneData, PaneViewerData } from "../../../../types"; import { type ChangesetFile, useChangeset } from "../../../useChangeset"; import { useOpenInExternalEditor } from "../../../useOpenInExternalEditor"; @@ -181,8 +183,14 @@ export function DiffPane({ | DiffLineAnnotation, item: CodeViewItem, ) => { - if (item.type !== "diff") return null; const m = annotation.metadata; + if (item.type === "file") { + if (m.kind !== "binary-placeholder") return null; + const file = fileByItemId.get(item.id); + if (!file) return null; + return ; + } + if (item.type !== "diff") return null; if (m.kind === "composer") { return ( ); } + if (m.kind !== "thread") return null; const annotationSide = "side" in annotation ? annotation.side : undefined; const focused = item.id === targetItemId && @@ -221,6 +230,8 @@ export function DiffPane({ data.focusTick, clearComposer, submitComposer, + fileByItemId, + onOpenFile, ], ); @@ -257,3 +268,29 @@ export function DiffPane({ /> ); } + +function BinaryDiffPlaceholder({ + file, + onOpenFile, +}: { + file: ChangesetFile; + onOpenFile: (path: string, openInNewTab?: boolean) => void; +}) { + const canOpen = file.status !== "deleted"; + + return ( +

+ +

Binary file hidden

+ {canOpen ? ( + + ) : null} +
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/components/DiffHeaderMetadata/DiffHeaderMetadata.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/components/DiffHeaderMetadata/DiffHeaderMetadata.tsx index 423e7fca55a..1edd11a97c9 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/components/DiffHeaderMetadata/DiffHeaderMetadata.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/components/DiffHeaderMetadata/DiffHeaderMetadata.tsx @@ -88,7 +88,6 @@ export function DiffHeaderMetadata({ }, [discardMutation, workspaceId, file.path]); const isDeleteAction = file.status === "untracked" || file.status === "added"; const basename = file.path.split("/").pop() ?? file.path; - const openLabel = file.isBinary ? "Open preview" : "Open in file viewer"; return ( <> @@ -98,22 +97,14 @@ export function DiffHeaderMetadata({ - {file.isBinary ? openLabel : policy.hint} + {policy.hint} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/hooks/useDiffAnnotations/useDiffAnnotations.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/hooks/useDiffAnnotations/useDiffAnnotations.ts index 5667db9021b..7a9271cf942 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/hooks/useDiffAnnotations/useDiffAnnotations.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/hooks/useDiffAnnotations/useDiffAnnotations.ts @@ -31,7 +31,8 @@ export interface DiffAgentComposer { export type DiffAnnotationMetadata = | ({ kind: "thread" } & DiffCommentThread) - | ({ kind: "composer" } & DiffAgentComposer); + | ({ kind: "composer" } & DiffAgentComposer) + | { kind: "binary-placeholder" }; interface UseDiffAnnotationsByPathOptions { workspaceId: string; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/hooks/useDiffCodeViewItems/useDiffCodeViewItems.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/hooks/useDiffCodeViewItems/useDiffCodeViewItems.ts index 5da3f2ed4c8..cca2a609293 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/hooks/useDiffCodeViewItems/useDiffCodeViewItems.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/hooks/useDiffCodeViewItems/useDiffCodeViewItems.ts @@ -105,6 +105,12 @@ export function useDiffCodeViewItems({ name: file.path, contents: "Binary file — cannot display diff", }, + annotations: [ + { + lineNumber: 1, + metadata: { kind: "binary-placeholder" }, + }, + ], collapsed, version: hashString( [ @@ -256,6 +262,7 @@ function getAnnotationsVersion( m.endSide, ].join(","); } + if (m.kind !== "thread") return "local"; return [ "t", annotation.side, From 124a594fc49eb951b4da7f3a03ccb9033e31f476 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Sun, 28 Jun 2026 23:45:22 -0500 Subject: [PATCH 07/13] Remove duplicate binary placeholder text --- .../DiffPane/hooks/useDiffCodeViewItems/useDiffCodeViewItems.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/hooks/useDiffCodeViewItems/useDiffCodeViewItems.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/hooks/useDiffCodeViewItems/useDiffCodeViewItems.ts index cca2a609293..708a271abc6 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/hooks/useDiffCodeViewItems/useDiffCodeViewItems.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/hooks/useDiffCodeViewItems/useDiffCodeViewItems.ts @@ -103,7 +103,7 @@ export function useDiffCodeViewItems({ type: "file", file: { name: file.path, - contents: "Binary file — cannot display diff", + contents: "", }, annotations: [ { From d535d79e1f40b81e2ea4577662ebd4ebf0803871 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Sun, 28 Jun 2026 23:48:11 -0500 Subject: [PATCH 08/13] Keep binary placeholder annotation anchored --- .../DiffPane/hooks/useDiffCodeViewItems/useDiffCodeViewItems.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/hooks/useDiffCodeViewItems/useDiffCodeViewItems.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/hooks/useDiffCodeViewItems/useDiffCodeViewItems.ts index 708a271abc6..97d0d0a6a7d 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/hooks/useDiffCodeViewItems/useDiffCodeViewItems.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/hooks/useDiffCodeViewItems/useDiffCodeViewItems.ts @@ -103,7 +103,7 @@ export function useDiffCodeViewItems({ type: "file", file: { name: file.path, - contents: "", + contents: " ", }, annotations: [ { From 4b43be19633068ecb900ce73965e74970a8c30a8 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Mon, 29 Jun 2026 10:17:02 -0500 Subject: [PATCH 09/13] Address PR review: binary file threads + untracked sniff memory - git-helpers: sniff untracked files via partial 8KB read so large files aren't fully loaded into memory before the line-count size guard - DiffPane: re-anchor existing review threads onto the binary placeholder item and render them, instead of silently dropping them - file-types: clarify isVideoFile JSDoc (covers all video extensions, not just browser-previewable ones) --- .../components/DiffPane/DiffPane.tsx | 6 ++--- .../useDiffCodeViewItems.ts | 22 +++++++++++++----- apps/desktop/src/shared/file-types.ts | 4 +++- .../src/trpc/router/git/utils/git-helpers.ts | 23 +++++++++++++------ 4 files changed, 38 insertions(+), 17 deletions(-) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/DiffPane.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/DiffPane.tsx index d96bde0fba8..8f56019b99a 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/DiffPane.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/DiffPane/DiffPane.tsx @@ -184,14 +184,14 @@ export function DiffPane({ item: CodeViewItem, ) => { const m = annotation.metadata; - if (item.type === "file") { - if (m.kind !== "binary-placeholder") return null; + if (m.kind === "binary-placeholder") { + if (item.type !== "file") return null; const file = fileByItemId.get(item.id); if (!file) return null; return ; } - if (item.type !== "diff") return null; if (m.kind === "composer") { + if (item.type !== "diff") return null; return ( ({ ...annotation, lineNumber: 1 })); + const annotations: DiffLineAnnotation[] = [ + { + side: "additions", + lineNumber: 1, + metadata: { kind: "binary-placeholder" }, + }, + ...threadAnnotations, + ]; nextItems.push({ id: itemId, type: "file", @@ -105,12 +119,7 @@ export function useDiffCodeViewItems({ name: file.path, contents: " ", }, - annotations: [ - { - lineNumber: 1, - metadata: { kind: "binary-placeholder" }, - }, - ], + annotations, collapsed, version: hashString( [ @@ -121,6 +130,7 @@ export function useDiffCodeViewItems({ file.deletions, "binary", collapsed ? "1" : "0", + getAnnotationsVersion(annotations), ].join("\0"), ), }); diff --git a/apps/desktop/src/shared/file-types.ts b/apps/desktop/src/shared/file-types.ts index df65983c446..06d75baf476 100644 --- a/apps/desktop/src/shared/file-types.ts +++ b/apps/desktop/src/shared/file-types.ts @@ -111,7 +111,9 @@ export function getImageMimeType(filePath: string): string | null { } /** - * Checks if a file is a browser-previewable video based on extension + * Checks if a file is a video based on extension. Covers all known video + * extensions, not just browser-playable ones — use isPreviewableVideoFile + * to gate the inline