feat: voortgang copy/duplicate/move in headerbar

This commit is contained in:
kodi
2026-03-15 11:40:21 +01:00
parent 9d5fb5a0c9
commit 73b09d2802
24 changed files with 1104 additions and 2 deletions
+252
View File
@@ -0,0 +1,252 @@
1 analyse
De repo heeft al een bruikbaar taskmodel voor copy, move, download en duplicate, maar de main WebUI gebruikt dat model voor copy/move nog nauwelijks. In de hoofd-UI ziet de gebruiker na start nu vooral een korte statusregel of summary; live voortgang staat feitelijk alleen in `F1 > Settings > Logs`. Daardoor ontbreekt directe, persistente feedback in de hoofd-UI en is er geen zichtbare rem op dubbel starten.
Belangrijkste conclusie:
- Copy en move hebben al echte backend-tasks met progressvelden.
- De bron van truth voor lopende copy/move-taken is al `/api/tasks`.
- Er bestaat nu geen cancel/abort voor copy of move.
- Een eerlijke abortknop voor copy/move kan dus nu niet frontend-only worden toegevoegd.
- De kleinste veilige stap is een compacte live task-indicator in de bestaande header/toolbar-zone, gevoed door de bestaande task-feed.
2 bestaande functionaliteit
A. Taskmodel / backend
- `copy` en `move` gebruiken hetzelfde taskmechanisme via [tasks_runner.py](/workspace/webmanager-mvp/webui/backend/app/tasks_runner.py), [task_repository.py](/workspace/webmanager-mvp/webui/backend/app/db/task_repository.py), [copy_task_service.py](/workspace/webmanager-mvp/webui/backend/app/services/copy_task_service.py) en [move_task_service.py](/workspace/webmanager-mvp/webui/backend/app/services/move_task_service.py).
- Taskstatussen die al bestaan in [task_repository.py](/workspace/webmanager-mvp/webui/backend/app/db/task_repository.py):
- `queued`
- `running`
- `completed`
- `failed`
- daarnaast voor download ook `requested`, `preparing`, `ready`, `cancelled`
- Progressinformatie bestaat al:
- files: `done_bytes`, `total_bytes`, `current_item`
- batch/directory: `done_items`, `total_items`, `current_item`
- Copy:
- file copy gebruikt byte-progress callback
- directory copy is grof: `0/1` naar `1/1`
- batch copy gebruikt item-progress
- Move:
- same-root file move heeft praktisch geen tussentijdse progress, alleen start/einde
- cross-root file move gebruikt copy-progress en delete na afloop
- directory move is grof `0/1` naar `1/1`
- batch move gebruikt item-progress
- Er is al read-API voor tasks:
- `GET /api/tasks`
- `GET /api/tasks/{task_id}`
- Er is geen cancel-API voor copy/move.
- De enige echte cancel in de repo zit nu bij archive-downloads in [archive_download_task_service.py](/workspace/webmanager-mvp/webui/backend/app/services/archive_download_task_service.py) en `POST /api/files/download/archive/{task_id}/cancel`.
- Copy/move workers in [tasks_runner.py](/workspace/webmanager-mvp/webui/backend/app/tasks_runner.py) hebben geen cooperative cancel checks.
- Copy/move history bestaat al via [history_repository.py](/workspace/webmanager-mvp/webui/backend/app/db/history_repository.py): `queued`, `completed`, `failed`.
B. Bestaande frontend feedback
- In de hoofd-UI starten copy en move vanuit [app.js](/workspace/webmanager-mvp/webui/html/app.js):
- `startCopySelected()`
- `executeMoveSelection()`
- Huidige feedback voor copy/move:
- `setStatus(...)` onderin/headerstatus
- `showActionSummary(...)`
- `openFeedbackModal(...)` via `actions-error`
- Die feedback is niet persistent als live taskweergave.
- Er is nu geen compacte taskindicator in de hoofd-UI.
- `state.selectedTaskId` en `refreshTasksSnapshot()` bestaan al in [app.js](/workspace/webmanager-mvp/webui/html/app.js), maar worden voor copy/move alleen gebruikt om een snapshotcount op te halen; er is geen zichtbare hoofd-UI-component die dit toont.
- Buiten download is er geen modal of popover voor actieve taken in de hoofd-UI.
C. Logs / history / settings
- `F1 > Settings > Logs` toont al twee side-by-side secties:
- `Tasks`
- `History`
- Deze UI gebruikt al de bestaande feeds:
- `/api/tasks`
- `/api/history`
- Polling bestaat al in [app.js](/workspace/webmanager-mvp/webui/html/app.js):
- `loadTasksForSettings()`
- `loadHistoryForSettings()`
- `loadLogsAndTasksForSettings()`
- `scheduleSettingsLogsPolling()`
- De UI rendert taskdetails al compact via `formatTaskLine(task)`:
- status
- source/destination
- `done_items/total_items`
- `current_item`
- Dat betekent dat de repo al een bruikbare frontend formatteringslaag heeft die ook buiten Settings herbruikbaar is.
D. Abort/cancel haalbaarheid
- Copy/move kunnen nu technisch niet veilig worden afgebroken via bestaande code.
- Er is geen taskstatus-overgang of API-contract voor copy/move-cancel.
- Er is geen cooperative worker-check in copy/move loops.
- Er is geen rollback.
- Eerlijke cancelsemantiek voor copy/move zou dus moeten zijn:
- stop resterende verwerking zo snel mogelijk op een checkpunt
- reeds verwerkte bestanden blijven zoals ze zijn
- geen rollback
- Maar die semantiek is nog niet geïmplementeerd.
- Conclusie: een abortknop voor copy/move is nu buiten scope zonder backendwerk.
3 scope
Minimale veilige volgende stap, op basis van wat al bestaat:
- frontend-only hoofd-UI verbetering
- geen layoutwijziging van de dual-pane browse-UI
- geen nieuw vast paneel
- wel een compacte task/status chip in bestaande headerbar of function-bar zone
- alleen zichtbaar als er actieve taken zijn (`queued`, `running`, en eventueel download `requested/preparing`)
- klik opent een kleine popover/dropdown met actieve taken
- popover hergebruikt bestaande taskdata en formattering uit `/api/tasks`
- popover bevat link/actie naar `F1 > Settings > Logs`
- geen abortknop voor copy/move in deze fase
Waarom dit binnen scope past:
- gebruikt bestaande task-feed
- gebruikt bestaande taaksemantiek
- verandert de hoofd-layout niet
- geeft persistente feedback zonder modal-first patroon
- is compatibel met de OneDrive-achtige richting: compacte indicator, detail op aanvraag
4 impact
Positief:
- gebruiker ziet direct in de hoofd-UI dat copy/move loopt
- feedback blijft zichtbaar zolang taak actief is
- minder kans op dubbel starten
- geen extra structureel paneel
- F1 Logs blijft intact als detailbron
Beperkingen:
- zonder backendwerk is er nog geen eerlijke cancel voor copy/move
- progress blijft zo nauwkeurig als bestaande taskdata toelaat
- same-root move en directory move blijven qua progress relatief grof
5 risico
Laag tot middel als alleen de voorgestelde frontendstap wordt gebouwd.
Belangrijkste risicos:
- polling in de hoofd-UI kan onrustig worden als hij niet net zo stabiel wordt gebouwd als de bestaande Settings-polling
- een te opvallende indicator kan visueel concurreren met de bestaande headerstatus
- als een abortknop zonder backendsteun zou worden toegevoegd, zou dat misleidend zijn; dat moet expliciet niet gebeuren
Expliciet risico buiten scope:
- copy/move-cancel vereist backend-aanpassing aan taskmodel, runner en waarschijnlijk history
6 testplan
Voor de minimale frontendstap:
- gerichte UI smoke/golden checks voor:
- indicator aanwezig in header/toolbar markup
- indicator alleen bedoeld voor actieve taken
- popover/dropdown markup aanwezig
- link naar bestaande logs-entrypoint aanwezig
- gerichte JS-checks voor:
- actieve taken worden uit `/api/tasks` gefilterd
- `queued`/`running` tonen indicator
- `completed`/`failed` verdwijnen uit de actieve indicator
- polling start/stop logisch zonder extra layoutreset
- geen backend golden updates nodig zolang `/api/tasks` contract ongewijzigd blijft
Niet nu testen:
- abort voor copy/move, want die functionaliteit bestaat nog niet
7 acceptatiecriteria
Voor de voorgestelde minimale stap:
- Een gestart copy- of move-proces is zichtbaar in de hoofd-UI zonder navigatie naar `F1 > Settings / Logs`.
- De oplossing verandert de dual-panel layout niet structureel.
- De feedback blijft zichtbaar zolang de taak actief is.
- De oplossing gebruikt bestaande taskdata als bron van truth.
- Er wordt geen fake progress getoond.
- Er wordt geen fake cancelknop getoond voor copy/move.
- Bestaande task/log/history-functionaliteit blijft intact.
- API-contract blijft ongewijzigd.
Voor abort/cancel:
- Niet acceptabel in deze fase zonder backendsteun.
- Eerst aparte backendfase nodig.
8 codex-uitvoering / voorstel
Huidige stap:
- Alleen analyse uitgevoerd.
- Geen functionele implementatie gedaan.
Waarom:
- `CHANGE_POLICY.md` zegt dat frontend flow aanpassen eerst een voorstel nodig heeft.
- De opdracht vroeg expliciet om eerst grondige repo-inspectie en pas daarna een minimaal voorstel.
- Cancel/abort voor copy/move is niet eerlijk implementeerbaar zonder backendwerk.
Minimaal wijzigingsvoorstel dat ik hierna zou uitvoeren als vervolgstap:
1. Frontend-only compacte task chip
- plaats in `#title-zone-actions` of direct naast `#status`
- toont bijvoorbeeld:
- `1 task running`
- `3 active tasks`
2. Kleine popover/dropdown
- opent op klik op de chip
- toont alleen actieve taken uit `/api/tasks`
- hergebruikt bestaande `formatTaskLine(task)` of een kleine variant daarop
- toont eerlijke status:
- `queued`
- `running`
- eventueel later download `requested/preparing`
3. Polling hergebruik
- hergebruik bestaande `/api/tasks`
- implementeer lichte polling alleen als er actieve taken zijn of als de popover open is
- gebruik stabiele rerender-aanpak zoals in Settings > Logs
4. Doorgang naar detail
- knop of link `View in Logs`
- opent bestaande `F1 > Settings > Logs`
5. Expliciet nog niet doen
- geen cancelknop voor copy/move
- geen extra paneel
- geen fake progressbar
Vervolgvoorstel voor latere backendfase als abort gewenst is:
- copy/move taskstatus uitbreiden met `cancelled`
- cancel-endpoint voor copy/move
- cooperative checks in `TaskRunner` tussen items/chunks
- eerlijke semantiek:
- stop resterende verwerking
- reeds verwerkte bestanden blijven bestaan
- geen rollback
9 gewijzigde bestanden
- [project_docs/UI_FEEDACK.md](/workspace/webmanager-mvp/project_docs/UI_FEEDACK.md)
10 uitgevoerde tests
Wel gedaan:
- code-inspectie van backend taskmodel, runners, services, routes en frontend task/log UI
Niet gedaan:
- geen functionele tests
- geen implementatiechecks
Reden:
- deze stap is bewust alleen analyse + voorstel, geen implementatie
Binary file not shown.
@@ -119,6 +119,27 @@ class HistoryRepository:
), ),
) )
def reconcile_entries_failed(
self,
entry_ids: list[str],
*,
error_code: str = "task_interrupted",
error_message: str = "Task was interrupted before completion",
) -> None:
if not entry_ids:
return
finished_at = self._now_iso()
placeholders = ", ".join("?" for _ in entry_ids)
with self._connection() as conn:
conn.execute(
f"""
UPDATE history
SET status = ?, error_code = ?, error_message = ?, finished_at = ?
WHERE id IN ({placeholders})
""",
("failed", error_code, error_message, finished_at, *entry_ids),
)
def _ensure_schema(self) -> None: def _ensure_schema(self) -> None:
db_path = Path(self._db_path) db_path = Path(self._db_path)
if db_path.parent and str(db_path.parent) not in {"", "."}: if db_path.parent and str(db_path.parent) not in {"", "."}:
+39
View File
@@ -8,6 +8,7 @@ from pathlib import Path
VALID_STATUSES = {"queued", "running", "completed", "failed", "requested", "preparing", "ready", "cancelled"} VALID_STATUSES = {"queued", "running", "completed", "failed", "requested", "preparing", "ready", "cancelled"}
VALID_OPERATIONS = {"copy", "move", "download", "duplicate"} VALID_OPERATIONS = {"copy", "move", "download", "duplicate"}
NON_TERMINAL_STATUSES = ("queued", "running", "requested", "preparing")
TASK_MIGRATION_COLUMNS: dict[str, str] = { TASK_MIGRATION_COLUMNS: dict[str, str] = {
"operation": "TEXT NOT NULL DEFAULT 'copy'", "operation": "TEXT NOT NULL DEFAULT 'copy'",
"status": "TEXT NOT NULL DEFAULT 'queued'", "status": "TEXT NOT NULL DEFAULT 'queued'",
@@ -394,6 +395,44 @@ class TaskRepository:
with self._connection() as conn: with self._connection() as conn:
conn.execute("DELETE FROM task_artifacts WHERE task_id = ?", (task_id,)) conn.execute("DELETE FROM task_artifacts WHERE task_id = ?", (task_id,))
def reconcile_incomplete_tasks(
self,
*,
error_code: str = "task_interrupted",
error_message: str = "Task was interrupted before completion",
) -> list[str]:
finished_at = self._now_iso()
placeholders = ", ".join("?" for _ in NON_TERMINAL_STATUSES)
with self._connection() as conn:
rows = conn.execute(
f"""
SELECT id
FROM tasks
WHERE status IN ({placeholders})
""",
NON_TERMINAL_STATUSES,
).fetchall()
task_ids = [row["id"] for row in rows]
if not task_ids:
return []
task_placeholders = ", ".join("?" for _ in task_ids)
conn.execute(
f"""
UPDATE tasks
SET status = ?, finished_at = ?, error_code = ?, error_message = ?, current_item = NULL
WHERE id IN ({task_placeholders})
""",
("failed", finished_at, error_code, error_message, *task_ids),
)
conn.execute(
f"""
DELETE FROM task_artifacts
WHERE task_id IN ({task_placeholders})
""",
task_ids,
)
return task_ids
def _migrate_tasks_columns(self, conn: sqlite3.Connection) -> None: def _migrate_tasks_columns(self, conn: sqlite3.Connection) -> None:
rows = conn.execute("PRAGMA table_info(tasks)").fetchall() rows = conn.execute("PRAGMA table_info(tasks)").fetchall()
existing_columns = {row["name"] for row in rows} existing_columns = {row["name"] for row in rows}
+10
View File
@@ -17,7 +17,9 @@ from backend.app.api.routes_move import router as move_router
from backend.app.api.routes_search import router as search_router from backend.app.api.routes_search import router as search_router
from backend.app.api.routes_settings import router as settings_router from backend.app.api.routes_settings import router as settings_router
from backend.app.api.routes_tasks import router as tasks_router from backend.app.api.routes_tasks import router as tasks_router
from backend.app.dependencies import get_history_repository, get_task_repository
from backend.app.logging import configure_logging from backend.app.logging import configure_logging
from backend.app.services.task_recovery_service import reconcile_persisted_incomplete_tasks
configure_logging() configure_logging()
@@ -40,6 +42,14 @@ app.include_router(history_router, prefix="/api")
app.include_router(tasks_router, prefix="/api") app.include_router(tasks_router, prefix="/api")
@app.on_event("startup")
async def reconcile_incomplete_tasks_on_startup() -> None:
reconcile_persisted_incomplete_tasks(
task_repository=get_task_repository(),
history_repository=get_history_repository(),
)
@app.exception_handler(AppError) @app.exception_handler(AppError)
async def handle_app_error(_: Request, exc: AppError) -> JSONResponse: async def handle_app_error(_: Request, exc: AppError) -> JSONResponse:
return JSONResponse( return JSONResponse(
@@ -0,0 +1,14 @@
from __future__ import annotations
from backend.app.db.history_repository import HistoryRepository
from backend.app.db.task_repository import TaskRepository
def reconcile_persisted_incomplete_tasks(
task_repository: TaskRepository,
history_repository: HistoryRepository,
) -> list[str]:
task_ids = task_repository.reconcile_incomplete_tasks()
if task_ids:
history_repository.reconcile_entries_failed(task_ids)
return task_ids
Binary file not shown.
@@ -231,6 +231,300 @@ class UiSmokeGoldenTest(unittest.TestCase):
) )
self.assertEqual(result.returncode, 0, msg=result.stderr or result.stdout) self.assertEqual(result.returncode, 0, msg=result.stderr or result.stdout)
def _run_header_task_chip_behavior_check(self, app_js: str) -> None:
functions = "\n\n".join(
[
self._extract_js_function(app_js, "headerTaskElements"),
self._extract_js_function(app_js, "formatTaskStatusLabel"),
self._extract_js_function(app_js, "inferDownloadTaskContext"),
self._extract_js_function(app_js, "formatTaskLine"),
self._extract_js_function(app_js, "isActiveTask"),
self._extract_js_function(app_js, "activeTasksFromItems"),
self._extract_js_function(app_js, "activeTaskChipLabel"),
self._extract_js_function(app_js, "headerTaskRenderKey"),
self._extract_js_function(app_js, "shouldPollHeaderTasks"),
self._extract_js_function(app_js, "stopHeaderTaskPolling"),
self._extract_js_function(app_js, "scheduleHeaderTaskPolling"),
self._extract_js_function(app_js, "setHeaderTaskPopoverOpen"),
self._extract_js_function(app_js, "renderHeaderTaskPopover"),
self._extract_js_function(app_js, "renderHeaderTaskChip"),
self._extract_js_function(app_js, "updateHeaderTaskState"),
]
)
script = textwrap.dedent(
f"""
const assert = (condition, message) => {{
if (!condition) {{
throw new Error(message);
}}
}};
function createClassList(initialHidden = false) {{
const names = new Set(initialHidden ? ["hidden"] : []);
return {{
add(name) {{ names.add(name); }},
remove(name) {{ names.delete(name); }},
toggle(name, force) {{
if (force === undefined) {{
if (names.has(name)) {{
names.delete(name);
}} else {{
names.add(name);
}}
return;
}}
if (force) {{
names.add(name);
}} else {{
names.delete(name);
}}
}},
contains(name) {{ return names.has(name); }},
}};
}}
function createElement(initialHidden = false) {{
return {{
classList: createClassList(initialHidden),
textContent: "",
innerHTML: "",
children: [],
scrollTop: 0,
attributes: {{}},
append(...nodes) {{
this.children.push(...nodes);
}},
setAttribute(name, value) {{
this.attributes[name] = value;
}},
}};
}}
const elements = {{
"header-task-chip-container": createElement(true),
"header-task-chip-btn": createElement(false),
"header-task-chip-label": createElement(false),
"header-task-popover": createElement(true),
"header-task-popover-list": createElement(false),
"header-task-logs-btn": createElement(false),
}};
const document = {{
getElementById(id) {{
return elements[id] || null;
}},
createElement() {{
return createElement(false);
}},
}};
const window = {{
setTimeout(fn, delay) {{
return 1;
}},
clearTimeout(id) {{}},
}};
function formatModified(value) {{
return value || "now";
}}
let headerTaskState = {{
activeItems: [],
popoverOpen: false,
pollTimer: null,
lastRenderKey: "",
}};
const ACTIVE_TASK_OPERATIONS = new Set(["copy", "move", "duplicate"]);
const ACTIVE_TASK_STATUSES = new Set(["queued", "running"]);
{functions}
const mixedTasks = [
{{ id: "a", operation: "copy", status: "queued", source: "/src/a", destination: "/dst/a" }},
{{ id: "b", operation: "move", status: "running", source: "/src/b", destination: "/dst/b", done_items: 1, total_items: 3, current_item: "b.mkv" }},
{{ id: "c", operation: "download", status: "requested", source: "/src/c", destination: "kodidownload-20260315-120000.zip" }},
{{ id: "d", operation: "download", status: "preparing", source: "/src/d", destination: "folder.zip" }},
{{ id: "dup", operation: "duplicate", status: "queued", source: "/src/dup", destination: "/dst/dup" }},
{{ id: "del", operation: "delete", status: "running", source: "/src/del", destination: "" }},
{{ id: "e", operation: "copy", status: "completed", source: "/src/e", destination: "/dst/e" }},
{{ id: "f", operation: "move", status: "failed", source: "/src/f", destination: "/dst/f" }},
{{ id: "g", operation: "download", status: "ready", source: "/src/g", destination: "folder.zip" }},
{{ id: "h", operation: "download", status: "cancelled", source: "/src/h", destination: "folder.zip" }},
];
const activeTasks = activeTasksFromItems(mixedTasks);
assert(activeTasks.length === 3, "Only copy, move and duplicate tasks in queued or running should count as active");
assert(activeTasks.every((task) => isActiveTask(task)), "All filtered tasks should be active");
assert(!activeTasks.some((task) => task.operation === "delete"), "Delete should not be counted because it is not task-based in the current UI flow");
assert(activeTaskChipLabel(activeTasks.length) === "3 active tasks", "Chip label should reflect active task count");
updateHeaderTaskState(mixedTasks);
assert(!elements["header-task-chip-container"].classList.contains("hidden"), "Chip should be visible with active tasks");
assert(elements["header-task-chip-label"].textContent === "3 active tasks", "Chip label should render active task count");
assert(shouldPollHeaderTasks(), "Active tasks should enable header polling");
setHeaderTaskPopoverOpen(true);
assert(headerTaskState.popoverOpen, "Popover should open when active tasks exist");
assert(!elements["header-task-popover"].classList.contains("hidden"), "Popover should be visible when open");
assert(elements["header-task-chip-btn"].attributes["aria-expanded"] === "true", "Chip button should expose expanded state");
assert(elements["header-task-popover-list"].children.length === 3, "Popover should render only active file-action tasks");
updateHeaderTaskState([
{{ id: "z1", operation: "copy", status: "completed", source: "/src/z1", destination: "/dst/z1" }},
{{ id: "z2", operation: "move", status: "failed", source: "/src/z2", destination: "/dst/z2" }},
{{ id: "z3", operation: "download", status: "ready", source: "/src/z3", destination: "folder.zip" }},
]);
assert(elements["header-task-chip-container"].classList.contains("hidden"), "Chip should hide when no active tasks remain");
assert(!headerTaskState.popoverOpen, "Popover should close when no active tasks remain");
assert(elements["header-task-popover"].classList.contains("hidden"), "Popover should be hidden when no active tasks remain");
assert(elements["header-task-chip-btn"].attributes["aria-expanded"] === "false", "Chip button should reset expanded state when hidden");
"""
)
result = subprocess.run(
["node", "-e", script],
cwd="/workspace/webmanager-mvp",
capture_output=True,
text=True,
check=False,
)
self.assertEqual(result.returncode, 0, msg=result.stderr or result.stdout)
def _run_task_snapshot_sync_behavior_check(self, app_js: str) -> None:
functions = "\n\n".join(
[
self._extract_js_function(app_js, "headerTaskElements"),
self._extract_js_function(app_js, "formatTaskStatusLabel"),
self._extract_js_function(app_js, "inferDownloadTaskContext"),
self._extract_js_function(app_js, "formatTaskLine"),
self._extract_js_function(app_js, "isActiveTask"),
self._extract_js_function(app_js, "activeTasksFromItems"),
self._extract_js_function(app_js, "activeTaskChipLabel"),
self._extract_js_function(app_js, "headerTaskRenderKey"),
self._extract_js_function(app_js, "shouldPollHeaderTasks"),
self._extract_js_function(app_js, "stopHeaderTaskPolling"),
self._extract_js_function(app_js, "scheduleHeaderTaskPolling"),
self._extract_js_function(app_js, "setHeaderTaskPopoverOpen"),
self._extract_js_function(app_js, "renderHeaderTaskPopover"),
self._extract_js_function(app_js, "renderHeaderTaskChip"),
self._extract_js_function(app_js, "updateHeaderTaskState"),
self._extract_js_function(app_js, "applyTaskSnapshot"),
]
)
script = textwrap.dedent(
f"""
const assert = (condition, message) => {{
if (!condition) {{
throw new Error(message);
}}
}};
function createClassList(initialHidden = false) {{
const names = new Set(initialHidden ? ["hidden"] : []);
return {{
add(name) {{ names.add(name); }},
remove(name) {{ names.delete(name); }},
toggle(name, force) {{
if (force === undefined) {{
if (names.has(name)) {{
names.delete(name);
}} else {{
names.add(name);
}}
return;
}}
if (force) {{
names.add(name);
}} else {{
names.delete(name);
}}
}},
contains(name) {{ return names.has(name); }},
}};
}}
function createElement(initialHidden = false) {{
return {{
classList: createClassList(initialHidden),
textContent: "",
innerHTML: "",
children: [],
scrollTop: 0,
attributes: {{}},
append(...nodes) {{
this.children.push(...nodes);
}},
setAttribute(name, value) {{
this.attributes[name] = value;
}},
}};
}}
const elements = {{
"header-task-chip-container": createElement(true),
"header-task-chip-btn": createElement(false),
"header-task-chip-label": createElement(false),
"header-task-popover": createElement(true),
"header-task-popover-list": createElement(false),
"header-task-logs-btn": createElement(false),
}};
const document = {{
getElementById(id) {{
return elements[id] || null;
}},
createElement() {{
return createElement(false);
}},
}};
const window = {{
setTimeout(fn, delay) {{
return 1;
}},
clearTimeout(id) {{}},
}};
function formatModified(value) {{
return value || "now";
}}
let state = {{ lastTaskCount: 0 }};
let headerTaskState = {{
activeItems: [],
popoverOpen: false,
pollTimer: null,
lastRenderKey: "",
}};
const ACTIVE_TASK_OPERATIONS = new Set(["copy", "move", "duplicate"]);
const ACTIVE_TASK_STATUSES = new Set(["queued", "running"]);
{functions}
applyTaskSnapshot([
{{ id: "copy-1", operation: "copy", status: "running", source: "/src", destination: "/dst" }},
]);
assert(!elements["header-task-chip-container"].classList.contains("hidden"), "Running task should make chip visible");
assert(elements["header-task-chip-label"].textContent === "1 active task", "Chip should show one active task");
assert(headerTaskState.activeItems.length === 1, "Snapshot should store active task state");
applyTaskSnapshot([
{{ id: "copy-1", operation: "copy", status: "completed", source: "/src", destination: "/dst" }},
]);
assert(elements["header-task-chip-container"].classList.contains("hidden"), "Chip should hide when latest task snapshot has no active tasks");
assert(headerTaskState.activeItems.length === 0, "Active task state should be reset when tasks are completed");
assert(state.lastTaskCount === 1, "Total task snapshot should still reflect fetched tasks list length");
"""
)
result = subprocess.run(
["node", "-e", script],
cwd="/workspace/webmanager-mvp",
capture_output=True,
text=True,
check=False,
)
self.assertEqual(result.returncode, 0, msg=result.stderr or result.stdout)
def test_ui_mount_and_index_contains_expected_panels(self) -> None: def test_ui_mount_and_index_contains_expected_panels(self) -> None:
mount = self._ui_mount() mount = self._ui_mount()
self.assertIsInstance(mount.app, StaticFiles) self.assertIsInstance(mount.app, StaticFiles)
@@ -256,6 +550,11 @@ class UiSmokeGoldenTest(unittest.TestCase):
self.assertIn('/ui/assets/img/logo.svg', body) self.assertIn('/ui/assets/img/logo.svg', body)
self.assertIn('id="title-zone-actions"', body) self.assertIn('id="title-zone-actions"', body)
self.assertIn('id="status"', body) self.assertIn('id="status"', body)
self.assertIn('id="header-task-chip-container"', body)
self.assertIn('id="header-task-chip-btn"', body)
self.assertIn('id="header-task-popover"', body)
self.assertIn('id="header-task-popover-list"', body)
self.assertIn('id="header-task-logs-btn"', body)
self.assertIn('id="theme-toggle"', body) self.assertIn('id="theme-toggle"', body)
self.assertIn('id="theme-toggle-icon"', body) self.assertIn('id="theme-toggle-icon"', body)
self.assertIn('id="left-pane"', body) self.assertIn('id="left-pane"', body)
@@ -438,6 +737,10 @@ class UiSmokeGoldenTest(unittest.TestCase):
self.assertIn("#title-brand {", base_css) self.assertIn("#title-brand {", base_css)
self.assertIn("#title-logo {", base_css) self.assertIn("#title-logo {", base_css)
self.assertIn("height: 32px;", base_css) self.assertIn("height: 32px;", base_css)
self.assertIn(".header-task-chip-container {", base_css)
self.assertIn(".header-task-chip {", base_css)
self.assertIn(".header-task-popover {", base_css)
self.assertIn(".header-task-popover-list {", base_css)
self.assertIn("width: min(1180px, calc(100vw - 32px));", base_css) self.assertIn("width: min(1180px, calc(100vw - 32px));", base_css)
self.assertIn(".settings-activity-grid {", base_css) self.assertIn(".settings-activity-grid {", base_css)
self.assertIn("grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);", base_css) self.assertIn("grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);", base_css)
@@ -476,6 +779,32 @@ class UiSmokeGoldenTest(unittest.TestCase):
self.assertIn('function formatTaskStatusLabel(task)', app_js) self.assertIn('function formatTaskStatusLabel(task)', app_js)
self.assertIn('function inferDownloadTaskContext(task)', app_js) self.assertIn('function inferDownloadTaskContext(task)', app_js)
self.assertIn('function formatTaskLine(task)', app_js) self.assertIn('function formatTaskLine(task)', app_js)
self.assertIn('let headerTaskState = {', app_js)
self.assertIn('const ACTIVE_TASK_OPERATIONS = new Set(["copy", "move", "duplicate"]);', app_js)
self.assertIn('const ACTIVE_TASK_STATUSES = new Set(["queued", "running"]);', app_js)
self.assertIn("Delete stays out of this set because it still runs as a direct request flow", app_js)
self.assertIn('function headerTaskElements()', app_js)
self.assertIn('function isActiveTask(task)', app_js)
self.assertIn('function activeTasksFromItems(items)', app_js)
self.assertIn('function activeTaskChipLabel(count)', app_js)
self.assertIn('function shouldPollHeaderTasks()', app_js)
self.assertIn('function scheduleHeaderTaskPolling()', app_js)
self.assertIn('function setHeaderTaskPopoverOpen(nextOpen)', app_js)
self.assertIn('function renderHeaderTaskPopover(items)', app_js)
self.assertIn('function renderHeaderTaskChip(items)', app_js)
self.assertIn('function updateHeaderTaskState(taskItems)', app_js)
self.assertIn('function applyTaskSnapshot(taskItems)', app_js)
self.assertIn('return `${count} active task${count === 1 ? "" : "s"}`;', app_js)
self.assertIn('ACTIVE_TASK_OPERATIONS.has(task.operation)', app_js)
self.assertIn('headerTaskState.activeItems = activeTasksFromItems(taskItems);', app_js)
self.assertIn('const open = Boolean(nextOpen) && headerTaskState.activeItems.length > 0;', app_js)
self.assertIn('const headerTasks = headerTaskElements();', app_js)
self.assertIn('headerTasks.chipButton.onclick = (event) => {', app_js)
self.assertIn('headerTasks.logsButton.onclick = () => {', app_js)
self.assertIn('setHeaderTaskPopoverOpen(!headerTaskState.popoverOpen);', app_js)
self.assertIn('setHeaderTaskPopoverOpen(false);', app_js)
self.assertIn('updateHeaderTaskState(items);', app_js)
self.assertIn('renderTaskItems(applyTaskSnapshot(data.items));', app_js)
self.assertIn('function renderTaskItems(items)', app_js) self.assertIn('function renderTaskItems(items)', app_js)
self.assertIn('async function loadTasksForSettings()', app_js) self.assertIn('async function loadTasksForSettings()', app_js)
self.assertIn('async function loadLogsAndTasksForSettings()', app_js) self.assertIn('async function loadLogsAndTasksForSettings()', app_js)
@@ -529,6 +858,7 @@ class UiSmokeGoldenTest(unittest.TestCase):
self.assertIn('setStatus(`Download requested: ${anchor.download}`);', app_js) self.assertIn('setStatus(`Download requested: ${anchor.download}`);', app_js)
self.assertIn('"/api/files/download/archive-prepare"', app_js) self.assertIn('"/api/files/download/archive-prepare"', app_js)
self.assertIn('"/api/files/duplicate"', app_js) self.assertIn('"/api/files/duplicate"', app_js)
self.assertIn('"/api/files/delete"', app_js)
self.assertIn('`/api/tasks/${encodeURIComponent(taskId)}`', app_js) self.assertIn('`/api/tasks/${encodeURIComponent(taskId)}`', app_js)
self.assertIn('`/api/files/download/archive/${encodeURIComponent(taskId)}`', app_js) self.assertIn('`/api/files/download/archive/${encodeURIComponent(taskId)}`', app_js)
self.assertIn('`/api/files/download/archive/${encodeURIComponent(taskId)}/cancel`', app_js) self.assertIn('`/api/files/download/archive/${encodeURIComponent(taskId)}/cancel`', app_js)
@@ -553,6 +883,7 @@ class UiSmokeGoldenTest(unittest.TestCase):
self.assertIn('function startContextMenuDownload()', app_js) self.assertIn('function startContextMenuDownload()', app_js)
self.assertIn('function startContextMenuRename()', app_js) self.assertIn('function startContextMenuRename()', app_js)
self.assertIn('function startDuplicateSelected()', app_js) self.assertIn('function startDuplicateSelected()', app_js)
self.assertIn('async function deleteSelected()', app_js)
self.assertIn('function startContextMenuDuplicate()', app_js) self.assertIn('function startContextMenuDuplicate()', app_js)
self.assertIn('function startContextMenuCopy()', app_js) self.assertIn('function startContextMenuCopy()', app_js)
self.assertIn('function startContextMenuMove()', app_js) self.assertIn('function startContextMenuMove()', app_js)
@@ -637,6 +968,7 @@ class UiSmokeGoldenTest(unittest.TestCase):
self.assertIn('Delete selected items and folder contents?', app_js) self.assertIn('Delete selected items and folder contents?', app_js)
self.assertIn('async function loadSettings()', app_js) self.assertIn('async function loadSettings()', app_js)
self.assertIn('await loadSettings();', app_js) self.assertIn('await loadSettings();', app_js)
self.assertIn('await refreshTasksSnapshot();', app_js)
self.assertIn('settings.showThumbnailsInput.onchange = handleShowThumbnailsChange;', app_js) self.assertIn('settings.showThumbnailsInput.onchange = handleShowThumbnailsChange;', app_js)
self.assertIn('settings.generalSaveButton.onclick = handlePreferredStartupPathSave;', app_js) self.assertIn('settings.generalSaveButton.onclick = handlePreferredStartupPathSave;', app_js)
self.assertIn('settings.interfaceSaveButton.onclick = handleInterfaceSave;', app_js) self.assertIn('settings.interfaceSaveButton.onclick = handleInterfaceSave;', app_js)
@@ -676,6 +1008,8 @@ class UiSmokeGoldenTest(unittest.TestCase):
self.assertIn('function ensureFolderUploadPicker()', app_js) self.assertIn('function ensureFolderUploadPicker()', app_js)
self.assertIn('function openFolderPicker()', app_js) self.assertIn('function openFolderPicker()', app_js)
self.assertIn('function uploadModalElements()', app_js) self.assertIn('function uploadModalElements()', app_js)
self._run_header_task_chip_behavior_check(app_js)
self._run_task_snapshot_sync_behavior_check(app_js)
self.assertIn('function setUploadModalVisible(', app_js) self.assertIn('function setUploadModalVisible(', app_js)
self.assertIn('function updateUploadModalDisplay(', app_js) self.assertIn('function updateUploadModalDisplay(', app_js)
@@ -0,0 +1,93 @@
from __future__ import annotations
import sys
import tempfile
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[3]))
from backend.app.db.history_repository import HistoryRepository
from backend.app.db.task_repository import TaskRepository
from backend.app.services.task_recovery_service import reconcile_persisted_incomplete_tasks
class TaskRecoveryServiceTest(unittest.TestCase):
def setUp(self) -> None:
self.temp_dir = tempfile.TemporaryDirectory()
self.db_path = str(Path(self.temp_dir.name) / "tasks.db")
self.task_repo = TaskRepository(self.db_path)
self.history_repo = HistoryRepository(self.db_path)
def tearDown(self) -> None:
self.temp_dir.cleanup()
def test_reconcile_persisted_incomplete_tasks_marks_old_non_terminal_tasks_failed(self) -> None:
self.task_repo.insert_task_for_testing(
{
"id": "task-running",
"operation": "copy",
"status": "running",
"source": "storage1/a.txt",
"destination": "storage2/a.txt",
"created_at": "2026-03-10T10:00:00Z",
"started_at": "2026-03-10T10:00:01Z",
"current_item": "storage1/a.txt",
}
)
self.history_repo.create_entry(
entry_id="task-running",
operation="copy",
status="queued",
source="storage1/a.txt",
destination="storage2/a.txt",
created_at="2026-03-10T10:00:00Z",
)
self.task_repo.insert_task_for_testing(
{
"id": "task-ready",
"operation": "download",
"status": "ready",
"source": "single_directory_zip",
"destination": "docs.zip",
"created_at": "2026-03-10T10:02:00Z",
"finished_at": "2026-03-10T10:03:00Z",
}
)
changed = reconcile_persisted_incomplete_tasks(self.task_repo, self.history_repo)
self.assertEqual(changed, ["task-running"])
task = self.task_repo.get_task("task-running")
self.assertEqual(task["status"], "failed")
self.assertEqual(task["error_code"], "task_interrupted")
self.assertEqual(task["error_message"], "Task was interrupted before completion")
self.assertIsNone(task["current_item"])
history = self.history_repo.list_history(limit=5)[0]
self.assertEqual(history["id"], "task-running")
self.assertEqual(history["status"], "failed")
self.assertEqual(history["error_code"], "task_interrupted")
ready_task = self.task_repo.get_task("task-ready")
self.assertEqual(ready_task["status"], "ready")
def test_reconcile_persisted_incomplete_tasks_is_noop_when_all_tasks_terminal(self) -> None:
self.task_repo.insert_task_for_testing(
{
"id": "task-completed",
"operation": "move",
"status": "completed",
"source": "storage1/a.txt",
"destination": "storage2/a.txt",
"created_at": "2026-03-10T10:00:00Z",
"finished_at": "2026-03-10T10:00:02Z",
}
)
changed = reconcile_persisted_incomplete_tasks(self.task_repo, self.history_repo)
self.assertEqual(changed, [])
self.assertEqual(self.task_repo.get_task("task-completed")["status"], "completed")
if __name__ == "__main__":
unittest.main()
@@ -107,6 +107,64 @@ class TaskRepositoryTest(unittest.TestCase):
self.assertEqual(task["status"], "cancelled") self.assertEqual(task["status"], "cancelled")
self.assertIsNotNone(task["finished_at"]) self.assertIsNotNone(task["finished_at"])
def test_reconcile_incomplete_tasks_marks_non_terminal_failed(self) -> None:
self.repo.insert_task_for_testing(
{
"id": "task-running",
"operation": "copy",
"status": "running",
"source": "storage1/a",
"destination": "storage2/a",
"created_at": "2026-03-10T09:00:00Z",
"started_at": "2026-03-10T09:00:01Z",
"current_item": "storage1/a",
}
)
self.repo.insert_task_for_testing(
{
"id": "task-completed",
"operation": "copy",
"status": "completed",
"source": "storage1/b",
"destination": "storage2/b",
"created_at": "2026-03-10T09:05:00Z",
"finished_at": "2026-03-10T09:06:00Z",
}
)
changed = self.repo.reconcile_incomplete_tasks()
running = self.repo.get_task("task-running")
completed = self.repo.get_task("task-completed")
self.assertEqual(changed, ["task-running"])
self.assertEqual(running["status"], "failed")
self.assertEqual(running["error_code"], "task_interrupted")
self.assertEqual(running["error_message"], "Task was interrupted before completion")
self.assertIsNone(running["current_item"])
self.assertIsNotNone(running["finished_at"])
self.assertEqual(completed["status"], "completed")
def test_reconcile_incomplete_tasks_removes_stale_artifact(self) -> None:
created = self.repo.create_task(
operation="download",
source="storage1/docs",
destination="docs.zip",
status="preparing",
)
self.repo.upsert_artifact(
task_id=created["id"],
file_path="/tmp/docs.zip.partial",
file_name="docs.zip",
expires_at="2026-03-10T10:30:00Z",
)
changed = self.repo.reconcile_incomplete_tasks()
task = self.repo.get_task(created["id"])
self.assertEqual(changed, [created["id"]])
self.assertEqual(task["status"], "failed")
self.assertIsNone(self.repo.get_artifact(created["id"]))
def test_migrates_legacy_tasks_schema_missing_source_destination(self) -> None: def test_migrates_legacy_tasks_schema_missing_source_destination(self) -> None:
legacy_db_path = Path(self.temp_dir.name) / "legacy.db" legacy_db_path = Path(self.temp_dir.name) / "legacy.db"
conn = sqlite3.connect(legacy_db_path) conn = sqlite3.connect(legacy_db_path)
+178 -2
View File
@@ -114,6 +114,16 @@ let settingsState = {
selectedColorMode: "dark", selectedColorMode: "dark",
zipDownloadLimits: null, 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 = [ const VALID_THEME_FAMILIES = [
"default", "default",
"macos-soft", "macos-soft",
@@ -189,6 +199,17 @@ function setStatus(msg) {
document.getElementById("status").textContent = 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) { function setError(id, msg) {
if (id === "actions-error") { if (id === "actions-error") {
document.getElementById(id).textContent = ""; document.getElementById(id).textContent = "";
@@ -1266,7 +1287,7 @@ async function uploadFileRequest(targetPath, file, overwrite = false) {
async function refreshTasksSnapshot() { async function refreshTasksSnapshot() {
try { try {
const data = await apiRequest("GET", "/api/tasks"); const data = await apiRequest("GET", "/api/tasks");
state.lastTaskCount = Array.isArray(data.items) ? data.items.length : state.lastTaskCount; applyTaskSnapshot(data.items);
} catch (_) { } catch (_) {
// Task list panel is not visible in current UI; silently keep flow stable. // 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) { function renderHistoryItems(items) {
const elements = settingsElements(); const elements = settingsElements();
const renderKey = JSON.stringify(Array.isArray(items) ? items : []); const renderKey = JSON.stringify(Array.isArray(items) ? items : []);
@@ -3957,7 +4110,7 @@ async function loadHistoryForSettings() {
async function loadTasksForSettings() { async function loadTasksForSettings() {
const data = await apiRequest("GET", "/api/tasks"); const data = await apiRequest("GET", "/api/tasks");
renderTaskItems(data.items || []); renderTaskItems(applyTaskSnapshot(data.items));
settingsState.tasksLoaded = true; settingsState.tasksLoaded = true;
} }
@@ -4517,6 +4670,11 @@ function handleKeyboardShortcuts(event) {
if (!shouldHandleShortcut(event.target)) { if (!shouldHandleShortcut(event.target)) {
return; 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); const isInfoShortcut = event.key === "Enter" && !event.shiftKey && !event.altKey && (event.metaKey || event.ctrlKey);
if (isInfoShortcut) { if (isInfoShortcut) {
@@ -4627,6 +4785,19 @@ function setupEvents() {
setupPaneEvents("right"); setupPaneEvents("right");
document.addEventListener("keydown", handleKeyboardShortcuts); document.addEventListener("keydown", handleKeyboardShortcuts);
document.getElementById("theme-toggle").onclick = toggleTheme; 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-btn").onclick = openUploadPicker;
document.getElementById("upload-menu-toggle").onclick = (event) => { document.getElementById("upload-menu-toggle").onclick = (event) => {
event.stopPropagation(); event.stopPropagation();
@@ -4715,6 +4886,10 @@ function setupEvents() {
} else { } else {
closeUploadMenu(); closeUploadMenu();
} }
const headerTaskContainer = headerTaskElements().container;
if (headerTaskContainer && !headerTaskContainer.contains(event.target)) {
setHeaderTaskPopoverOpen(false);
}
const contextMenu = contextMenuElements().menu; const contextMenu = contextMenuElements().menu;
if (contextMenu && !contextMenu.contains(event.target)) { if (contextMenu && !contextMenu.contains(event.target)) {
closeContextMenu(); closeContextMenu();
@@ -4914,6 +5089,7 @@ async function init() {
paneState("right").currentPath = "/Volumes"; paneState("right").currentPath = "/Volumes";
await loadBrowsePane("right"); await loadBrowsePane("right");
} }
await refreshTasksSnapshot();
} }
init(); init();
+86
View File
@@ -71,6 +71,92 @@ body {
min-width: 0; 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 { h1, h2, h3 {
margin: 0; margin: 0;
} }
+19
View File
@@ -25,6 +25,25 @@
</div> </div>
<div id="title-zone-actions"> <div id="title-zone-actions">
<div id="status"></div> <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"> <button id="theme-toggle" type="button" aria-label="Toggle theme" title="Toggle theme">
<span id="theme-toggle-icon" aria-hidden="true"></span> <span id="theme-toggle-icon" aria-hidden="true"></span>
</button> </button>