edit any text file, and make new ones from the empty panel
Two things, both asked for. ── Open in editor now covers text ── It was markdown and code only, because `text` is `getFileType`'s FALLBACK and so also catches binaries. That was the wrong trade: media, archives and pdf are already claimed by earlier branches, so what actually lands in the fallback is .txt, .log, .csv, .conf, dotfiles, Makefile-likes and anything with an extension the platform has never heard of. Refusing all of that to avoid one bad case cost far more than it saved. The bad case is named instead of guessed at — a ~40-entry list of extensions that reach the fallback but are not text (.exe .so .sqlite .docx .woff …). A denylist that is too short costs one bad render; a `text` test that is too strict costs the feature. Not a security control: `/file-browser/read` is UTF-8 and capped at 5 MB, so the worst outcome is mojibake. Checked against 18 names: notes.txt, server.log, data.csv, nginx.conf, .env, Makefile, script.sh, and an extensionless file all offer it; jpg, mp3, zip, pdf, exe, so, sqlite and docx all do not. ── New file, from the empty panel ── Right-click → New file takes a name WHOLE, extension included, creates it empty and opens it in the editor. The extension is what the editor uses to pick a language, so guessing one would be wrong more often than not; no extension is fine and opens as plain text. It refuses to clobber. `/write` is an overwrite, so creating over an existing name would silently empty it — the one outcome nobody wants from a menu item called "New file". There is no stat endpoint, so the check is a read that is expected to fail. `prompt()` to match New folder directly above it. Both deserve a real dialog and neither has one; making this one different would just be inconsistent. ── Verified rather than assumed ── `getActiveFile()` only matches ALREADY-OPEN files, so a path arriving in the URL could have landed on an empty editor — the exact silent failure this whole audit keeps turning up. It does not: CodeEditor.tsx:60-76 fetches and opens an unopened `?file=`, with an `unreadable` set guarding the retry loop. Confirmed /code-editor renders with `urlState`, without which the param is ignored entirely. The param is imported as `EDITOR_FILE_PARAM` rather than written as 'file' twice, so renaming it cannot leave this behind. tsgo clean, frontend builds, 808 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+66
-4
@@ -99,6 +99,61 @@ function formatDate(ms: number): string {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extensions that reach `getFileType`'s `text` fallback but are not text.
|
||||||
|
*
|
||||||
|
* Not security — the editor reads via `/file-browser/read`, which is UTF-8 and capped at 5 MB, so the
|
||||||
|
* worst case is mojibake. This is about not offering something that cannot work.
|
||||||
|
*/
|
||||||
|
const BINARY_EXTS = new Set([
|
||||||
|
'exe',
|
||||||
|
'dll',
|
||||||
|
'so',
|
||||||
|
'dylib',
|
||||||
|
'bin',
|
||||||
|
'o',
|
||||||
|
'a',
|
||||||
|
'obj',
|
||||||
|
'class',
|
||||||
|
'jar',
|
||||||
|
'wasm',
|
||||||
|
'pyc',
|
||||||
|
'pyo',
|
||||||
|
'db',
|
||||||
|
'sqlite',
|
||||||
|
'sqlite3',
|
||||||
|
'dat',
|
||||||
|
'pack',
|
||||||
|
'idx',
|
||||||
|
'iso',
|
||||||
|
'img',
|
||||||
|
'dmg',
|
||||||
|
'deb',
|
||||||
|
'rpm',
|
||||||
|
'apk',
|
||||||
|
'woff',
|
||||||
|
'woff2',
|
||||||
|
'ttf',
|
||||||
|
'otf',
|
||||||
|
'eot',
|
||||||
|
'psd',
|
||||||
|
'ai',
|
||||||
|
'sketch',
|
||||||
|
'blend',
|
||||||
|
'fbx',
|
||||||
|
'glb',
|
||||||
|
'doc',
|
||||||
|
'docx',
|
||||||
|
'xls',
|
||||||
|
'xlsx',
|
||||||
|
'ppt',
|
||||||
|
'pptx',
|
||||||
|
'odt',
|
||||||
|
'ods',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const isBinaryName = (name: string): boolean => BINARY_EXTS.has(name.split('.').pop()?.toLowerCase() ?? '');
|
||||||
|
|
||||||
type MenuPrimitives = {
|
type MenuPrimitives = {
|
||||||
Item: React.ComponentType<{ onClick?: () => void; className?: string; children: React.ReactNode }>;
|
Item: React.ComponentType<{ onClick?: () => void; className?: string; children: React.ReactNode }>;
|
||||||
Separator: React.ComponentType<Record<string, never>>;
|
Separator: React.ComponentType<Record<string, never>>;
|
||||||
@@ -172,10 +227,17 @@ const MenuItems = ({
|
|||||||
const fileType = entry.type === 'file' ? getFileType(entry.name) : null;
|
const fileType = entry.type === 'file' ? getFileType(entry.name) : null;
|
||||||
const showReadAloud = fileType === 'markdown' || fileType === 'code' || fileType === 'text';
|
const showReadAloud = fileType === 'markdown' || fileType === 'code' || fileType === 'text';
|
||||||
const showExtract = fileType === 'archive';
|
const showExtract = fileType === 'archive';
|
||||||
// Editable in the code editor. Deliberately narrower than `showReadAloud`, which uses the same set
|
// Editable in the code editor.
|
||||||
// plus the `text` FALLBACK — that fallback matches .exe and .bin, and opening those in a text editor
|
//
|
||||||
// is worse than not offering it.
|
// `text` is included, and it is `getFileType`'s FALLBACK rather than a real match — media, archives
|
||||||
const showEdit = fileType === 'markdown' || fileType === 'code';
|
// and pdf are already claimed by earlier branches, so what lands here is .txt, .log, .csv, .conf, the
|
||||||
|
// dotfiles, and anything with an extension the platform has never heard of. Being able to edit all of
|
||||||
|
// that is the point; refusing it to avoid one bad case was the wrong trade.
|
||||||
|
//
|
||||||
|
// The one bad case is a BINARY with an unrecognised extension, which the fallback also catches. Those
|
||||||
|
// are named rather than guessed at, because a denylist that is too short only ever costs a bad render,
|
||||||
|
// while a `text` test that is too strict costs the feature.
|
||||||
|
const showEdit = (fileType === 'markdown' || fileType === 'code' || fileType === 'text') && !isBinaryName(entry.name);
|
||||||
|
|
||||||
const hasTasks = taskGroups.length > 0;
|
const hasTasks = taskGroups.length > 0;
|
||||||
const hasAgents = agentGroups.length > 0;
|
const hasAgents = agentGroups.length > 0;
|
||||||
|
|||||||
+15
@@ -4,6 +4,7 @@ import {
|
|||||||
Folder,
|
Folder,
|
||||||
ClipboardPaste,
|
ClipboardPaste,
|
||||||
FolderPlus,
|
FolderPlus,
|
||||||
|
FilePlus,
|
||||||
FolderUp,
|
FolderUp,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
Upload,
|
Upload,
|
||||||
@@ -48,6 +49,7 @@ export const FileViewContainer = ({ fileBrowserManager }: FileViewContainerProps
|
|||||||
handleCopyCurrentPath,
|
handleCopyCurrentPath,
|
||||||
handleChatHere,
|
handleChatHere,
|
||||||
handleCreateDir,
|
handleCreateDir,
|
||||||
|
handleCreateFile,
|
||||||
handleCreateDashboardHere,
|
handleCreateDashboardHere,
|
||||||
setShowVideoDownload,
|
setShowVideoDownload,
|
||||||
setShowDictate,
|
setShowDictate,
|
||||||
@@ -151,6 +153,19 @@ export const FileViewContainer = ({ fileBrowserManager }: FileViewContainerProps
|
|||||||
Paste
|
Paste
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
)}
|
)}
|
||||||
|
{/* Name taken whole, extension included — the extension is what the editor uses to pick a
|
||||||
|
language, so `notes.txt`, `deploy.sh` and `.env` all do the right thing. Opens straight
|
||||||
|
into the editor: creating an empty file you then have to find is half a feature. */}
|
||||||
|
<ContextMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
const name = prompt('File name (with extension)');
|
||||||
|
if (name?.trim()) handleCreateFile(name.trim());
|
||||||
|
}}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
<FilePlus className="mr-2 h-4 w-4" />
|
||||||
|
New file
|
||||||
|
</ContextMenuItem>
|
||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const name = prompt('Folder name');
|
const name = prompt('Folder name');
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useSearchParams, useNavigate } from 'react-router';
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useFilesAPI, type DirEntry } from '../../../hooks/useFilesAPI';
|
import { useFilesAPI, type DirEntry } from '../../../hooks/useFilesAPI';
|
||||||
import { usePinnedFiles } from '../usePinnedFiles';
|
import { usePinnedFiles } from '../usePinnedFiles';
|
||||||
|
import { EDITOR_FILE_PARAM } from '../../CodeEditor/useEditorState';
|
||||||
import { useTasks, type TaskSummary } from '../useTasks';
|
import { useTasks, type TaskSummary } from '../useTasks';
|
||||||
import { useAgents, type AgentSummary } from '../useAgents';
|
import { useAgents, type AgentSummary } from '../useAgents';
|
||||||
import { useUserState } from 'state/useUserState';
|
import { useUserState } from 'state/useUserState';
|
||||||
@@ -320,6 +321,36 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an empty file and open it in the editor.
|
||||||
|
*
|
||||||
|
* The name is taken whole, extension and all — `notes.txt`, `deploy.sh`, `.env`, `Makefile` — because
|
||||||
|
* the extension is what the editor uses to pick a language, and guessing one for the user would be
|
||||||
|
* wrong more often than not. No extension is fine too; that file simply opens as plain text.
|
||||||
|
*
|
||||||
|
* Refuses to clobber. `/write` is an overwrite, so creating over an existing name would silently empty
|
||||||
|
* it — the one outcome nobody wants from a menu item called "New file". The check is a `read` because
|
||||||
|
* there is no stat endpoint; a successful read means it is there.
|
||||||
|
*/
|
||||||
|
const handleCreateFile = async (name: string) => {
|
||||||
|
const filePath = currentPath === '/' ? `/${name}` : `${currentPath}/${name}`;
|
||||||
|
try {
|
||||||
|
await files.readFile(filePath);
|
||||||
|
toast.error(`"${name}" already exists`);
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
// Not there — which is what we want.
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await files.writeFile(filePath, '');
|
||||||
|
await refresh();
|
||||||
|
toast.success(`Created "${name}"`);
|
||||||
|
navigate(`/code-editor?${EDITOR_FILE_PARAM}=${encodeURIComponent(filePath)}`);
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to create file');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleUpload = async (fileList: FileList | File[]) => {
|
const handleUpload = async (fileList: FileList | File[]) => {
|
||||||
const count = fileList.length;
|
const count = fileList.length;
|
||||||
setUploadProgress(0);
|
setUploadProgress(0);
|
||||||
@@ -577,7 +608,7 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa
|
|||||||
* can edit — there was simply no way to get there from a file. `?file=` is the editor's own param.
|
* can edit — there was simply no way to get there from a file. `?file=` is the editor's own param.
|
||||||
*/
|
*/
|
||||||
const handleOpenInEditor = (entry: DirEntry) => {
|
const handleOpenInEditor = (entry: DirEntry) => {
|
||||||
navigate(`/code-editor?file=${encodeURIComponent(entryPath(entry.name))}`);
|
navigate(`/code-editor?${EDITOR_FILE_PARAM}=${encodeURIComponent(entryPath(entry.name))}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTogglePin = (entry: DirEntry) => {
|
const handleTogglePin = (entry: DirEntry) => {
|
||||||
@@ -803,6 +834,7 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa
|
|||||||
handleNavigate,
|
handleNavigate,
|
||||||
handleOpen,
|
handleOpen,
|
||||||
handleCreateDir,
|
handleCreateDir,
|
||||||
|
handleCreateFile,
|
||||||
handleUpload,
|
handleUpload,
|
||||||
handleRename,
|
handleRename,
|
||||||
handleDelete,
|
handleDelete,
|
||||||
|
|||||||
Reference in New Issue
Block a user