file item's row menu keyed (80 -> 67)

The single row menu shared by the dropdown and the context-menu variants: open,
edit, paste-into, read aloud, extract, run task/agent, cut/copy/duplicate/rename,
compress, copy path, download, pin, chat, create dashboard, delete.

Four strings the scanner cannot see, converted anyway since the file was open:
"Open" sits after a conditional icon expression (`{cond ? <A/> : <B/>}`) rather than
directly after a tag's `>`, which is outside the jsx-text pattern's reach. "Cut" is
three characters and the pattern requires four. "Open in editor"/"(too large)" and
"Compress"/"Compress selection" are both ternaries, which the scanner's own docs
already say it cannot see.

formatDate() called toLocaleDateString(undefined, {...}) — the browser-locale
contradiction D7 describes, same shape as ApiKeys and DavAppPasswords in phase 2.
Replaced with useFormat().date, threaded from the exported FileItem component since
the old helper was module-level and a hook cannot be called from one. Verified the
two locales now genuinely differ: "14 Nov 2023" against "14/11/2023", not "whatever
the browser happens to be set to".

task.name, agent.name and group.category stay untouched — content, not chrome.

Reused files.menu.copyPath, .createDashboardHere and .download from the previous
commit rather than adding duplicates for the same words.
This commit is contained in:
2026-08-23 16:16:41 +00:00
parent 3565cc6ac6
commit 8148023a1e
3 changed files with 62 additions and 30 deletions
@@ -50,6 +50,8 @@ import type { DirEntry } from '../../../../hooks/useFilesAPI';
import type { TaskGroup, TaskSummary } from '../../useTasks';
import type { AgentGroup, AgentSummary } from '../../useAgents';
import { getFileType } from '../../../FileViewer';
import { useTranslation } from '../../../../locales';
import { useFormat } from 'i18n';
export type FileItemProps = {
entry: DirEntry;
@@ -91,14 +93,6 @@ function formatSize(bytes: number): string {
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function formatDate(ms: number): string {
return new Date(ms).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
});
}
/**
* Extensions that reach `getFileType`'s `text` fallback but are not text.
*
@@ -232,6 +226,7 @@ const MenuItems = ({
onRunAgent,
onCreateDashboard,
}: MenuItemsProps & { ui: MenuPrimitives }) => {
const { t } = useTranslation();
const isDir = entry.type === 'directory';
const fileType = entry.type === 'file' ? getFileType(entry.name) : null;
// Same binary guard as the editor. `text` is the FALLBACK type, so without this Read Aloud offered to
@@ -268,7 +263,7 @@ const MenuItems = ({
puts first. */}
<Item onClick={() => onOpen(entry)} className="cursor-pointer">
{isDir ? <FolderOpen className="mr-2 h-4 w-4" /> : <ExternalLink className="mr-2 h-4 w-4" />}
Open
{t('files.menu.open')}
</Item>
{showEdit && (
// The first place `disabled` is used rather than hiding. `/file-browser/read` refuses anything
@@ -281,7 +276,7 @@ const MenuItems = ({
className={tooBigToEdit ? '' : 'cursor-pointer'}
>
<Code className="mr-2 h-4 w-4" />
{tooBigToEdit ? 'Open in editor (too large)' : 'Open in editor'}
{tooBigToEdit ? t('files.menu.openInEditorTooLarge') : t('files.menu.openInEditor')}
</Item>
)}
{/* Paste INTO a folder. The background menu could only paste into the folder already open, so a
@@ -289,27 +284,27 @@ const MenuItems = ({
{isDir && canPaste && (
<Item onClick={() => onPasteInto(entry)} className="cursor-pointer">
<ClipboardPaste className="mr-2 h-4 w-4" />
Paste into folder
{t('files.menu.pasteIntoFolder')}
</Item>
)}
<Separator />
{showReadAloud && (
<Item onClick={() => onReadAloud(entry)} className="cursor-pointer">
<Volume2 className="mr-2 h-4 w-4" />
Read Aloud
{t('files.menu.readAloud')}
</Item>
)}
{showExtract && (
<Item onClick={() => onExtract(entry)} className="cursor-pointer">
<FolderArchive className="mr-2 h-4 w-4" />
Extract
{t('files.menu.extract')}
</Item>
)}
{hasTasks && (
<Sub>
<SubTrigger className="cursor-pointer">
<Play className="mr-2 h-4 w-4" />
Run Task
{t('files.menu.runTask')}
</SubTrigger>
<SubContent className="z-[600]">
{nestTasks
@@ -337,7 +332,7 @@ const MenuItems = ({
<Sub>
<SubTrigger className="cursor-pointer">
<Bot className="mr-2 h-4 w-4" />
Run Agent
{t('files.menu.runAgent')}
</SubTrigger>
<SubContent className="z-[600]">
{nestAgents
@@ -364,58 +359,58 @@ const MenuItems = ({
{hasActions && <Separator />}
<Item onClick={onCut} className="cursor-pointer">
<Scissors className="mr-2 h-4 w-4" />
Cut
{t('files.menu.cut')}
</Item>
<Item onClick={onCopy} className="cursor-pointer">
<Copy className="mr-2 h-4 w-4" />
Copy
{t('files.menu.copy')}
</Item>
{!multiSelected && (
<Item onClick={() => onDuplicate(entry)} className="cursor-pointer">
<CopyPlus className="mr-2 h-4 w-4" />
Duplicate
{t('files.menu.duplicate')}
</Item>
)}
{!multiSelected && (
<Item onClick={onStartRename} className="cursor-pointer">
<Pencil className="mr-2 h-4 w-4" />
Rename
{t('files.menu.rename')}
</Item>
)}
{/* Compress, the missing half of Extract. `/download` already shelled out to zip but streamed the
result away; nothing could make an archive and keep it. */}
<Item onClick={() => onCompress(entry)} className="cursor-pointer">
<FileArchive className="mr-2 h-4 w-4" />
{multiSelected ? 'Compress selection' : 'Compress'}
{multiSelected ? t('files.menu.compressSelection') : t('files.menu.compress')}
</Item>
<Item onClick={() => onCopyPath(entry)} className="cursor-pointer">
<ClipboardCopy className="mr-2 h-4 w-4" />
Copy path
{t('files.menu.copyPath')}
</Item>
<Item onClick={() => onDownload(entry)} className="cursor-pointer">
<Download className="mr-2 h-4 w-4" />
Download
{t('files.menu.download')}
</Item>
<Separator />
{/* Pinning lived only in the widget, which is not where you meet a file worth keeping to hand. */}
<Item onClick={() => onTogglePin(entry)} className="cursor-pointer">
{isPinned ? <PinOff className="mr-2 h-4 w-4" /> : <Pin className="mr-2 h-4 w-4" />}
{isPinned ? 'Unpin' : 'Pin'}
{isPinned ? t('files.menu.unpin') : t('files.menu.pin')}
</Item>
<Item onClick={() => onChat(entry)} className="cursor-pointer">
<MessageSquare className="mr-2 h-4 w-4" />
Chat...
{t('files.menu.chat')}
</Item>
{isDir && (
<Item onClick={() => onCreateDashboard(entry)} className="cursor-pointer">
<LayoutGrid className="mr-2 h-4 w-4" />
Create Dashboard here
{t('files.menu.createDashboardHere')}
</Item>
)}
<Separator />
<Item onClick={() => onDelete(entry)} className="text-destructive cursor-pointer">
<Trash2 className="mr-2 h-4 w-4" />
Delete
{t('files.menu.delete')}
</Item>
</>
);
@@ -593,6 +588,7 @@ export const FileItem = ({
isPinned,
canPaste,
}: FileItemProps) => {
const { date } = useFormat();
const [renaming, setRenaming] = useState(false);
const clickTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const isDir = entry.type === 'directory';
@@ -730,7 +726,7 @@ export const FileItem = ({
{isDir ? '--' : formatSize(entry.size)}
</span>
<span className="hidden md:inline text-xs text-duck-dark/50 shrink-0 w-28 text-right">
{formatDate(entry.modifiedAt)}
{date(entry.modifiedAt, { month: 'short', day: 'numeric', year: 'numeric' })}
</span>
<div className="file-item-ellipsis opacity-0 group-hover:opacity-100 data-[state=open]:opacity-100 transition-opacity shrink-0">
<EllipsisMenu {...menuProps} onEnsureSelected={ensureSelected} />
@@ -780,7 +776,7 @@ export const FileItem = ({
<span className="text-xs text-duck-dark/50">
{isDir ? 'Folder' : formatSize(entry.size)}
{' · '}
{formatDate(entry.modifiedAt)}
{date(entry.modifiedAt, { month: 'short', day: 'numeric', year: 'numeric' })}
</span>
</div>
</ContextMenuTrigger>
@@ -503,7 +503,25 @@
"openFolder": "Open folder",
"showLocation": "Show location",
"download": "Download",
"noResults": "No results found"
"noResults": "No results found",
"open": "Open",
"openInEditor": "Open in editor",
"openInEditorTooLarge": "Open in editor (too large)",
"pasteIntoFolder": "Paste into folder",
"readAloud": "Read Aloud",
"extract": "Extract",
"runTask": "Run Task",
"runAgent": "Run Agent",
"cut": "Cut",
"copy": "Copy",
"duplicate": "Duplicate",
"rename": "Rename",
"compress": "Compress",
"compressSelection": "Compress selection",
"unpin": "Unpin",
"pin": "Pin",
"chat": "Chat...",
"delete": "Delete"
}
},
"taskRunner": {
@@ -503,7 +503,25 @@
"openFolder": "Abrir pasta",
"showLocation": "Mostrar localização",
"download": "Descarregar",
"noResults": "Nenhum resultado encontrado"
"noResults": "Nenhum resultado encontrado",
"open": "Abrir",
"openInEditor": "Abrir no editor",
"openInEditorTooLarge": "Abrir no editor (demasiado grande)",
"pasteIntoFolder": "Colar na pasta",
"readAloud": "Ler em voz alta",
"extract": "Extrair",
"runTask": "Executar tarefa",
"runAgent": "Executar agente",
"cut": "Cortar",
"copy": "Copiar",
"duplicate": "Duplicar",
"rename": "Renomear",
"compress": "Comprimir",
"compressSelection": "Comprimir seleção",
"unpin": "Remover fixação",
"pin": "Fixar",
"chat": "Chat...",
"delete": "Eliminar"
}
},
"taskRunner": {