Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ const App: React.FC = () => {
logs,
clearLogs,
isProcessing,
cancelTask,
} = useRepositoryManager({ repositories, updateRepository });

const [repoFormState, setRepoFormState] = useState<{
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -1477,6 +1498,7 @@ const App: React.FC = () => {
height={taskLogState.height}
setHeight={(h) => setTaskLogState(p => ({ ...p, height: h }))}
isProcessing={isProcessing}
onCancelTask={handleCancelTask}
/>
)}
</div>
Expand Down
2 changes: 2 additions & 0 deletions components/Dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -225,6 +226,7 @@ const Dashboard: React.FC<DashboardProps> = (props) => {
onEditRepo={(repoId) => props.onOpenRepoForm(repoId)}
onOpenTaskSelection={props.onOpenTaskSelection}
onRunTask={props.onRunTask}
onCancelTask={props.onCancelTask}
onViewLogs={props.onViewLogs}
onViewHistory={props.onViewHistory}
onDeleteRepo={props.onDeleteRepo}
Expand Down
16 changes: 16 additions & 0 deletions components/RepositoryCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -37,6 +38,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;
Expand Down Expand Up @@ -500,6 +502,7 @@ const RepositoryCard: React.FC<RepositoryCardProps> = ({
categoryId,
onOpenTaskSelection,
onRunTask,
onCancelTask,
onViewLogs,
onViewHistory,
onEditRepo,
Expand Down Expand Up @@ -562,6 +565,7 @@ const RepositoryCard: React.FC<RepositoryCardProps> = ({
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');
Expand Down Expand Up @@ -759,6 +763,18 @@ const RepositoryCard: React.FC<RepositoryCardProps> = ({
</div>

<div className="flex items-center space-x-0.5">
{isProcessing && (
<button
{...cancelTooltip}
onClick={() => {
hideTooltip();
onCancelTask(id);
}}
className="p-1.5 text-red-500 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 hover:bg-red-100 dark:hover:bg-red-900/50 rounded-full transition-colors"
>
<XCircleIcon className="h-5 w-5" />
</button>
)}
{isPathValid && hasMoreTasks && (
<button
{...moreTasksTooltip}
Expand Down
15 changes: 14 additions & 1 deletion components/TaskLogPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ interface TaskLogPanelProps {
height: number;
setHeight: (height: number) => void;
isProcessing: Set<string>;
onCancelTask: (repoId: string) => void;
}

const LOG_LEVEL_STYLES: Record<LogLevel, string> = {
Expand Down Expand Up @@ -54,14 +55,15 @@ const HighlightedText: React.FC<{ text: string; highlight: string }> = ({ text,

const TaskLogPanel: React.FC<TaskLogPanelProps> = ({
onClosePanel, onCloseTab, onSelectTab, logs, allRepositories,
activeRepoIds, selectedRepoId, height, setHeight, isProcessing,
activeRepoIds, selectedRepoId, height, setHeight, isProcessing, onCancelTask,
}) => {
const logContainerRef = useRef<HTMLDivElement>(null);
const [isResizing, setIsResizing] = useState(false);
const [statusBarHeight, setStatusBarHeight] = useState(0);
const [searchQuery, setSearchQuery] = useState('');

const selectedRepo = allRepositories.find(r => r.id === selectedRepoId);
const canCancelSelectedRepo = selectedRepoId ? isProcessing.has(selectedRepoId) : false;

const filteredLogs = useMemo(() => {
const selectedLogs = selectedRepoId ? logs[selectedRepoId] || [] : [];
Expand Down Expand Up @@ -227,6 +229,17 @@ const TaskLogPanel: React.FC<TaskLogPanelProps> = ({
</div>
</div>
<div className="flex items-center gap-2 pr-2">
{canCancelSelectedRepo && selectedRepoId && (
<button
onClick={() => onCancelTask(selectedRepoId)}
className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-white bg-red-600 rounded-md hover:bg-red-700 transition-colors"
title="Cancel running task"
aria-label="Cancel running task"
>
<XCircleIcon className="h-4 w-4" />
<span className="hidden sm:inline">Cancel Task</span>
</button>
)}
<div className="relative flex-1 max-w-xs">
<MagnifyingGlassIcon className="pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
<input
Expand Down
5 changes: 3 additions & 2 deletions electron/electron.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ export interface IElectronAPI {
executionId: string;
task: Task;
}) => void;
cancelTaskExecution: (args: { executionId: string }) => void;

onTaskLog: (
callback: (event: IpcRendererEvent, data: { executionId: string; message: string; level: LogLevel }) => void
Expand All @@ -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)
Expand Down
101 changes: 93 additions & 8 deletions electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -70,6 +71,8 @@ const migrateSettingsIfNeeded = async () => {
let mainWindow: BrowserWindow | null = null;
let logStream: fsSync.WriteStream | null = null;
const taskLogStreams = new Map<string, fsSync.WriteStream>();
const runningProcesses = new Map<string, ChildProcess>();
const cancelledExecutions = new Set<string>();

type DownloadedUpdateValidation = {
version: string;
Expand Down Expand Up @@ -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 }) => {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -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 {
Expand Down Expand Up @@ -2006,19 +2080,25 @@ 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<number> {
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);

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);
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
}
}
});

Expand Down
7 changes: 5 additions & 2 deletions electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
},

Expand Down
Loading