feat: voortgang copy/duplicate/move in headerbar
This commit is contained in:
+178
-2
@@ -114,6 +114,16 @@ let settingsState = {
|
||||
selectedColorMode: "dark",
|
||||
zipDownloadLimits: null,
|
||||
};
|
||||
let headerTaskState = {
|
||||
activeItems: [],
|
||||
popoverOpen: false,
|
||||
pollTimer: null,
|
||||
lastRenderKey: "",
|
||||
};
|
||||
// The header chip reflects only user-visible file actions that currently use the shared task system.
|
||||
// Delete stays out of this set because it still runs as a direct request flow, not as a backend task.
|
||||
const ACTIVE_TASK_OPERATIONS = new Set(["copy", "move", "duplicate"]);
|
||||
const ACTIVE_TASK_STATUSES = new Set(["queued", "running"]);
|
||||
const VALID_THEME_FAMILIES = [
|
||||
"default",
|
||||
"macos-soft",
|
||||
@@ -189,6 +199,17 @@ function setStatus(msg) {
|
||||
document.getElementById("status").textContent = msg;
|
||||
}
|
||||
|
||||
function headerTaskElements() {
|
||||
return {
|
||||
container: document.getElementById("header-task-chip-container"),
|
||||
chipButton: document.getElementById("header-task-chip-btn"),
|
||||
chipLabel: document.getElementById("header-task-chip-label"),
|
||||
popover: document.getElementById("header-task-popover"),
|
||||
popoverList: document.getElementById("header-task-popover-list"),
|
||||
logsButton: document.getElementById("header-task-logs-btn"),
|
||||
};
|
||||
}
|
||||
|
||||
function setError(id, msg) {
|
||||
if (id === "actions-error") {
|
||||
document.getElementById(id).textContent = "";
|
||||
@@ -1266,7 +1287,7 @@ async function uploadFileRequest(targetPath, file, overwrite = false) {
|
||||
async function refreshTasksSnapshot() {
|
||||
try {
|
||||
const data = await apiRequest("GET", "/api/tasks");
|
||||
state.lastTaskCount = Array.isArray(data.items) ? data.items.length : state.lastTaskCount;
|
||||
applyTaskSnapshot(data.items);
|
||||
} catch (_) {
|
||||
// Task list panel is not visible in current UI; silently keep flow stable.
|
||||
}
|
||||
@@ -3865,6 +3886,138 @@ function formatTaskLine(task) {
|
||||
};
|
||||
}
|
||||
|
||||
function isActiveTask(task) {
|
||||
return Boolean(task) && ACTIVE_TASK_OPERATIONS.has(task.operation) && ACTIVE_TASK_STATUSES.has(task.status);
|
||||
}
|
||||
|
||||
function activeTasksFromItems(items) {
|
||||
return Array.isArray(items) ? items.filter((task) => isActiveTask(task)) : [];
|
||||
}
|
||||
|
||||
function activeTaskChipLabel(count) {
|
||||
return `${count} active task${count === 1 ? "" : "s"}`;
|
||||
}
|
||||
|
||||
function headerTaskRenderKey(items) {
|
||||
return JSON.stringify(
|
||||
Array.isArray(items)
|
||||
? items.map((task) => ({
|
||||
id: task.id || "",
|
||||
operation: task.operation || "",
|
||||
status: task.status || "",
|
||||
source: task.source || "",
|
||||
destination: task.destination || "",
|
||||
done_items: task.done_items,
|
||||
total_items: task.total_items,
|
||||
current_item: task.current_item || "",
|
||||
}))
|
||||
: []
|
||||
);
|
||||
}
|
||||
|
||||
function shouldPollHeaderTasks() {
|
||||
return headerTaskState.popoverOpen || headerTaskState.activeItems.length > 0;
|
||||
}
|
||||
|
||||
function stopHeaderTaskPolling() {
|
||||
if (headerTaskState.pollTimer) {
|
||||
window.clearTimeout(headerTaskState.pollTimer);
|
||||
headerTaskState.pollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleHeaderTaskPolling() {
|
||||
stopHeaderTaskPolling();
|
||||
if (!shouldPollHeaderTasks()) {
|
||||
return;
|
||||
}
|
||||
headerTaskState.pollTimer = window.setTimeout(async () => {
|
||||
await refreshTasksSnapshot();
|
||||
scheduleHeaderTaskPolling();
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
function setHeaderTaskPopoverOpen(nextOpen) {
|
||||
const elements = headerTaskElements();
|
||||
const open = Boolean(nextOpen) && headerTaskState.activeItems.length > 0;
|
||||
headerTaskState.popoverOpen = open;
|
||||
if (elements.chipButton) {
|
||||
elements.chipButton.setAttribute("aria-expanded", open ? "true" : "false");
|
||||
}
|
||||
if (elements.popover) {
|
||||
elements.popover.classList.toggle("hidden", !open);
|
||||
}
|
||||
scheduleHeaderTaskPolling();
|
||||
}
|
||||
|
||||
function renderHeaderTaskPopover(items) {
|
||||
const elements = headerTaskElements();
|
||||
if (!elements.popoverList) {
|
||||
return;
|
||||
}
|
||||
const renderKey = headerTaskRenderKey(items);
|
||||
if (headerTaskState.lastRenderKey === renderKey) {
|
||||
return;
|
||||
}
|
||||
const scrollTop = elements.popoverList.scrollTop;
|
||||
elements.popoverList.innerHTML = "";
|
||||
if (!Array.isArray(items) || items.length === 0) {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "header-task-item-empty";
|
||||
empty.textContent = "No active tasks right now.";
|
||||
elements.popoverList.append(empty);
|
||||
headerTaskState.lastRenderKey = renderKey;
|
||||
return;
|
||||
}
|
||||
for (const task of items) {
|
||||
const line = formatTaskLine(task);
|
||||
const row = document.createElement("div");
|
||||
row.className = "header-task-item";
|
||||
const title = document.createElement("div");
|
||||
title.className = "header-task-item-title";
|
||||
title.textContent = line.title;
|
||||
const path = document.createElement("div");
|
||||
path.className = "header-task-item-path";
|
||||
path.textContent = line.path;
|
||||
const meta = document.createElement("div");
|
||||
meta.className = "header-task-item-meta";
|
||||
meta.textContent = line.meta;
|
||||
row.append(title, path, meta);
|
||||
elements.popoverList.append(row);
|
||||
}
|
||||
headerTaskState.lastRenderKey = renderKey;
|
||||
elements.popoverList.scrollTop = scrollTop;
|
||||
}
|
||||
|
||||
function renderHeaderTaskChip(items) {
|
||||
const elements = headerTaskElements();
|
||||
if (!elements.container || !elements.chipLabel) {
|
||||
return;
|
||||
}
|
||||
const hasActiveTasks = Array.isArray(items) && items.length > 0;
|
||||
elements.container.classList.toggle("hidden", !hasActiveTasks);
|
||||
elements.chipLabel.textContent = activeTaskChipLabel(items.length);
|
||||
if (!hasActiveTasks) {
|
||||
headerTaskState.lastRenderKey = "";
|
||||
setHeaderTaskPopoverOpen(false);
|
||||
return;
|
||||
}
|
||||
renderHeaderTaskPopover(items);
|
||||
}
|
||||
|
||||
function updateHeaderTaskState(taskItems) {
|
||||
headerTaskState.activeItems = activeTasksFromItems(taskItems);
|
||||
renderHeaderTaskChip(headerTaskState.activeItems);
|
||||
scheduleHeaderTaskPolling();
|
||||
}
|
||||
|
||||
function applyTaskSnapshot(taskItems) {
|
||||
const items = Array.isArray(taskItems) ? taskItems : [];
|
||||
state.lastTaskCount = items.length;
|
||||
updateHeaderTaskState(items);
|
||||
return items;
|
||||
}
|
||||
|
||||
function renderHistoryItems(items) {
|
||||
const elements = settingsElements();
|
||||
const renderKey = JSON.stringify(Array.isArray(items) ? items : []);
|
||||
@@ -3957,7 +4110,7 @@ async function loadHistoryForSettings() {
|
||||
|
||||
async function loadTasksForSettings() {
|
||||
const data = await apiRequest("GET", "/api/tasks");
|
||||
renderTaskItems(data.items || []);
|
||||
renderTaskItems(applyTaskSnapshot(data.items));
|
||||
settingsState.tasksLoaded = true;
|
||||
}
|
||||
|
||||
@@ -4517,6 +4670,11 @@ function handleKeyboardShortcuts(event) {
|
||||
if (!shouldHandleShortcut(event.target)) {
|
||||
return;
|
||||
}
|
||||
if (event.key === "Escape" && headerTaskState.popoverOpen) {
|
||||
event.preventDefault();
|
||||
setHeaderTaskPopoverOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const isInfoShortcut = event.key === "Enter" && !event.shiftKey && !event.altKey && (event.metaKey || event.ctrlKey);
|
||||
if (isInfoShortcut) {
|
||||
@@ -4627,6 +4785,19 @@ function setupEvents() {
|
||||
setupPaneEvents("right");
|
||||
document.addEventListener("keydown", handleKeyboardShortcuts);
|
||||
document.getElementById("theme-toggle").onclick = toggleTheme;
|
||||
const headerTasks = headerTaskElements();
|
||||
if (headerTasks.chipButton) {
|
||||
headerTasks.chipButton.onclick = (event) => {
|
||||
event.stopPropagation();
|
||||
setHeaderTaskPopoverOpen(!headerTaskState.popoverOpen);
|
||||
};
|
||||
}
|
||||
if (headerTasks.logsButton) {
|
||||
headerTasks.logsButton.onclick = () => {
|
||||
setHeaderTaskPopoverOpen(false);
|
||||
openSettings("logs");
|
||||
};
|
||||
}
|
||||
document.getElementById("upload-btn").onclick = openUploadPicker;
|
||||
document.getElementById("upload-menu-toggle").onclick = (event) => {
|
||||
event.stopPropagation();
|
||||
@@ -4715,6 +4886,10 @@ function setupEvents() {
|
||||
} else {
|
||||
closeUploadMenu();
|
||||
}
|
||||
const headerTaskContainer = headerTaskElements().container;
|
||||
if (headerTaskContainer && !headerTaskContainer.contains(event.target)) {
|
||||
setHeaderTaskPopoverOpen(false);
|
||||
}
|
||||
const contextMenu = contextMenuElements().menu;
|
||||
if (contextMenu && !contextMenu.contains(event.target)) {
|
||||
closeContextMenu();
|
||||
@@ -4914,6 +5089,7 @@ async function init() {
|
||||
paneState("right").currentPath = "/Volumes";
|
||||
await loadBrowsePane("right");
|
||||
}
|
||||
await refreshTasksSnapshot();
|
||||
}
|
||||
|
||||
init();
|
||||
|
||||
@@ -71,6 +71,92 @@ body {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.header-task-chip-container {
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.header-task-chip {
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-primary);
|
||||
border-radius: 999px;
|
||||
padding: 5px 10px;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 1px 2px rgba(8, 14, 22, 0.08);
|
||||
}
|
||||
|
||||
.header-task-chip:hover,
|
||||
.header-task-chip[aria-expanded="true"] {
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.header-task-popover {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
width: min(360px, calc(100vw - 24px));
|
||||
padding: 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-elevated);
|
||||
box-shadow: var(--shadow-elevated);
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
.header-task-popover-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.header-task-link {
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--color-accent);
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.header-task-popover-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.header-task-item {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface);
|
||||
padding: 8px 9px;
|
||||
}
|
||||
|
||||
.header-task-item-title {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.header-task-item-path,
|
||||
.header-task-item-meta,
|
||||
.header-task-item-empty {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
h1, h2, h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -25,6 +25,25 @@
|
||||
</div>
|
||||
<div id="title-zone-actions">
|
||||
<div id="status"></div>
|
||||
<div id="header-task-chip-container" class="header-task-chip-container hidden">
|
||||
<button
|
||||
id="header-task-chip-btn"
|
||||
type="button"
|
||||
class="header-task-chip"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded="false"
|
||||
aria-controls="header-task-popover"
|
||||
>
|
||||
<span id="header-task-chip-label">1 active task</span>
|
||||
</button>
|
||||
<div id="header-task-popover" class="header-task-popover hidden" role="dialog" aria-label="Active tasks">
|
||||
<div class="header-task-popover-header">
|
||||
<strong>Active tasks</strong>
|
||||
<button id="header-task-logs-btn" type="button" class="header-task-link">View in Logs</button>
|
||||
</div>
|
||||
<div id="header-task-popover-list" class="header-task-popover-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
<button id="theme-toggle" type="button" aria-label="Toggle theme" title="Toggle theme">
|
||||
<span id="theme-toggle-icon" aria-hidden="true">☾</span>
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user