file browser: run a task on a multi-selection

selecting several files/folders and running a task now runs it on the whole
selection instead of just the right-clicked item. reuses the folder + include
mechanism: the run targets the current folder scoped to the selected files
(and everything under selected folders). track-picker tasks scope their probe
to the selection too.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-21 08:38:39 +00:00
co-authored by Claude Opus 4.8
parent 821e7ab6c4
commit 068cbfe32b
3 changed files with 66 additions and 14 deletions
@@ -10,6 +10,10 @@ export const TaskRunnerDialog = ({ fileBrowserManager }: TaskRunnerDialogProps)
if (!runningTask) return null; if (!runningTask) return null;
const entryAbs = getEntryAbsPath(runningTask.entry.name);
// Parent folder of the entry — the batch target when a multi-selection is run.
const folderFullPath = entryAbs.replace(/\/[^/]+$/, '');
return ( return (
<TaskRunnerModal <TaskRunnerModal
open open
@@ -21,9 +25,11 @@ export const TaskRunnerDialog = ({ fileBrowserManager }: TaskRunnerDialogProps)
}} }}
task={runningTask.task} task={runningTask.task}
entryName={runningTask.entry.name} entryName={runningTask.entry.name}
entryFullPath={getEntryAbsPath(runningTask.entry.name)} entryFullPath={entryAbs}
entryType={runningTask.entry.type} entryType={runningTask.entry.type}
cwd={{ root: 'home', path: currentPath.replace(/^\//, '') }} cwd={{ root: 'home', path: currentPath.replace(/^\//, '') }}
selectedNames={runningTask.selectedNames}
folderFullPath={folderFullPath}
/> />
); );
}; };
@@ -502,9 +502,10 @@ type ScriptRunnerProps = {
cwd?: string; cwd?: string;
entryType?: 'file' | 'directory'; entryType?: 'file' | 'directory';
filePath?: string; filePath?: string;
selectedNames?: string[];
}; };
const ScriptRunner = ({ taskDirName, autoInputs, context, cwd, entryType, filePath }: ScriptRunnerProps) => { const ScriptRunner = ({ taskDirName, autoInputs, context, cwd, entryType, filePath, selectedNames }: ScriptRunnerProps) => {
const runner = useTaskRunner(); const runner = useTaskRunner();
const client = useClient(); const client = useClient();
const files = useFilesAPI('home'); const files = useFilesAPI('home');
@@ -532,6 +533,10 @@ const ScriptRunner = ({ taskDirName, autoInputs, context, cwd, entryType, filePa
} }
setFormValues(initial); setFormValues(initial);
// Multi-selection: scope the run to exactly the selected files. Even tasks without pickers
// (which return early below) need this so the batch only touches the selection.
if (selectedNames && selectedNames.length > 0) setIncludeFiles(selectedNames.join('\n'));
// Track pickers: audio defaults to keep-all, subtitles to keep-none. A folder probes every // Track pickers: audio defaults to keep-all, subtitles to keep-none. A folder probes every
// video and drives the pickers off the largest matching group (the rest convert separately). // video and drives the pickers off the largest matching group (the rest convert separately).
const hasTrackPickers = Object.values(defs).some( const hasTrackPickers = Object.values(defs).some(
@@ -559,16 +564,38 @@ const ScriptRunner = ({ taskDirName, autoInputs, context, cwd, entryType, filePa
files files
.probeFolder(filePath) .probeFolder(filePath)
.then((probe: FolderProbe) => { .then((probe: FolderProbe) => {
const majority = probe.groups[0]; // Restrict grouping to the selection when this is a multi-selection run — a file matches
// if it's selected directly, or sits under a selected folder.
const sel = selectedNames && selectedNames.length > 0 ? new Set(selectedNames) : null;
const inSel = (f: string) => {
if (sel!.has(f)) return true;
let p = f;
for (let i = p.lastIndexOf('/'); i >= 0; i = p.lastIndexOf('/')) {
p = p.slice(0, i);
if (sel!.has(p)) return true;
}
return false;
};
const groups = sel
? probe.groups
.map((g) => ({ ...g, files: g.files.filter(inSel) }))
.filter((g) => g.files.length > 0)
.map((g) => ({ ...g, count: g.files.length }))
.sort((a, b) => b.count - a.count)
: probe.groups;
const fileCount = sel ? groups.reduce((n, g) => n + g.files.length, 0) : probe.fileCount;
const majority = groups[0];
if (!majority) { if (!majority) {
setFolder({ fileCount: probe.fileCount, majorityCount: 0, skipped: [] }); setFolder({ fileCount, majorityCount: 0, skipped: [] });
return; return;
} }
seedTracks(majority.audioTracks, majority.subtitleTracks); seedTracks(majority.audioTracks, majority.subtitleTracks);
const skipped = probe.groups.slice(1).flatMap((g) => g.files); const skipped = groups.slice(1).flatMap((g) => g.files);
setFolder({ fileCount: probe.fileCount, majorityCount: majority.count, skipped }); setFolder({ fileCount, majorityCount: majority.count, skipped });
// Only pin the include list when some files are being left out; a uniform folder converts all. // Pin the include list when leaving files out — always, for a multi-selection (so it
setIncludeFiles(skipped.length > 0 ? majority.files.join('\n') : ''); // never spills onto unselected files), otherwise only on a mixed-layout folder.
setIncludeFiles(sel || skipped.length > 0 ? majority.files.join('\n') : '');
}) })
.catch(() => setFolder({ fileCount: 0, majorityCount: 0, skipped: [] })) .catch(() => setFolder({ fileCount: 0, majorityCount: 0, skipped: [] }))
.finally(() => setProbing(false)); .finally(() => setProbing(false));
@@ -619,6 +646,11 @@ const ScriptRunner = ({ taskDirName, autoInputs, context, cwd, entryType, filePa
if (runner.phase === 'ready') { if (runner.phase === 'ready') {
return ( return (
<div className="flex-1 flex flex-col"> <div className="flex-1 flex flex-col">
{selectedNames && selectedNames.length > 1 && (
<div className="px-5 py-2 border-b border-duck-dark/10 text-sm text-duck-dark/70 dark:text-foreground/70">
Running on <span className="font-medium">{selectedNames.length}</span> selected items
</div>
)}
{entryType === 'directory' && folder && !probing && <FolderSummary folder={folder} />} {entryType === 'directory' && folder && !probing && <FolderSummary folder={folder} />}
{inputDefs && ( {inputDefs && (
<TaskInputForm <TaskInputForm
@@ -1024,9 +1056,11 @@ type TaskRunnerModalProps = {
promptOverride?: string; promptOverride?: string;
description?: string; description?: string;
sandboxed?: boolean; sandboxed?: boolean;
selectedNames?: string[];
folderFullPath?: string;
}; };
export const TaskRunnerModal = ({ open, onOpenChange, task, entryName, entryFullPath, entryType, cwd = { path: '' }, promptOverride, description, sandboxed }: TaskRunnerModalProps) => { export const TaskRunnerModal = ({ open, onOpenChange, task, entryName, entryFullPath, entryType, cwd = { path: '' }, promptOverride, description, sandboxed, selectedNames, folderFullPath }: TaskRunnerModalProps) => {
const navigate = useNavigate(); const navigate = useNavigate();
const { settings } = useSettings(); const { settings } = useSettings();
const taskSettings = settings.tasks; const taskSettings = settings.tasks;
@@ -1034,6 +1068,10 @@ export const TaskRunnerModal = ({ open, onOpenChange, task, entryName, entryFull
const isScript = task.mode === 'script'; const isScript = task.mode === 'script';
const isPipeline = task.mode === 'pipeline'; const isPipeline = task.mode === 'pipeline';
// Multi-selection: run a script task on the whole current folder, scoped to the selected files.
const multi = isScript && (selectedNames?.length ?? 0) > 1;
const effectiveEntryType = multi ? 'directory' : entryType;
// Agentic mode prompt (fallback if task has no body) // Agentic mode prompt (fallback if task has no body)
const defaultInput = promptOverride const defaultInput = promptOverride
?? (entryRef && entryType ?? (entryRef && entryType
@@ -1051,7 +1089,8 @@ export const TaskRunnerModal = ({ open, onOpenChange, task, entryName, entryFull
// Script mode: auto-filled inputs from context (e.g. file_path from file browser) // Script mode: auto-filled inputs from context (e.g. file_path from file browser)
const autoInputs: Record<string, string> = {}; const autoInputs: Record<string, string> = {};
if (entryFullPath) autoInputs.file_path = entryFullPath; if (multi && folderFullPath) autoInputs.file_path = folderFullPath;
else if (entryFullPath) autoInputs.file_path = entryFullPath;
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
@@ -1093,8 +1132,9 @@ export const TaskRunnerModal = ({ open, onOpenChange, task, entryName, entryFull
autoInputs={autoInputs} autoInputs={autoInputs}
context={autofillContext} context={autofillContext}
cwd={cwd.path || undefined} cwd={cwd.path || undefined}
entryType={entryType} entryType={effectiveEntryType}
filePath={entryName ? (cwd.path ? `${cwd.path}/${entryName}` : entryName) : undefined} filePath={multi ? cwd.path || undefined : entryName ? (cwd.path ? `${cwd.path}/${entryName}` : entryName) : undefined}
selectedNames={multi ? selectedNames : undefined}
/> />
) : ( ) : (
<PiMonoInner <PiMonoInner
@@ -49,7 +49,7 @@ export const useFileBrowserApp = (
const [cloneUrl, setCloneUrl] = useState(''); const [cloneUrl, setCloneUrl] = useState('');
const [cloning, setCloning] = useState(false); const [cloning, setCloning] = useState(false);
const [dragging, setDragging] = useState(false); const [dragging, setDragging] = useState(false);
const [runningTask, setRunningTask] = useState<{ task: TaskSummary; entry: DirEntry } | null>(null); const [runningTask, setRunningTask] = useState<{ task: TaskSummary; entry: DirEntry; selectedNames?: string[] } | null>(null);
const [showVideoDownload, setShowVideoDownload] = useState(false); const [showVideoDownload, setShowVideoDownload] = useState(false);
const [videoUrl, setVideoUrl] = useState(''); const [videoUrl, setVideoUrl] = useState('');
const [audioOnly, setAudioOnly] = useState(false); const [audioOnly, setAudioOnly] = useState(false);
@@ -387,7 +387,13 @@ export const useFileBrowserApp = (
}; };
const handleRunTask = (task: TaskSummary, entry: DirEntry) => { const handleRunTask = (task: TaskSummary, entry: DirEntry) => {
setRunningTask({ task, entry }); // When several items are selected and the task is run on one of them, run it on the whole
// selection (scoped via INPUT_INCLUDE — files match by path, folders by prefix).
const names =
selected.size > 1 && selected.has(entry.name)
? visibleEntries.filter((e) => selected.has(e.name)).map((e) => e.name)
: [];
setRunningTask({ task, entry, selectedNames: names.length > 1 ? names : undefined });
}; };
const handleCreateDashboard = (entry: DirEntry) => { const handleCreateDashboard = (entry: DirEntry) => {