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:
+7
-1
@@ -10,6 +10,10 @@ export const TaskRunnerDialog = ({ fileBrowserManager }: TaskRunnerDialogProps)
|
||||
|
||||
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 (
|
||||
<TaskRunnerModal
|
||||
open
|
||||
@@ -21,9 +25,11 @@ export const TaskRunnerDialog = ({ fileBrowserManager }: TaskRunnerDialogProps)
|
||||
}}
|
||||
task={runningTask.task}
|
||||
entryName={runningTask.entry.name}
|
||||
entryFullPath={getEntryAbsPath(runningTask.entry.name)}
|
||||
entryFullPath={entryAbs}
|
||||
entryType={runningTask.entry.type}
|
||||
cwd={{ root: 'home', path: currentPath.replace(/^\//, '') }}
|
||||
selectedNames={runningTask.selectedNames}
|
||||
folderFullPath={folderFullPath}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
+51
-11
@@ -502,9 +502,10 @@ type ScriptRunnerProps = {
|
||||
cwd?: string;
|
||||
entryType?: 'file' | 'directory';
|
||||
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 client = useClient();
|
||||
const files = useFilesAPI('home');
|
||||
@@ -532,6 +533,10 @@ const ScriptRunner = ({ taskDirName, autoInputs, context, cwd, entryType, filePa
|
||||
}
|
||||
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
|
||||
// video and drives the pickers off the largest matching group (the rest convert separately).
|
||||
const hasTrackPickers = Object.values(defs).some(
|
||||
@@ -559,16 +564,38 @@ const ScriptRunner = ({ taskDirName, autoInputs, context, cwd, entryType, filePa
|
||||
files
|
||||
.probeFolder(filePath)
|
||||
.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) {
|
||||
setFolder({ fileCount: probe.fileCount, majorityCount: 0, skipped: [] });
|
||||
setFolder({ fileCount, majorityCount: 0, skipped: [] });
|
||||
return;
|
||||
}
|
||||
seedTracks(majority.audioTracks, majority.subtitleTracks);
|
||||
const skipped = probe.groups.slice(1).flatMap((g) => g.files);
|
||||
setFolder({ fileCount: probe.fileCount, majorityCount: majority.count, skipped });
|
||||
// Only pin the include list when some files are being left out; a uniform folder converts all.
|
||||
setIncludeFiles(skipped.length > 0 ? majority.files.join('\n') : '');
|
||||
const skipped = groups.slice(1).flatMap((g) => g.files);
|
||||
setFolder({ fileCount, majorityCount: majority.count, skipped });
|
||||
// Pin the include list when leaving files out — always, for a multi-selection (so it
|
||||
// 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: [] }))
|
||||
.finally(() => setProbing(false));
|
||||
@@ -619,6 +646,11 @@ const ScriptRunner = ({ taskDirName, autoInputs, context, cwd, entryType, filePa
|
||||
if (runner.phase === 'ready') {
|
||||
return (
|
||||
<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} />}
|
||||
{inputDefs && (
|
||||
<TaskInputForm
|
||||
@@ -1024,9 +1056,11 @@ type TaskRunnerModalProps = {
|
||||
promptOverride?: string;
|
||||
description?: string;
|
||||
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 { settings } = useSettings();
|
||||
const taskSettings = settings.tasks;
|
||||
@@ -1034,6 +1068,10 @@ export const TaskRunnerModal = ({ open, onOpenChange, task, entryName, entryFull
|
||||
const isScript = task.mode === 'script';
|
||||
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)
|
||||
const defaultInput = promptOverride
|
||||
?? (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)
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
@@ -1093,8 +1132,9 @@ export const TaskRunnerModal = ({ open, onOpenChange, task, entryName, entryFull
|
||||
autoInputs={autoInputs}
|
||||
context={autofillContext}
|
||||
cwd={cwd.path || undefined}
|
||||
entryType={entryType}
|
||||
filePath={entryName ? (cwd.path ? `${cwd.path}/${entryName}` : entryName) : undefined}
|
||||
entryType={effectiveEntryType}
|
||||
filePath={multi ? cwd.path || undefined : entryName ? (cwd.path ? `${cwd.path}/${entryName}` : entryName) : undefined}
|
||||
selectedNames={multi ? selectedNames : undefined}
|
||||
/>
|
||||
) : (
|
||||
<PiMonoInner
|
||||
|
||||
@@ -49,7 +49,7 @@ export const useFileBrowserApp = (
|
||||
const [cloneUrl, setCloneUrl] = useState('');
|
||||
const [cloning, setCloning] = 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 [videoUrl, setVideoUrl] = useState('');
|
||||
const [audioOnly, setAudioOnly] = useState(false);
|
||||
@@ -387,7 +387,13 @@ export const useFileBrowserApp = (
|
||||
};
|
||||
|
||||
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) => {
|
||||
|
||||
Reference in New Issue
Block a user