group file-browser tasks into category submenus

Every task declares a directory trigger, so right-clicking any folder listed all
fifteen at once — Tag Album offered on a folder of photos. TASK.md gains an
optional `category`, and the context menu nests by it: Run Task > Video > …

Nesting only kicks in when more than one category matches. A .mp4 matches eight
tasks that are all Video, so file menus stay flat rather than gaining a pointless
hop. Known categories lead (Video, Audio, Cleanup); anything else follows
alphabetically with Other last.

Applied to both menus — the right-click one and the ⋮ dropdown — which carried
identical blocks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
brunorezio
2026-07-26 02:26:48 +01:00
co-authored by Claude Opus 5
parent 1d0842cc01
commit 6a77ec22df
4 changed files with 297 additions and 182 deletions
+6
View File
@@ -11,6 +11,7 @@ const YAML = (Bun as unknown as { YAML: { parse(input: string): unknown } }).YAM
export type TaskFrontmatter = {
name: string;
description: string | null;
category: string | null; // groups the task into a context-menu submenu; null falls back to 'Other'
version: number;
mode: string;
inline: boolean; // quick/interactive tasks run in the modal instead of as a background job
@@ -37,6 +38,7 @@ export type TaskSummary = {
dirName: string;
name: string;
description: string | null;
category: string | null;
mode: string;
inline: boolean;
version: number;
@@ -47,6 +49,7 @@ export type TaskSummary = {
const FM_KEYS = [
'name',
'description',
'category',
'version',
'mode',
'inline',
@@ -106,6 +109,7 @@ function parseTaskMd(raw: string): { fm: TaskFrontmatter; body: string } {
fm: {
name: parsed.name == null ? '' : String(parsed.name),
description: parsed.description == null ? null : String(parsed.description),
category: parsed.category == null ? null : String(parsed.category),
version,
mode: parsed.mode == null ? 'agentic' : String(parsed.mode),
inline: parsed.inline === true,
@@ -163,6 +167,7 @@ export async function listTasks(): Promise<TaskSummary[]> {
dirName: entry.name,
name: fm.name || entry.name,
description: fm.description,
category: fm.category,
mode: fm.mode,
inline: fm.inline,
version: fm.version,
@@ -209,6 +214,7 @@ export async function createTask(input: CreateTaskInput): Promise<TaskSummary &
dirName,
name,
description: input.description ?? null,
category: null,
mode,
inline: false,
version: 1,
+2
View File
@@ -12,6 +12,7 @@ tasksRouter.get('/', async (ctx) => {
dirName: row.dirName,
name: row.name,
description: row.description,
category: row.category,
triggers: (row.trigger as TriggerConfig[]) ?? [],
mode: row.mode ?? 'agentic',
}));
@@ -29,6 +30,7 @@ tasksRouter.get('/:name', async (ctx) => {
dirName: task.dirName,
name: task.name,
description: task.description,
category: task.category,
mode: task.mode,
inline: task.inline,
language: task.language,
@@ -1,5 +1,24 @@
import { useState, useEffect, useRef } from 'react';
import { Folder, Trash2, Pencil, MoreVertical, MessageSquare, Scissors, Copy, Check, Play, Download, LayoutGrid, Volume2, ScanText, FileText, AudioLines, FolderArchive, ClipboardCopy, Music } from 'lucide-react';
import {
Folder,
Trash2,
Pencil,
MoreVertical,
MessageSquare,
Scissors,
Copy,
Check,
Play,
Download,
LayoutGrid,
Volume2,
ScanText,
FileText,
AudioLines,
FolderArchive,
ClipboardCopy,
Music,
} from 'lucide-react';
import { getIcon } from 'material-file-icons';
import {
DropdownMenu,
@@ -23,7 +42,7 @@ import {
} from '@/components/ui/context-menu';
import { cardStyle } from '@/components/Card';
import type { DirEntry } from '../../../../hooks/useFilesAPI';
import type { TaskSummary } from '../../useTasks';
import { groupTasksByCategory, type TaskSummary } from '../../useTasks';
import { getFileType } from '../../../FileViewer';
export type FileItemProps = {
@@ -118,101 +137,133 @@ const DropdownMenuItems = ({
const showExtract = fileType === 'archive';
const showPlay = fileType === 'audio' || entry.type === 'directory';
const hasActions = showPlay || showReadAloud || showOcr || showTranscribe || showExtractAudio || showExtract || matchingTasks.length > 0;
const hasActions =
showPlay ||
showReadAloud ||
showOcr ||
showTranscribe ||
showExtractAudio ||
showExtract ||
matchingTasks.length > 0;
// Only nest when more than one category is present — a file usually matches a single category,
// and Run Task > Video > Convert would just add a hop.
const taskGroups = groupTasksByCategory(matchingTasks);
const nestTasks = taskGroups.length > 1;
return (
<>
{showPlay && (
<DropdownMenuItem onClick={() => onPlay(entry)} className="cursor-pointer">
<Music className="mr-2 h-4 w-4" />
Play
<>
{showPlay && (
<DropdownMenuItem onClick={() => onPlay(entry)} className="cursor-pointer">
<Music className="mr-2 h-4 w-4" />
Play
</DropdownMenuItem>
)}
{showReadAloud && (
<DropdownMenuItem onClick={() => onReadAloud(entry)} className="cursor-pointer">
<Volume2 className="mr-2 h-4 w-4" />
Read Aloud
</DropdownMenuItem>
)}
{showOcr && (
<DropdownMenuItem onClick={() => onOcr(entry)} className="cursor-pointer">
<ScanText className="mr-2 h-4 w-4" />
Extract Text (OCR)
</DropdownMenuItem>
)}
{showTranscribe && (
<DropdownMenuItem onClick={() => onTranscribe(entry)} className="cursor-pointer">
<FileText className="mr-2 h-4 w-4" />
Transcribe
</DropdownMenuItem>
)}
{showExtractAudio && (
<DropdownMenuItem onClick={() => onExtractAudio(entry)} className="cursor-pointer">
<AudioLines className="mr-2 h-4 w-4" />
Extract Audio
</DropdownMenuItem>
)}
{showExtract && (
<DropdownMenuItem onClick={() => onExtract(entry)} className="cursor-pointer">
<FolderArchive className="mr-2 h-4 w-4" />
Extract
</DropdownMenuItem>
)}
{matchingTasks.length > 0 && (
<DropdownMenuSub>
<DropdownMenuSubTrigger className="cursor-pointer">
<Play className="mr-2 h-4 w-4" />
Run Task
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="z-[600]">
{nestTasks
? taskGroups.map((group) => (
<DropdownMenuSub key={group.category}>
<DropdownMenuSubTrigger className="cursor-pointer">{group.category}</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="z-[600]">
{group.tasks.map((task) => (
<DropdownMenuItem
key={task.dirName}
onClick={() => onRunTask(task, entry)}
className="cursor-pointer"
>
{task.name}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
))
: matchingTasks.map((task) => (
<DropdownMenuItem
key={task.dirName}
onClick={() => onRunTask(task, entry)}
className="cursor-pointer"
>
{task.name}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
{hasActions && <DropdownMenuSeparator />}
<DropdownMenuItem onClick={onCut} className="cursor-pointer">
<Scissors className="mr-2 h-4 w-4" />
Cut
</DropdownMenuItem>
)}
{showReadAloud && (
<DropdownMenuItem onClick={() => onReadAloud(entry)} className="cursor-pointer">
<Volume2 className="mr-2 h-4 w-4" />
Read Aloud
<DropdownMenuItem onClick={onCopy} className="cursor-pointer">
<Copy className="mr-2 h-4 w-4" />
Copy
</DropdownMenuItem>
)}
{showOcr && (
<DropdownMenuItem onClick={() => onOcr(entry)} className="cursor-pointer">
<ScanText className="mr-2 h-4 w-4" />
Extract Text (OCR)
{!multiSelected && (
<DropdownMenuItem onClick={onStartRename} className="cursor-pointer">
<Pencil className="mr-2 h-4 w-4" />
Rename
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={() => onCopyPath(entry)} className="cursor-pointer">
<ClipboardCopy className="mr-2 h-4 w-4" />
Copy path
</DropdownMenuItem>
)}
{showTranscribe && (
<DropdownMenuItem onClick={() => onTranscribe(entry)} className="cursor-pointer">
<FileText className="mr-2 h-4 w-4" />
Transcribe
<DropdownMenuItem onClick={() => onDownload(entry)} className="cursor-pointer">
<Download className="mr-2 h-4 w-4" />
Download
</DropdownMenuItem>
)}
{showExtractAudio && (
<DropdownMenuItem onClick={() => onExtractAudio(entry)} className="cursor-pointer">
<AudioLines className="mr-2 h-4 w-4" />
Extract Audio
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onChat(entry)} className="cursor-pointer">
<MessageSquare className="mr-2 h-4 w-4" />
Chat...
</DropdownMenuItem>
)}
{showExtract && (
<DropdownMenuItem onClick={() => onExtract(entry)} className="cursor-pointer">
<FolderArchive className="mr-2 h-4 w-4" />
Extract
{entry.type === 'directory' && (
<DropdownMenuItem onClick={() => onCreateDashboard(entry)} className="cursor-pointer">
<LayoutGrid className="mr-2 h-4 w-4" />
Create Dashboard here
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onDelete(entry)} className="text-red-600 cursor-pointer">
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
)}
{matchingTasks.length > 0 && (
<DropdownMenuSub>
<DropdownMenuSubTrigger className="cursor-pointer">
<Play className="mr-2 h-4 w-4" />
Run Task
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="z-[600]">
{matchingTasks.map((task) => (
<DropdownMenuItem key={task.dirName} onClick={() => onRunTask(task, entry)} className="cursor-pointer">
{task.name}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
{hasActions && <DropdownMenuSeparator />}
<DropdownMenuItem onClick={onCut} className="cursor-pointer">
<Scissors className="mr-2 h-4 w-4" />
Cut
</DropdownMenuItem>
<DropdownMenuItem onClick={onCopy} className="cursor-pointer">
<Copy className="mr-2 h-4 w-4" />
Copy
</DropdownMenuItem>
{!multiSelected && (
<DropdownMenuItem onClick={onStartRename} className="cursor-pointer">
<Pencil className="mr-2 h-4 w-4" />
Rename
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={() => onCopyPath(entry)} className="cursor-pointer">
<ClipboardCopy className="mr-2 h-4 w-4" />
Copy path
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onDownload(entry)} className="cursor-pointer">
<Download className="mr-2 h-4 w-4" />
Download
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onChat(entry)} className="cursor-pointer">
<MessageSquare className="mr-2 h-4 w-4" />
Chat...
</DropdownMenuItem>
{entry.type === 'directory' && (
<DropdownMenuItem onClick={() => onCreateDashboard(entry)} className="cursor-pointer">
<LayoutGrid className="mr-2 h-4 w-4" />
Create Dashboard here
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onDelete(entry)} className="text-red-600 cursor-pointer">
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</>
</>
);
};
@@ -244,101 +295,129 @@ const ContextMenuItems = ({
const showExtract = fileType === 'archive';
const showPlay = fileType === 'audio' || entry.type === 'directory';
const hasActions = showPlay || showReadAloud || showOcr || showTranscribe || showExtractAudio || showExtract || matchingTasks.length > 0;
const hasActions =
showPlay ||
showReadAloud ||
showOcr ||
showTranscribe ||
showExtractAudio ||
showExtract ||
matchingTasks.length > 0;
// Only nest when more than one category is present — a file usually matches a single category,
// and Run Task > Video > Convert would just add a hop.
const taskGroups = groupTasksByCategory(matchingTasks);
const nestTasks = taskGroups.length > 1;
return (
<>
{showPlay && (
<ContextMenuItem onClick={() => onPlay(entry)} className="cursor-pointer">
<Music className="mr-2 h-4 w-4" />
Play
<>
{showPlay && (
<ContextMenuItem onClick={() => onPlay(entry)} className="cursor-pointer">
<Music className="mr-2 h-4 w-4" />
Play
</ContextMenuItem>
)}
{showReadAloud && (
<ContextMenuItem onClick={() => onReadAloud(entry)} className="cursor-pointer">
<Volume2 className="mr-2 h-4 w-4" />
Read Aloud
</ContextMenuItem>
)}
{showOcr && (
<ContextMenuItem onClick={() => onOcr(entry)} className="cursor-pointer">
<ScanText className="mr-2 h-4 w-4" />
Extract Text (OCR)
</ContextMenuItem>
)}
{showTranscribe && (
<ContextMenuItem onClick={() => onTranscribe(entry)} className="cursor-pointer">
<FileText className="mr-2 h-4 w-4" />
Transcribe
</ContextMenuItem>
)}
{showExtractAudio && (
<ContextMenuItem onClick={() => onExtractAudio(entry)} className="cursor-pointer">
<AudioLines className="mr-2 h-4 w-4" />
Extract Audio
</ContextMenuItem>
)}
{showExtract && (
<ContextMenuItem onClick={() => onExtract(entry)} className="cursor-pointer">
<FolderArchive className="mr-2 h-4 w-4" />
Extract
</ContextMenuItem>
)}
{matchingTasks.length > 0 && (
<ContextMenuSub>
<ContextMenuSubTrigger className="cursor-pointer">
<Play className="mr-2 h-4 w-4" />
Run Task
</ContextMenuSubTrigger>
<ContextMenuSubContent className="z-[600]">
{nestTasks
? taskGroups.map((group) => (
<ContextMenuSub key={group.category}>
<ContextMenuSubTrigger className="cursor-pointer">{group.category}</ContextMenuSubTrigger>
<ContextMenuSubContent className="z-[600]">
{group.tasks.map((task) => (
<ContextMenuItem
key={task.dirName}
onClick={() => onRunTask(task, entry)}
className="cursor-pointer"
>
{task.name}
</ContextMenuItem>
))}
</ContextMenuSubContent>
</ContextMenuSub>
))
: matchingTasks.map((task) => (
<ContextMenuItem key={task.dirName} onClick={() => onRunTask(task, entry)} className="cursor-pointer">
{task.name}
</ContextMenuItem>
))}
</ContextMenuSubContent>
</ContextMenuSub>
)}
{hasActions && <ContextMenuSeparator />}
<ContextMenuItem onClick={onCut} className="cursor-pointer">
<Scissors className="mr-2 h-4 w-4" />
Cut
</ContextMenuItem>
)}
{showReadAloud && (
<ContextMenuItem onClick={() => onReadAloud(entry)} className="cursor-pointer">
<Volume2 className="mr-2 h-4 w-4" />
Read Aloud
<ContextMenuItem onClick={onCopy} className="cursor-pointer">
<Copy className="mr-2 h-4 w-4" />
Copy
</ContextMenuItem>
)}
{showOcr && (
<ContextMenuItem onClick={() => onOcr(entry)} className="cursor-pointer">
<ScanText className="mr-2 h-4 w-4" />
Extract Text (OCR)
{!multiSelected && (
<ContextMenuItem onClick={onStartRename} className="cursor-pointer">
<Pencil className="mr-2 h-4 w-4" />
Rename
</ContextMenuItem>
)}
<ContextMenuItem onClick={() => onCopyPath(entry)} className="cursor-pointer">
<ClipboardCopy className="mr-2 h-4 w-4" />
Copy path
</ContextMenuItem>
)}
{showTranscribe && (
<ContextMenuItem onClick={() => onTranscribe(entry)} className="cursor-pointer">
<FileText className="mr-2 h-4 w-4" />
Transcribe
<ContextMenuItem onClick={() => onDownload(entry)} className="cursor-pointer">
<Download className="mr-2 h-4 w-4" />
Download
</ContextMenuItem>
)}
{showExtractAudio && (
<ContextMenuItem onClick={() => onExtractAudio(entry)} className="cursor-pointer">
<AudioLines className="mr-2 h-4 w-4" />
Extract Audio
<ContextMenuSeparator />
<ContextMenuItem onClick={() => onChat(entry)} className="cursor-pointer">
<MessageSquare className="mr-2 h-4 w-4" />
Chat...
</ContextMenuItem>
)}
{showExtract && (
<ContextMenuItem onClick={() => onExtract(entry)} className="cursor-pointer">
<FolderArchive className="mr-2 h-4 w-4" />
Extract
{entry.type === 'directory' && (
<ContextMenuItem onClick={() => onCreateDashboard(entry)} className="cursor-pointer">
<LayoutGrid className="mr-2 h-4 w-4" />
Create Dashboard here
</ContextMenuItem>
)}
<ContextMenuSeparator />
<ContextMenuItem onClick={() => onDelete(entry)} className="text-red-600 cursor-pointer">
<Trash2 className="mr-2 h-4 w-4" />
Delete
</ContextMenuItem>
)}
{matchingTasks.length > 0 && (
<ContextMenuSub>
<ContextMenuSubTrigger className="cursor-pointer">
<Play className="mr-2 h-4 w-4" />
Run Task
</ContextMenuSubTrigger>
<ContextMenuSubContent className="z-[600]">
{matchingTasks.map((task) => (
<ContextMenuItem key={task.dirName} onClick={() => onRunTask(task, entry)} className="cursor-pointer">
{task.name}
</ContextMenuItem>
))}
</ContextMenuSubContent>
</ContextMenuSub>
)}
{hasActions && <ContextMenuSeparator />}
<ContextMenuItem onClick={onCut} className="cursor-pointer">
<Scissors className="mr-2 h-4 w-4" />
Cut
</ContextMenuItem>
<ContextMenuItem onClick={onCopy} className="cursor-pointer">
<Copy className="mr-2 h-4 w-4" />
Copy
</ContextMenuItem>
{!multiSelected && (
<ContextMenuItem onClick={onStartRename} className="cursor-pointer">
<Pencil className="mr-2 h-4 w-4" />
Rename
</ContextMenuItem>
)}
<ContextMenuItem onClick={() => onCopyPath(entry)} className="cursor-pointer">
<ClipboardCopy className="mr-2 h-4 w-4" />
Copy path
</ContextMenuItem>
<ContextMenuItem onClick={() => onDownload(entry)} className="cursor-pointer">
<Download className="mr-2 h-4 w-4" />
Download
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={() => onChat(entry)} className="cursor-pointer">
<MessageSquare className="mr-2 h-4 w-4" />
Chat...
</ContextMenuItem>
{entry.type === 'directory' && (
<ContextMenuItem onClick={() => onCreateDashboard(entry)} className="cursor-pointer">
<LayoutGrid className="mr-2 h-4 w-4" />
Create Dashboard here
</ContextMenuItem>
)}
<ContextMenuSeparator />
<ContextMenuItem onClick={() => onDelete(entry)} className="text-red-600 cursor-pointer">
<Trash2 className="mr-2 h-4 w-4" />
Delete
</ContextMenuItem>
</>
</>
);
};
@@ -431,7 +510,9 @@ const Checkbox = ({
onClick(ev);
}}
className={`flex items-center justify-center h-5 w-5 rounded border-2 transition-all cursor-pointer ${
checked ? 'bg-duck-teal border-duck-teal text-white' : 'border-duck-dark/30 bg-background/80 hover:border-duck-teal/50'
checked
? 'bg-duck-teal border-duck-teal text-white'
: 'border-duck-dark/30 bg-background/80 hover:border-duck-teal/50'
} ${anySelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'}`}
>
{checked && <Check className="h-3 w-3" />}
@@ -614,11 +695,7 @@ export const FileItem = ({
className={`group relative rounded-lg border-2 p-4 flex flex-col items-center gap-2 hover:border-duck-teal/50 data-[state=open]:border-duck-teal/50 transition-colors cursor-pointer ${
selected ? 'border-duck-teal/50 bg-duck-teal/10' : 'border-duck-dark/20'
} ${cutOpacity} select-none`}
style={
selected
? undefined
: cardStyle()
}
style={selected ? undefined : cardStyle()}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
onPointerDown={(ev) => ev.stopPropagation()}
@@ -9,11 +9,41 @@ export type TaskSummary = {
name: string;
description: string;
scope: string;
category: string | null;
triggers: TriggerConfig[];
mode: 'script' | 'agentic' | 'pipeline';
userId: number | null;
};
export type TaskGroup = { category: string; tasks: TaskSummary[] };
const UNCATEGORIZED = 'Other';
// Every task carries a `directory` trigger, so right-clicking a folder listed all of them at once.
// Grouping by category turns that into Run Task > Video > … Known categories lead in this order;
// anything else follows alphabetically, with Other always last.
const CATEGORY_ORDER = ['Video', 'Audio', 'Cleanup'];
const categoryRank = (category: string): number => {
const known = CATEGORY_ORDER.indexOf(category);
if (known !== -1) return known;
return category === UNCATEGORIZED ? Number.MAX_SAFE_INTEGER : CATEGORY_ORDER.length;
};
export const groupTasksByCategory = (tasks: TaskSummary[]): TaskGroup[] => {
const groups = new Map<string, TaskSummary[]>();
for (const task of tasks) {
const category = task.category?.trim() || UNCATEGORIZED;
const bucket = groups.get(category);
if (bucket) bucket.push(task);
else groups.set(category, [task]);
}
return Array.from(groups.entries())
.map(([category, list]) => ({ category, tasks: [...list].sort((a, b) => a.name.localeCompare(b.name)) }))
.sort((a, b) => categoryRank(a.category) - categoryRank(b.category) || a.category.localeCompare(b.category));
};
export const useTasks = () => {
const client = useClient();