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:
|
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 {"", "."}:
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
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)
|
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)
|
||||||
|
|||||||
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.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
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user