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 { 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>
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user