librelad 1b0040dbf1 refactor(tasks): decompose tasks-manager god-file into 8 responsibility files
Faithful brace-aware split of tasks-manager.js (2664->491 line base) into
list-render, data-load, log-stream, row-expand, actions, modals, logs-modal,
format — augmenting TasksManager.prototype. First removed 4 provably-dead
duplicate defs (the earlier init/setupAutoRefresh/startLogStreaming/loadTaskLogs
that the later defs already overrode — behavior-preserving). Methods relocated
verbatim via a brace-aware Node extractor (handles strings/templates/comments/
regex, fixing the line-heuristic over-capture). Verified: all 60 (deduped)
methods present exactly once, no dups, all 9 files node --check clean. Wired
after the base in the task-system loader (async=false-ordered).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-30 14:53:19 +01:00

295 lines
12 KiB
JavaScript

// Auto-extracted from tasks-manager.js (verbatim) — augments TasksManager.prototype. Loaded after the base.
Object.assign(TasksManager.prototype, {
async retryTask(taskId) {
if (!confirm('Are you sure you want to retry this task?')) return;
try {
const task = this.tasks.find(t => t.id === taskId);
if (!task) {
throw new Error('Task not found');
}
// Create a new task with the same command
const newTask = await this.taskManager.createTask(
task.command,
task.type,
task.app,
task.config
);
//// // console.log(`✅ Task retried: ${newTask.id}`);
// Refresh tasks to show the new one
await this.loadTasks();
if (window.notificationSystem) {
// Use the source task's type icon — retrying a backup shows 💾 etc.
const typeIcon = this.getTaskTypeIcon ? this.getTaskTypeIcon(task)?.icon : '';
const customIcon = typeIcon ? `<span style="font-size:18px;line-height:1;">${typeIcon}</span>` : null;
window.notificationSystem.show('Task retried successfully', 'success', null, null, null, customIcon);
}
} catch (error) {
console.error('Error retrying task:', error);
if (window.notificationSystem) {
window.notificationSystem.error(`Failed to retry task: ${error.message}`);
}
}
},
async deleteTask(taskId) {
// Get the latest known status before deciding what to do. The server
// refuses to delete tasks that are still running or queued (HTTP 409),
// and we used to just propagate that as a red banner — but the user is
// almost always trying to clean up a row that looked finished, or a
// genuinely active task they want gone. Either way the right answer is
// "cancel first, then delete", which is what we do here.
const cached = (window.taskEventBus && window.taskEventBus.getTask)
? window.taskEventBus.getTask(taskId)
: null;
let task = cached || (this.tasks && this.tasks.find(t => t.id === taskId));
if (!task && this.taskManager && this.taskManager.getTask) {
try { task = await this.taskManager.getTask(taskId); } catch {}
}
const isActive = task && (task.status === 'running' || task.status === 'queued' || task.status === 'pending');
const confirmed = await this._showDeleteTaskModal(task, isActive);
if (!confirmed) return;
try {
if (isActive) {
// Ask the server to cancel. POST /cancel either flips status straight
// to `cancelled` (queued -> cancelled is synchronous) or drops a
// `<id>.cancel` marker the bash processor picks up within ~1s
// (running -> cancelled). We then wait for the terminal status via
// SSE before issuing the delete.
try { await this.taskManager.cancelTask(taskId); } catch {
// If cancel itself failed (e.g. already terminal), fall through
// and try the delete anyway.
}
await this._waitForTaskTerminal(taskId, 15_000);
}
// Try the polite delete first. If the task is still flagged as
// running/queued server-side (e.g. the bash processor is dead and
// never picked up the cancel marker), fall back to the force-delete
// override so the user isn't stuck with a permanently-undeletable row.
try {
await this.taskManager.deleteTask(taskId);
} catch (err) {
const looks409 = /\bHTTP 409\b/.test(err && err.message ? err.message : '');
if (!looks409) throw err;
await this.taskManager.deleteTask(taskId, { force: true });
}
// Remove from local array and re-render
this.tasks = this.tasks.filter(t => t.id !== taskId);
this.renderTasks();
this.updateStats();
this.updateSidebarCounts();
this.generateAppCategories();
if (window.notificationSystem) {
// Match the "<App> task <verb>" two-line shape used by started/
// completed/failed/cancelled so deletion reads as part of the same
// family. Type emoji falls back to 🗑️ since deletion is a 🗑️ action.
const { appName, actionTitle, displayName, icon, typeIcon } = this._taskNotificationDescriptor(task);
const emoji = typeIcon || '🗑️';
const customIcon = `<span style="font-size:18px;line-height:1;">${emoji}</span>`;
const body = displayName
? `<strong>${displayName}</strong><br>${actionTitle} task deleted.`
: `${actionTitle || 'Task'} deleted.`;
window.notificationSystem.show(body, 'info', appName, null, icon, customIcon);
}
} catch (error) {
console.error('Error deleting task:', error);
if (window.notificationSystem) {
window.notificationSystem.error(`Failed to delete task: ${error.message}`);
}
}
},
async clearAllTasks() {
// Routes to one of two modes depending on whether any rows are ticked.
// Both paths share _showClearAllModal — same UX, same modal, different
// input list + title.
const hasSelection = this.selectedTaskIds.size > 0;
const targetTasks = hasSelection
? this.tasks.filter(t => this.selectedTaskIds.has(t.id))
: this.tasks;
const result = await this._showClearAllModal(targetTasks, hasSelection ? 'selected' : 'all');
if (!result || !result.confirmed) return false;
await this.performClearAll({ cancelRunning: result.cancelRunning, targets: targetTasks });
if (hasSelection) {
// Drop any ids we just deleted from the selection set, then refresh
// the button label + master-checkbox state.
this.selectedTaskIds = new Set([...this.selectedTaskIds].filter(id => this.tasks.find(t => t.id === id)));
this._updateSelectionUI();
}
return true;
},
async performClearAll(opts) {
opts = opts || {};
const cancelRunning = !!opts.cancelRunning;
// Caller (clearAllTasks) passes the subset to operate on. Falls back
// to this.tasks for backward compatibility — old direct callers
// continue to mean "everything".
const universe = Array.isArray(opts.targets) ? opts.targets : (this.tasks || []);
// Show progress notification with the trash type icon in the left slot
// (this is conceptually a delete-all action, so 🗑️ matches the row icon).
const customIcon = '<span style="font-size:18px;line-height:1;">🗑️</span>';
const progressNotification = window.notificationSystem.show(
'Clearing tasks...',
'info',
null,
null,
null,
customIcon
);
const isActive = (t) => t && (t.status === 'running' || t.status === 'queued' || t.status === 'pending');
// Partition: which tasks we attempt now, which we skip silently.
const targets = universe.filter(t => cancelRunning || !isActive(t));
const skipped = universe.filter(t => !targets.includes(t));
try {
// For active tasks (only reached when the toggle is on): cancel first,
// wait for terminal status, then delete. Mirrors the single-row
// deleteTask flow so the bash processor has a chance to honour the
// cancel marker before the DELETE arrives (otherwise we'd 409).
await Promise.all(targets.map(async (task) => {
if (cancelRunning && isActive(task)) {
try { await this.taskManager.cancelTask(task.id); } catch {}
await this._waitForTaskTerminal(task.id, 15_000);
}
try {
await this.taskManager.deleteTask(task.id);
} catch (err) {
const looks409 = /\bHTTP 409\b/.test(err && err.message ? err.message : '');
if (!looks409) throw err;
await this.taskManager.deleteTask(task.id, { force: true });
}
}));
// Re-sync local state: keep anything we intentionally skipped.
const deletedIds = new Set(targets.map(t => t.id));
this.tasks = this.tasks.filter(t => !deletedIds.has(t.id));
this.renderTasks();
this.updateStats();
this.updateSidebarCounts();
this.generateAppCategories();
if (progressNotification && progressNotification.remove) {
progressNotification.remove();
}
if (window.notificationSystem) {
const msg = skipped.length > 0
? `Deleted ${targets.length} tasks (skipped ${skipped.length} still running)`
: (targets.length === 1 ? 'Task deleted' : `Deleted ${targets.length} tasks`);
window.notificationSystem.show(msg, 'success', null, null, null, customIcon);
}
} catch (error) {
console.error('Error clearing tasks:', error);
if (progressNotification && progressNotification.remove) {
progressNotification.remove();
}
if (window.notificationSystem) {
window.notificationSystem.show(
`Failed to clear tasks: ${error.message}`,
'error',
null,
null,
null,
customIcon
);
}
}
},
// Selection helpers wired by the row + master checkboxes.
toggleTaskSelection(taskId, checked) {
if (checked) this.selectedTaskIds.add(taskId);
else this.selectedTaskIds.delete(taskId);
this._updateSelectionUI();
},
toggleSelectAll(checked) {
if (checked) {
// Tick every CURRENTLY VISIBLE row. We use the rendered checkboxes
// rather than this.tasks so category-filtered views only select
// what the user can see.
const boxes = document.querySelectorAll('#tasks-list [data-task-select]');
boxes.forEach((cb) => {
this.selectedTaskIds.add(cb.dataset.taskSelect);
cb.checked = true;
});
} else {
this.selectedTaskIds.clear();
document.querySelectorAll('#tasks-list [data-task-select]').forEach((cb) => { cb.checked = false; });
}
this._updateSelectionUI();
},
// Rewrites the Clear All button label + the master-checkbox indeterminate
// state to reflect the current selection. Cheap — only touches a few
// DOM nodes, safe to call from any selection-change path.
_updateSelectionUI() {
const n = this.selectedTaskIds.size;
const btn = document.getElementById('tasks-clear-btn');
const btnLabel = btn && btn.querySelector('.clear-btn-label');
if (btnLabel) btnLabel.textContent = n > 0 ? `Delete Selected (${n})` : 'Clear All';
if (btn) btn.title = n > 0 ? `Delete ${n} selected task${n === 1 ? '' : 's'}` : 'Clear All Tasks';
// Master checkbox: checked when ALL visible are picked, indeterminate
// when SOME are, unchecked when none.
const master = document.getElementById('tasks-select-all');
const visible = document.querySelectorAll('#tasks-list [data-task-select]');
if (master) {
if (n === 0 || visible.length === 0) {
master.checked = false;
master.indeterminate = false;
} else if (n >= visible.length) {
master.checked = true;
master.indeterminate = false;
} else {
master.checked = false;
master.indeterminate = true;
}
}
},
// Resolves once the task reaches completed/failed/cancelled, or after the
// timeout. Used by deleteTask so a cancel request has time to take effect
// before we issue the DELETE that would otherwise 409.
_waitForTaskTerminal(taskId, timeoutMs = 15_000) {
return new Promise((resolve) => {
// Already terminal in the bus cache? No need to wait.
const cached = (window.taskEventBus && window.taskEventBus.getTask) ? window.taskEventBus.getTask(taskId) : null;
if (cached && (cached.status === 'completed' || cached.status === 'failed' || cached.status === 'cancelled')) {
return resolve(cached);
}
let timer;
const cleanup = () => {
window.removeEventListener('taskCompleted', onComplete);
window.removeEventListener('taskUpdated', onUpdate);
if (timer) clearTimeout(timer);
};
const isTerminal = (s) => s === 'completed' || s === 'failed' || s === 'cancelled';
const onComplete = (e) => {
const t = e.detail && (e.detail.task || (e.detail.taskId === taskId ? { id: taskId, status: e.detail.status } : null));
const id = (t && t.id) || (e.detail && e.detail.taskId);
if (id !== taskId) return;
cleanup();
resolve(t);
};
const onUpdate = (e) => {
const t = e.detail && e.detail.task;
if (!t || t.id !== taskId) return;
if (isTerminal(t.status)) { cleanup(); resolve(t); }
};
window.addEventListener('taskCompleted', onComplete);
window.addEventListener('taskUpdated', onUpdate);
timer = setTimeout(() => { cleanup(); resolve(null); }, timeoutMs);
});
},
});