feat(KB-227): allow viewing binary files as read-only in file browser

- Remove binary file restrictions from file service backend

- Add visual read-only indicator for binary files in FileBrowserModal

- Add CSS styles for binary file indicators

- Update tests to expect binary files to be readable
This commit is contained in:
gsxdsm
2026-03-30 18:55:57 -07:00
parent a253bb56af
commit 99f99ca401
4 changed files with 43 additions and 39 deletions

View File

@@ -1,5 +1,5 @@
import { useState, useCallback, useEffect } from "react"; import { useState, useCallback, useEffect } from "react";
import { X, Save, RotateCcw, Folder } from "lucide-react"; import { X, Save, RotateCcw, Folder, FileType } from "lucide-react";
import { useFileBrowser } from "../hooks/useFileBrowser"; import { useFileBrowser } from "../hooks/useFileBrowser";
import { useFileEditor } from "../hooks/useFileEditor"; import { useFileEditor } from "../hooks/useFileEditor";
import { useProjectFileBrowser } from "../hooks/useProjectFileBrowser"; import { useProjectFileBrowser } from "../hooks/useProjectFileBrowser";
@@ -7,6 +7,27 @@ import { useProjectFileEditor } from "../hooks/useProjectFileEditor";
import { FileBrowser } from "./FileBrowser"; import { FileBrowser } from "./FileBrowser";
import { FileEditor } from "./FileEditor"; import { FileEditor } from "./FileEditor";
/**
* Binary file extensions that should be displayed as read-only.
*/
const BINARY_EXTENSIONS = new Set([
".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico", ".bmp", ".svgz",
".exe", ".dll", ".so", ".dylib",
".zip", ".tar", ".gz", ".bz2", ".xz", ".7z", ".rar",
".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx",
".mp3", ".mp4", ".avi", ".mov", ".webm", ".mkv", ".flv",
".woff", ".woff2", ".ttf", ".otf", ".eot",
".wasm", ".bin",
]);
/**
* Check if a file is a binary file based on extension.
*/
function isBinaryFile(filename: string): boolean {
const ext = filename.slice(filename.lastIndexOf(".")).toLowerCase();
return BINARY_EXTENSIONS.has(ext);
}
type TaskModeProps = { type TaskModeProps = {
taskId: string; taskId: string;
worktreePath?: string; worktreePath?: string;
@@ -134,6 +155,12 @@ export function FileBrowserModal(props: FileBrowserModalProps) {
<div className="file-browser-toolbar"> <div className="file-browser-toolbar">
<div className="file-browser-file-info"> <div className="file-browser-file-info">
{selectedFile} {selectedFile}
{selectedFile && isBinaryFile(selectedFile) && (
<span className="file-browser-binary-indicator">
<FileType size={12} />
Binary file read only
</span>
)}
{mtime && ( {mtime && (
<span className="file-browser-mtime"> <span className="file-browser-mtime">
Modified: {new Date(mtime).toLocaleString()} Modified: {new Date(mtime).toLocaleString()}
@@ -176,6 +203,7 @@ export function FileBrowserModal(props: FileBrowserModalProps) {
content={content} content={content}
onChange={setContent} onChange={setContent}
filePath={selectedFile} filePath={selectedFile}
readOnly={selectedFile ? isBinaryFile(selectedFile) : false}
/> />
</div> </div>

View File

@@ -6560,6 +6560,17 @@ html .column.drag-over * {
color: var(--text-muted); color: var(--text-muted);
} }
.file-browser-binary-indicator {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: var(--text-muted);
background: var(--bg-tertiary);
padding: 2px 8px;
border-radius: 4px;
}
.file-browser-loading { .file-browser-loading {
font-size: 11px; font-size: 11px;
color: var(--todo); color: var(--todo);

View File

@@ -81,30 +81,6 @@ const TEXT_EXTENSIONS = new Set([
".lock", ".log", ".lock", ".log",
]); ]);
/**
* Binary file extensions set.
*/
const BINARY_EXTENSIONS = new Set([
".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico", ".bmp", ".svgz",
".exe", ".dll", ".so", ".dylib",
".zip", ".tar", ".gz", ".bz2", ".xz", ".7z", ".rar",
".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx",
".mp3", ".mp4", ".avi", ".mov", ".webm", ".mkv", ".flv",
".woff", ".woff2", ".ttf", ".otf", ".eot",
".wasm", ".bin",
]);
/**
* Check if a file is a binary file based on extension.
*/
function isBinaryFile(filename: string): boolean {
const ext = filename.slice(filename.lastIndexOf(".")).toLowerCase();
if (BINARY_EXTENSIONS.has(ext)) {
return true;
}
return false;
}
/** /**
* Get the base path for a task's files. * Get the base path for a task's files.
* Returns the worktree path if it exists, otherwise the task directory. * Returns the worktree path if it exists, otherwise the task directory.
@@ -290,12 +266,6 @@ export async function readFile(
throw new FileServiceError(`File too large: ${stats.size} bytes (max ${MAX_FILE_SIZE})`, "ETOOLARGE"); throw new FileServiceError(`File too large: ${stats.size} bytes (max ${MAX_FILE_SIZE})`, "ETOOLARGE");
} }
// Check if it's a binary file
const basename = filePath.split("/").pop() || filePath;
if (isBinaryFile(basename)) {
throw new FileServiceError(`Binary file, cannot edit: ${filePath}`, "EINVAL");
}
try { try {
const content = await fsReadFile(resolvedPath, "utf-8"); const content = await fsReadFile(resolvedPath, "utf-8");
@@ -506,12 +476,6 @@ export async function readProjectFile(
throw new FileServiceError(`File too large: ${stats.size} bytes (max ${MAX_FILE_SIZE})`, "ETOOLARGE"); throw new FileServiceError(`File too large: ${stats.size} bytes (max ${MAX_FILE_SIZE})`, "ETOOLARGE");
} }
// Check if it's a binary file
const basename = filePath.split("/").pop() || filePath;
if (isBinaryFile(basename)) {
throw new FileServiceError(`Binary file, cannot edit: ${filePath}`, "EINVAL");
}
try { try {
const content = await fsReadFile(resolvedPath, "utf-8"); const content = await fsReadFile(resolvedPath, "utf-8");

View File

@@ -3456,14 +3456,15 @@ describe("Git Management endpoints", () => {
expect(res.status === 400 || res.status === 404).toBe(true); expect(res.status === 400 || res.status === 404).toBe(true);
}); });
it("returns 415 for binary files", async () => { it("allows reading binary files (returns 404 if not found)", async () => {
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({ (store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
id: "KB-001", id: "KB-001",
worktree: null, worktree: null,
}); });
const res = await GET(buildApp(), "/api/tasks/KB-001/files/image.png"); const res = await GET(buildApp(), "/api/tasks/KB-001/files/image.png");
expect([415, 404, 500]).toContain(res.status); // Binary files are now allowed; returns 404 if file doesn't exist
expect(res.status).toBe(404);
}); });
it("rejects path traversal attempts", async () => { it("rejects path traversal attempts", async () => {