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:
2026-08-15 19:15:05 +00:00
co-authored by Claude Opus 5
parent cc889a864c
commit e2b0d5c06b
3 changed files with 114 additions and 5 deletions
@@ -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 = {
Item: React.ComponentType<{ onClick?: () => void; className?: string; children: React.ReactNode }>;
Separator: React.ComponentType<Record<string, never>>;
@@ -172,10 +227,17 @@ const MenuItems = ({
const fileType = entry.type === 'file' ? getFileType(entry.name) : null;
const showReadAloud = fileType === 'markdown' || fileType === 'code' || fileType === 'text';
const showExtract = fileType === 'archive';
// Editable in the code editor. Deliberately narrower than `showReadAloud`, which uses the same set
// plus the `text` FALLBACK — that fallback matches .exe and .bin, and opening those in a text editor
// is worse than not offering it.
const showEdit = fileType === 'markdown' || fileType === 'code';
// Editable in the code editor.
//
// `text` is included, and it is `getFileType`'s FALLBACK rather than a real match — media, archives
// 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 hasAgents = agentGroups.length > 0;
@@ -4,6 +4,7 @@ import {
Folder,
ClipboardPaste,
FolderPlus,
FilePlus,
FolderUp,
LayoutGrid,
Upload,
@@ -48,6 +49,7 @@ export const FileViewContainer = ({ fileBrowserManager }: FileViewContainerProps
handleCopyCurrentPath,
handleChatHere,
handleCreateDir,
handleCreateFile,
handleCreateDashboardHere,
setShowVideoDownload,
setShowDictate,
@@ -151,6 +153,19 @@ export const FileViewContainer = ({ fileBrowserManager }: FileViewContainerProps
Paste
</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
onClick={() => {
const name = prompt('Folder name');
@@ -3,6 +3,7 @@ import { useSearchParams, useNavigate } from 'react-router';
import { toast } from 'sonner';
import { useFilesAPI, type DirEntry } from '../../../hooks/useFilesAPI';
import { usePinnedFiles } from '../usePinnedFiles';
import { EDITOR_FILE_PARAM } from '../../CodeEditor/useEditorState';
import { useTasks, type TaskSummary } from '../useTasks';
import { useAgents, type AgentSummary } from '../useAgents';
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 count = fileList.length;
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.
*/
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) => {
@@ -803,6 +834,7 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa
handleNavigate,
handleOpen,
handleCreateDir,
handleCreateFile,
handleUpload,
handleRename,
handleDelete,