feat: voortgang copy/duplicate/move in headerbar
This commit is contained in:
@@ -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 risico’s:
|
||||
|
||||
- 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.
Binary file not shown.
Binary file not shown.
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:
|
||||
db_path = Path(self._db_path)
|
||||
if db_path.parent and str(db_path.parent) not in {"", "."}:
|
||||
|
||||
@@ -8,6 +8,7 @@ from pathlib import Path
|
||||
|
||||
VALID_STATUSES = {"queued", "running", "completed", "failed", "requested", "preparing", "ready", "cancelled"}
|
||||
VALID_OPERATIONS = {"copy", "move", "download", "duplicate"}
|
||||
NON_TERMINAL_STATUSES = ("queued", "running", "requested", "preparing")
|
||||
TASK_MIGRATION_COLUMNS: dict[str, str] = {
|
||||
"operation": "TEXT NOT NULL DEFAULT 'copy'",
|
||||
"status": "TEXT NOT NULL DEFAULT 'queued'",
|
||||
@@ -394,6 +395,44 @@ class TaskRepository:
|
||||
with self._connection() as conn:
|
||||
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:
|
||||
rows = conn.execute("PRAGMA table_info(tasks)").fetchall()
|
||||
existing_columns = {row["name"] for row in rows}
|
||||
|
||||
@@ -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_settings import router as settings_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.services.task_recovery_service import reconcile_persisted_incomplete_tasks
|
||||
|
||||
configure_logging()
|
||||
|
||||
@@ -40,6 +42,14 @@ app.include_router(history_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)
|
||||
async def handle_app_error(_: Request, exc: AppError) -> JSONResponse:
|
||||
return JSONResponse(
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -231,6 +231,300 @@ class UiSmokeGoldenTest(unittest.TestCase):
|
||||
)
|
||||
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:
|
||||
mount = self._ui_mount()
|
||||
self.assertIsInstance(mount.app, StaticFiles)
|
||||
@@ -256,6 +550,11 @@ class UiSmokeGoldenTest(unittest.TestCase):
|
||||
self.assertIn('/ui/assets/img/logo.svg', body)
|
||||
self.assertIn('id="title-zone-actions"', 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-icon"', body)
|
||||
self.assertIn('id="left-pane"', body)
|
||||
@@ -438,6 +737,10 @@ class UiSmokeGoldenTest(unittest.TestCase):
|
||||
self.assertIn("#title-brand {", base_css)
|
||||
self.assertIn("#title-logo {", 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(".settings-activity-grid {", 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 inferDownloadTaskContext(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('async function loadTasksForSettings()', 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('"/api/files/download/archive-prepare"', 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/files/download/archive/${encodeURIComponent(taskId)}`', 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 startContextMenuRename()', app_js)
|
||||
self.assertIn('function startDuplicateSelected()', app_js)
|
||||
self.assertIn('async function deleteSelected()', app_js)
|
||||
self.assertIn('function startContextMenuDuplicate()', app_js)
|
||||
self.assertIn('function startContextMenuCopy()', 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('async function 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.generalSaveButton.onclick = handlePreferredStartupPathSave;', 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 openFolderPicker()', 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 updateUploadModalDisplay(', app_js)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -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.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:
|
||||
legacy_db_path = Path(self.temp_dir.name) / "legacy.db"
|
||||
conn = sqlite3.connect(legacy_db_path)
|
||||
|
||||
+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