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:
@@ -1,5 +1,5 @@
|
||||
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 { useFileEditor } from "../hooks/useFileEditor";
|
||||
import { useProjectFileBrowser } from "../hooks/useProjectFileBrowser";
|
||||
@@ -7,6 +7,27 @@ import { useProjectFileEditor } from "../hooks/useProjectFileEditor";
|
||||
import { FileBrowser } from "./FileBrowser";
|
||||
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 = {
|
||||
taskId: string;
|
||||
worktreePath?: string;
|
||||
@@ -134,6 +155,12 @@ export function FileBrowserModal(props: FileBrowserModalProps) {
|
||||
<div className="file-browser-toolbar">
|
||||
<div className="file-browser-file-info">
|
||||
{selectedFile}
|
||||
{selectedFile && isBinaryFile(selectedFile) && (
|
||||
<span className="file-browser-binary-indicator">
|
||||
<FileType size={12} />
|
||||
Binary file — read only
|
||||
</span>
|
||||
)}
|
||||
{mtime && (
|
||||
<span className="file-browser-mtime">
|
||||
Modified: {new Date(mtime).toLocaleString()}
|
||||
@@ -176,6 +203,7 @@ export function FileBrowserModal(props: FileBrowserModalProps) {
|
||||
content={content}
|
||||
onChange={setContent}
|
||||
filePath={selectedFile}
|
||||
readOnly={selectedFile ? isBinaryFile(selectedFile) : false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -6560,6 +6560,17 @@ html .column.drag-over * {
|
||||
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 {
|
||||
font-size: 11px;
|
||||
color: var(--todo);
|
||||
|
||||
@@ -81,30 +81,6 @@ const TEXT_EXTENSIONS = new Set([
|
||||
".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.
|
||||
* 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");
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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");
|
||||
}
|
||||
|
||||
// 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 {
|
||||
const content = await fsReadFile(resolvedPath, "utf-8");
|
||||
|
||||
|
||||
@@ -3456,14 +3456,15 @@ describe("Git Management endpoints", () => {
|
||||
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({
|
||||
id: "KB-001",
|
||||
worktree: null,
|
||||
});
|
||||
|
||||
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 () => {
|
||||
|
||||
Reference in New Issue
Block a user