diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileItem.tsx b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileItem.tsx index 39fbb529..b2d9d0dc 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileItem.tsx +++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileItem.tsx @@ -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>; @@ -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; diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileViewContainer.tsx b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileViewContainer.tsx index e84a9552..1f472433 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileViewContainer.tsx +++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileViewContainer.tsx @@ -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 )} + {/* 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. */} + { + const name = prompt('File name (with extension)'); + if (name?.trim()) handleCreateFile(name.trim()); + }} + className="cursor-pointer" + > + + New file + { const name = prompt('Folder name'); diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/useFileBrowserApp.ts b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/useFileBrowserApp.ts index 4fdb8c55..7a6c7596 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/useFileBrowserApp.ts +++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/useFileBrowserApp.ts @@ -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,