From b72c897c976617f7fabd4e65390fc9020cd69dcc Mon Sep 17 00:00:00 2001 From: Tim Sinaeve Date: Sun, 19 Oct 2025 17:26:10 +0200 Subject: [PATCH 1/2] Allow cancelling running task processes --- App.tsx | 24 +++++++- components/Dashboard.tsx | 2 + components/RepositoryCard.tsx | 15 +++++ components/TaskLogPanel.tsx | 15 ++++- electron/electron.d.ts | 5 +- electron/main.ts | 101 +++++++++++++++++++++++++++++++--- electron/preload.ts | 7 ++- hooks/useRepositoryManager.ts | 64 ++++++++++++++++----- 8 files changed, 206 insertions(+), 27 deletions(-) diff --git a/App.tsx b/App.tsx index 2f9fa47..e33d536 100644 --- a/App.tsx +++ b/App.tsx @@ -162,6 +162,7 @@ const App: React.FC = () => { logs, clearLogs, isProcessing, + cancelTask, } = useRepositoryManager({ repositories, updateRepository }); const [repoFormState, setRepoFormState] = useState<{ @@ -968,6 +969,25 @@ const App: React.FC = () => { }); }, [clearLogs]); + const handleCancelTask = useCallback((repoId: string) => { + const repo = repositories.find(r => r.id === repoId); + if (!repo) { + setToast({ message: 'Repository not found.', type: 'error' }); + return; + } + + const cancelResult = cancelTask(repoId); + if (cancelResult === 'requested') { + logger.warn('Cancellation requested for running task', { repoId, name: repo.name }); + instrumentation?.trace('task:run-cancel-requested', { repoId, repoName: repo.name }); + setToast({ message: 'Cancellation requested. Attempting to stop task...', type: 'info' }); + } else if (cancelResult === 'no-step') { + setToast({ message: 'No running task to cancel.', type: 'info' }); + } else { + setToast({ message: 'Task cancellation is not supported in this environment.', type: 'error' }); + } + }, [repositories, cancelTask, logger, instrumentation]); + const handleRunTask = useCallback(async (repoId: string, taskId: string) => { const repo = repositories.find(r => r.id === repoId); const task = repo?.tasks.find(t => t.id === taskId); @@ -1433,8 +1453,9 @@ const App: React.FC = () => { onToggleCategoryCollapse={toggleCategoryCollapse} onMoveCategory={moveCategory} onReorderCategories={reorderCategories} - onOpenTaskSelection={handleOpenTaskSelection} + onOpenTaskSelection={handleOpenTaskSelection} onRunTask={handleRunTask} + onCancelTask={handleCancelTask} onViewLogs={handleViewLogs} onViewHistory={handleViewHistory} onOpenRepoForm={handleOpenRepoForm} @@ -1477,6 +1498,7 @@ const App: React.FC = () => { height={taskLogState.height} setHeight={(h) => setTaskLogState(p => ({ ...p, height: h }))} isProcessing={isProcessing} + onCancelTask={handleCancelTask} /> )} diff --git a/components/Dashboard.tsx b/components/Dashboard.tsx index a957d45..47abce3 100644 --- a/components/Dashboard.tsx +++ b/components/Dashboard.tsx @@ -25,6 +25,7 @@ interface DashboardProps { onOpenRepoForm: (repoId: string | 'new', categoryId?: string) => void; onOpenTaskSelection: (repoId: string) => void; onRunTask: (repoId: string, taskId: string) => void; + onCancelTask: (repoId: string) => void; onViewLogs: (repoId: string) => void; onViewHistory: (repoId: string) => void; onDeleteRepo: (repoId: string) => void; @@ -225,6 +226,7 @@ const Dashboard: React.FC = (props) => { onEditRepo={(repoId) => props.onOpenRepoForm(repoId)} onOpenTaskSelection={props.onOpenTaskSelection} onRunTask={props.onRunTask} + onCancelTask={props.onCancelTask} onViewLogs={props.onViewLogs} onViewHistory={props.onViewHistory} onDeleteRepo={props.onDeleteRepo} diff --git a/components/RepositoryCard.tsx b/components/RepositoryCard.tsx index a90294d..d710106 100644 --- a/components/RepositoryCard.tsx +++ b/components/RepositoryCard.tsx @@ -37,6 +37,7 @@ interface RepositoryCardProps { categoryId: string | 'uncategorized'; onOpenTaskSelection: (repoId: string) => void; onRunTask: (repoId: string, taskId: string) => void; + onCancelTask: (repoId: string) => void; onViewLogs: (repoId: string) => void; onViewHistory: (repoId: string) => void; onEditRepo: (repoId: string) => void; @@ -500,6 +501,7 @@ const RepositoryCard: React.FC = ({ categoryId, onOpenTaskSelection, onRunTask, + onCancelTask, onViewLogs, onViewHistory, onEditRepo, @@ -562,6 +564,7 @@ const RepositoryCard: React.FC = ({ const logsTooltip = useTooltip('View Logs'); const configureTooltip = useTooltip('Configure Repository'); const deleteTooltip = useTooltip('Delete Repository'); + const cancelTooltip = useTooltip('Cancel running task'); const refreshTooltip = useTooltip('Refresh Status'); const moveUpTooltip = useTooltip('Move Up'); const moveDownTooltip = useTooltip('Move Down'); @@ -759,6 +762,18 @@ const RepositoryCard: React.FC = ({
+ {isProcessing && ( + + )} {isPathValid && hasMoreTasks && (
+ {canCancelSelectedRepo && selectedRepoId && ( + + )}
void; + cancelTaskExecution: (args: { executionId: string }) => void; onTaskLog: ( callback: (event: IpcRendererEvent, data: { executionId: string; message: string; level: LogLevel }) => void @@ -84,10 +85,10 @@ export interface IElectronAPI { ) => void; onTaskStepEnd: ( - callback: (event: IpcRendererEvent, data: { executionId: string; exitCode: number }) => void + callback: (event: IpcRendererEvent, data: { executionId: string; exitCode: number; cancelled?: boolean }) => void ) => void; removeTaskStepEndListener: ( - callback: (event: IpcRendererEvent, data: { executionId: string; exitCode: number }) => void + callback: (event: IpcRendererEvent, data: { executionId: string; exitCode: number; cancelled?: boolean }) => void ) => void; // Debug Logging (Main -> Renderer) diff --git a/electron/main.ts b/electron/main.ts index cbc0bc1..3927403 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -4,6 +4,7 @@ import path, { dirname } from 'path'; import fs from 'fs/promises'; import os, { platform } from 'os'; import { spawn, exec, execFile } from 'child_process'; +import type { ChildProcess } from 'child_process'; import type { Repository, Task, TaskStep, TaskVariable, GlobalSettings, ProjectSuggestion, LocalPathState, DetailedStatus, VcsFileStatus, Commit, BranchInfo, DebugLogEntry, VcsType, PythonCapabilities, ProjectInfo, DelphiCapabilities, DelphiProject, NodejsCapabilities, LazarusCapabilities, LazarusProject, Category, AppDataContextState, ReleaseInfo, DockerCapabilities, CommitDiffFile, GoCapabilities, RustCapabilities, MavenCapabilities, DotnetCapabilities } from '../types'; import { TaskStepType, LogLevel, VcsType as VcsTypeEnum } from '../types'; import fsSync from 'fs'; @@ -70,6 +71,8 @@ const migrateSettingsIfNeeded = async () => { let mainWindow: BrowserWindow | null = null; let logStream: fsSync.WriteStream | null = null; const taskLogStreams = new Map(); +const runningProcesses = new Map(); +const cancelledExecutions = new Set(); type DownloadedUpdateValidation = { version: string; @@ -1856,6 +1859,70 @@ const sanitizeFilename = (name: string): string => { return name.replace(/[^a-z0-9_.-]/gi, '_').substring(0, 100); }; +const registerChildProcess = (executionId: string, child: ChildProcess) => { + runningProcesses.set(executionId, child); + const cleanup = () => { + if (runningProcesses.get(executionId) === child) { + runningProcesses.delete(executionId); + } + }; + child.once('close', cleanup); + child.once('exit', cleanup); + child.once('error', cleanup); +}; + +ipcMain.on('cancel-task-execution', (event, { executionId }: { executionId: string }) => { + const sender = mainWindow?.webContents.send.bind(mainWindow.webContents); + if (!sender) return; + + const child = runningProcesses.get(executionId); + if (!child) { + sender('task-log', { executionId, message: 'No running process found for cancellation request.', level: LogLevel.Warn }); + sender('task-step-end', { executionId, exitCode: 0, cancelled: true }); + return; + } + + cancelledExecutions.add(executionId); + sender('task-log', { executionId, message: 'Cancellation requested. Attempting to terminate running process...', level: LogLevel.Warn }); + + const terminationSent = child.kill('SIGTERM'); + if (terminationSent) { + sender('task-log', { executionId, message: 'Termination signal sent to process.', level: LogLevel.Info }); + return; + } + + const pid = child.pid; + if (!pid) { + cancelledExecutions.delete(executionId); + sender('task-log', { executionId, message: 'Unable to terminate process: missing PID.', level: LogLevel.Error }); + return; + } + + if (os.platform() === 'win32') { + exec(`taskkill /PID ${pid} /T /F`, (error) => { + if (error) { + cancelledExecutions.delete(executionId); + mainLogger.error('Failed to force terminate process', { executionId, error: error.message }); + sender('task-log', { executionId, message: `Failed to terminate process: ${error.message}`, level: LogLevel.Error }); + } else { + sender('task-log', { executionId, message: 'Force termination command sent to process tree.', level: LogLevel.Info }); + } + }); + } else { + try { + const hardKillSent = child.kill('SIGKILL'); + if (!hardKillSent) { + throw new Error('Unable to deliver SIGKILL to process.'); + } + sender('task-log', { executionId, message: 'Force termination signal sent to process.', level: LogLevel.Info }); + } catch (killError: any) { + cancelledExecutions.delete(executionId); + mainLogger.error('Failed to force terminate process', { executionId, error: killError.message }); + sender('task-log', { executionId, message: `Failed to terminate process: ${killError.message}`, level: LogLevel.Error }); + } + } +}); + // --- IPC handler for cloning a repo --- ipcMain.on('clone-repository', async (event, { repo, executionId }: { repo: Repository, executionId: string }) => { @@ -1888,8 +1955,8 @@ ipcMain.on('clone-repository', async (event, { repo, executionId }: { repo: Repo const sendLog = (message: string, level: LogLevel) => { sender('task-log', { executionId, message, level }); }; - const sendEnd = (exitCode: number) => { - sender('task-step-end', { executionId, exitCode }); + const sendEnd = (exitCode: number, options: { cancelled?: boolean } = {}) => { + sender('task-step-end', { executionId, exitCode, ...options }); }; let command: string; @@ -1919,6 +1986,7 @@ ipcMain.on('clone-repository', async (event, { repo, executionId }: { repo: Repo cwd: parentDir, shell: os.platform() === 'win32', }); + registerChildProcess(executionId, child); const stdoutLogger = createLineLogger(executionId, LogLevel.Info, sender); const stderrLogger = createLineLogger(executionId, LogLevel.Info, sender); @@ -1929,6 +1997,12 @@ ipcMain.on('clone-repository', async (event, { repo, executionId }: { repo: Repo child.on('close', (code) => { stdoutLogger.flush(); stderrLogger.flush(); + if (cancelledExecutions.has(executionId)) { + cancelledExecutions.delete(executionId); + sendLog(`${verb || 'Clone'} command was cancelled by user.`, LogLevel.Warn); + sendEnd(0, { cancelled: true }); + return; + } if (code !== 0) { sendLog(`${verb} command exited with code ${code}`, LogLevel.Error); } else { @@ -2006,8 +2080,9 @@ const substituteVariables = (command: string, variables: TaskVariable[] = []): s function executeCommand(cwd: string, fullCommand: string, sender: (channel: string, ...args: any[]) => void, executionId: string, env: { [key: string]: string | undefined }): Promise { return new Promise((resolve, reject) => { sender('task-log', { executionId, message: `$ ${fullCommand}`, level: LogLevel.Command }); - + const child = spawn(fullCommand, [], { cwd, shell: true, env }); + registerChildProcess(executionId, child); const stdoutLogger = createLineLogger(executionId, LogLevel.Info, sender); const stderrLogger = createLineLogger(executionId, LogLevel.Info, sender); @@ -2015,10 +2090,15 @@ function executeCommand(cwd: string, fullCommand: string, sender: (channel: stri child.stdout.on('data', stdoutLogger.process); child.stderr.on('data', stderrLogger.process); child.on('error', (err) => sender('task-log', { executionId, message: `Spawn error: ${err.message}`, level: LogLevel.Error })); - + child.on('close', (code) => { stdoutLogger.flush(); stderrLogger.flush(); + if (cancelledExecutions.has(executionId)) { + cancelledExecutions.delete(executionId); + reject(new Error('cancelled')); + return; + } if (code !== 0) { sender('task-log', { executionId, message: `Command exited with code ${code}`, level: LogLevel.Error }); reject(code ?? 1); @@ -2061,8 +2141,8 @@ ipcMain.on('run-task-step', async (event, { repo, step, settings, executionId, t const sendLog = (message: string, level: LogLevel) => { sender('task-log', { executionId, message, level }); }; - const sendEnd = (exitCode: number) => { - sender('task-step-end', { executionId, exitCode }); + const sendEnd = (exitCode: number, options: { cancelled?: boolean } = {}) => { + sender('task-step-end', { executionId, exitCode, ...options }); }; try { @@ -2345,8 +2425,13 @@ ipcMain.on('run-task-step', async (event, { repo, step, settings, executionId, t sendEnd(0); } catch (error: any) { - sendLog(`Error during step '${step.type}': ${error.message}`, LogLevel.Error); - sendEnd(typeof error === 'number' ? error : 1); + if (error?.message === 'cancelled') { + sendLog(`Step '${step.type}' was cancelled by user.`, LogLevel.Warn); + sendEnd(0, { cancelled: true }); + } else { + sendLog(`Error during step '${step.type}': ${error.message}`, LogLevel.Error); + sendEnd(typeof error === 'number' ? error : 1); + } } }); diff --git a/electron/preload.ts b/electron/preload.ts index 1731a5c..40614da 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -81,6 +81,9 @@ contextBridge.exposeInMainWorld('electronAPI', { runTaskStep: (args: { repo: Repository; step: TaskStep; settings: GlobalSettings; executionId: string; task: Task; }) => { ipcRenderer.send('run-task-step', args); }, + cancelTaskExecution: (args: { executionId: string }) => { + ipcRenderer.send('cancel-task-execution', args); + }, onTaskLog: (callback: (event: IpcRendererEvent, data: { executionId: string, message: string, level: LogLevel}) => void) => { ipcRenderer.on(taskLogChannel, callback); @@ -89,10 +92,10 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.removeListener(taskLogChannel, callback); }, - onTaskStepEnd: (callback: (event: IpcRendererEvent, data: { executionId: string, exitCode: number }) => void) => { + onTaskStepEnd: (callback: (event: IpcRendererEvent, data: { executionId: string, exitCode: number, cancelled?: boolean }) => void) => { ipcRenderer.on(taskStepEndChannel, callback); }, - removeTaskStepEndListener: (callback: (event: IpcRendererEvent, data: { executionId: string, exitCode: number }) => void) => { + removeTaskStepEndListener: (callback: (event: IpcRendererEvent, data: { executionId: string, exitCode: number, cancelled?: boolean }) => void) => { ipcRenderer.removeListener(taskStepEndChannel, callback); }, diff --git a/hooks/useRepositoryManager.ts b/hooks/useRepositoryManager.ts index 32996fe..f4251ff 100644 --- a/hooks/useRepositoryManager.ts +++ b/hooks/useRepositoryManager.ts @@ -1,5 +1,5 @@ -import { useState, useCallback } from 'react'; +import { useState, useCallback, useRef } from 'react'; import type { Repository, LogEntry, Task, GlobalSettings, TaskStep, GitRepository } from '../types'; import { RepoStatus, BuildHealth, LogLevel, TaskStepType, VcsType } from '../types'; @@ -36,6 +36,7 @@ const substituteVariables = (command: string, variables: Task['variables'] = []) export const useRepositoryManager = ({ repositories, updateRepository }: { repositories: Repository[], updateRepository: (repo: Repository) => void }) => { const [logs, setLogs] = useState>({}); const [isProcessing, setIsProcessing] = useState>(new Set()); + const activeExecutionsRef = useRef>(new Map()); const addLogEntry = useCallback((repoId: string, message: string, level: LogLevel) => { const newEntry: LogEntry = { @@ -70,7 +71,17 @@ export const useRepositoryManager = ({ repositories, updateRepository }: { repos const taskExecutionId = `exec_${repo.id}_${task.id}_${Date.now()}`; const { id: repoId } = repo; setIsProcessing(prev => new Set(prev).add(repoId)); - + activeExecutionsRef.current.set(repoId, { stepExecutionId: null }); + + const runTrackedStep = async (stepToRun: TaskStep, executionId: string) => { + activeExecutionsRef.current.set(repoId, { stepExecutionId: executionId }); + try { + await runRealStep(repo, stepToRun, settings, addLogEntry, executionId, task); + } finally { + activeExecutionsRef.current.set(repoId, { stepExecutionId: null }); + } + }; + try { updateRepoStatus(repoId, RepoStatus.Syncing); addLogEntry(repoId, `Starting task: '${task.name}'...`, LogLevel.Info); @@ -118,26 +129,31 @@ export const useRepositoryManager = ({ repositories, updateRepository }: { repos } else if (choice === 'stash') { addLogEntry(repoId, 'Stashing changes...', LogLevel.Info); const stashExecutionId = `${stepExecutionId}_stash`; - await runRealStep(repo, {id: 'stash_step', type: TaskStepType.GitStash, enabled: true}, settings, addLogEntry, stashExecutionId, task); + await runTrackedStep({id: 'stash_step', type: TaskStepType.GitStash, enabled: true}, stashExecutionId); } // if 'force' or 'ignored_and_continue', proceed as normal } } } - await runRealStep(repo, stepToRun, settings, addLogEntry, stepExecutionId, task); + await runTrackedStep(stepToRun, stepExecutionId); } } addLogEntry(repoId, `Task '${task.name}' completed successfully.`, LogLevel.Success); updateRepoStatus(repoId, RepoStatus.Success, BuildHealth.Healthy); } catch (error: any) { - if (error.message === 'cancelled') throw error; + if (error.message === 'cancelled') { + addLogEntry(repoId, `Task '${task.name}' was cancelled by user.`, LogLevel.Warn); + updateRepoStatus(repoId, RepoStatus.Idle); + throw error; + } const errorMessage = error.message || 'An unknown error occurred.'; addLogEntry(repoId, `Error during task '${task.name}': ${errorMessage}`, LogLevel.Error); addLogEntry(repoId, 'Task failed.', LogLevel.Error); updateRepoStatus(repoId, RepoStatus.Failed, BuildHealth.Failing); throw error; } finally { + activeExecutionsRef.current.delete(repoId); setIsProcessing(prev => { const newSet = new Set(prev); newSet.delete(repoId); @@ -150,7 +166,7 @@ export const useRepositoryManager = ({ repositories, updateRepository }: { repos const executionId = `clone_${repo.id}_${Date.now()}`; const { id: repoId } = repo; setIsProcessing(prev => new Set(prev).add(repoId)); - + try { updateRepoStatus(repoId, RepoStatus.Syncing); addLogEntry(repoId, `Cloning repository from '${repo.remoteUrl}'...`, LogLevel.Info); @@ -213,14 +229,32 @@ export const useRepositoryManager = ({ repositories, updateRepository }: { repos setLogs(prev => ({...prev, [repoId]: []})); }; - return { - runTask, + const cancelTask = useCallback((repoId: string): 'requested' | 'no-step' | 'unsupported' => { + const execution = activeExecutionsRef.current.get(repoId); + if (!execution || !execution.stepExecutionId) { + addLogEntry(repoId, 'No running step to cancel for this repository.', LogLevel.Warn); + return 'no-step'; + } + + if (!window.electronAPI?.cancelTaskExecution) { + addLogEntry(repoId, 'Cancellation is not supported in this environment.', LogLevel.Error); + return 'unsupported'; + } + + window.electronAPI.cancelTaskExecution({ executionId: execution.stepExecutionId }); + addLogEntry(repoId, 'Cancellation requested. Attempting to terminate running process...', LogLevel.Warn); + return 'requested'; + }, [addLogEntry]); + + return { + runTask, cloneRepository, launchApplication, launchExecutable, logs, clearLogs, - isProcessing + isProcessing, + cancelTask }; }; @@ -299,12 +333,14 @@ const runRealStep = ( } }; - const handleEnd = (_event: any, endData: { executionId: string, exitCode: number }) => { + const handleEnd = (_event: any, endData: { executionId: string, exitCode: number, cancelled?: boolean }) => { if (endData.executionId === executionId) { window.electronAPI.removeTaskLogListener(handleLog); window.electronAPI.removeTaskStepEndListener(handleEnd); - if (endData.exitCode === 0) { + if (endData.cancelled) { + reject(new Error('cancelled')); + } else if (endData.exitCode === 0) { resolve(); } else { reject(new Error(`Step failed with exit code ${endData.exitCode}. See logs for details.`)); @@ -338,11 +374,13 @@ const runClone = ( } }; - const handleEnd = (_event: any, endData: { executionId: string, exitCode: number }) => { + const handleEnd = (_event: any, endData: { executionId: string, exitCode: number, cancelled?: boolean }) => { if (endData.executionId === executionId) { window.electronAPI.removeTaskLogListener(handleLog); window.electronAPI.removeTaskStepEndListener(handleEnd); - if (endData.exitCode === 0) { + if (endData.cancelled) { + reject(new Error('cancelled')); + } else if (endData.exitCode === 0) { resolve(); } else { reject(new Error(`Clone failed with exit code ${endData.exitCode}. See logs for details.`)); From eb5f1ea1532f8fe2a585182b2939bcf591701a2c Mon Sep 17 00:00:00 2001 From: Tim Sinaeve Date: Sun, 19 Oct 2025 17:33:00 +0200 Subject: [PATCH 2/2] Import XCircleIcon for cancel button --- components/RepositoryCard.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/components/RepositoryCard.tsx b/components/RepositoryCard.tsx index d710106..2da080c 100644 --- a/components/RepositoryCard.tsx +++ b/components/RepositoryCard.tsx @@ -28,6 +28,7 @@ import { ClipboardIcon } from './icons/ClipboardIcon'; import { ArrowPathIcon } from './icons/ArrowPathIcon'; import { ArrowUpIcon } from './icons/ArrowUpIcon'; import { ArrowDownIcon } from './icons/ArrowDownIcon'; +import { XCircleIcon } from './icons/XCircleIcon'; import BranchSelectionModal from './modals/BranchSelectionModal'; import { getDisplayBranchName, getRemoteBranchesToOffer, getMainBranchDetails, normalizeBranchForComparison } from '../utils/branchHelpers';