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;
|
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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+51
-11
@@ -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) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user