From 068cbfe32b5b787d43bb091c4f95750e0abd6491 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Tue, 21 Jul 2026 08:38:39 +0000 Subject: [PATCH] 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 --- .../components/TaskRunnerDialog.tsx | 8 ++- .../components/TaskRunnerModal.tsx | 62 +++++++++++++++---- .../FileBrowserApp/useFileBrowserApp.ts | 10 ++- 3 files changed, 66 insertions(+), 14 deletions(-) diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/TaskRunnerDialog.tsx b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/TaskRunnerDialog.tsx index 7b5376c9..3c9711fd 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/TaskRunnerDialog.tsx +++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/TaskRunnerDialog.tsx @@ -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 ( ); }; diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/TaskRunnerModal.tsx b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/TaskRunnerModal.tsx index b356cbd7..6be117b4 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/TaskRunnerModal.tsx +++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/TaskRunnerModal.tsx @@ -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 (
+ {selectedNames && selectedNames.length > 1 && ( +
+ Running on {selectedNames.length} selected items +
+ )} {entryType === 'directory' && folder && !probing && } {inputDefs && ( { +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 = {}; - if (entryFullPath) autoInputs.file_path = entryFullPath; + if (multi && folderFullPath) autoInputs.file_path = folderFullPath; + else if (entryFullPath) autoInputs.file_path = entryFullPath; return ( @@ -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} /> ) : ( (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) => {