From 492082c2b74147d9f6b4031b31f9ca1f1059abee Mon Sep 17 00:00:00 2001 From: kodi Date: Sun, 15 Mar 2026 13:52:48 +0100 Subject: [PATCH] feat: feedback verbetering 02 --- .../test_ui_smoke_golden.cpython-313.pyc | Bin 81548 -> 86000 bytes .../tests/golden/test_ui_smoke_golden.py | 65 +++++++++++- webui/html/app.js | 98 +++++++++++++++++- webui/html/base.css | 21 ++++ 4 files changed, 176 insertions(+), 8 deletions(-) diff --git a/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc b/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc index 9399e7940e7c00deb20e45f3eed06ff5a4a99917..4bb37681ac048b5277211a7c88daf558a670b6ac 100644 GIT binary patch delta 3713 zcmbVOeNa@_72mtNzydCUz_PG>JeJRGVV4z@1OX8WM$i;ow{=7#kKG5XF7IvLzV(BU z8qJ3~rjdA(JZ)>#*4U3}lQAy^Go8jYA+^S~k;TUb79 zJ7tI8dH39N&pr2d&%Niq{DpqFtSi-9h;6XnARcU`>oaUG-S)z}Ka=)BNt?h(X(R@%Y_cYig&iLB%k_>F8g=by;z zV}qMGmiO_!Jt6-FiR*6{gh1}8E+EbreM=SKnI&U30E%NT3B5WvnvCfFy0{9YpVGG2k_9Fich zekP!j&g|{uc!u4>`uuFSAO6~AZjTCLvG;C!|L#4H@~>`FgfoV4X0;IljCgB3oZpXk zeTJDzqP5RyqsW<4V$+2bmCyCg5jCfqHv-dzCy5%NW&)=65hGSnf?=pFAvY4xVhWs& zDl2SnwitJFTws<+DEH)%fetmS#p9Mt9qI{w-xjk>O*BdxN8u)Xl~FX57sc%Wem-dNPA_)qH_@B*r32W9bCW{6uewTHOUJ&*If#q zdM)rpOXm6}JO@+lSXn``#YN|3$h-_WoAc5#k~Sn=X8C0BYVh$0Kzd6F9Nm}^r-lx` zh7vVwb2D{_{gcFw1Y5Zvc<0JR!O!jlJ-%?Z+ZKts<13+n-{)Z^j;VAwS4M^Io$dJl z;*U^IP8XzXqSWiWJ`vbPQw^32R)HCxw*}5ci)a!2UQUdre9b28n-N1ixm=?{CsWR6 zU1IlR>R2(zEu=Xmk|DF_73T>9yVqrrJgJ{gHD-2RkgFC2`5&03`7w76qvD-Y&W}1I z_OKo<>g?ilCp#6DDaz4}>3XeM3uoKUfU!IU#(PuXL2rt_^=5J#oa)Vmf0m`d1*~lE z7&JP}#-u4-R(isb`03Tq(3g?kfy~Cq{n~!5F?mWSXjdd0iJk6(&T=*}2bV-zL@WM# z5k+QNN;79mshO72ELut}w3KGMa%d?fWfT`uS3WJJR$596)KXdqpSe3N;!ZrX*hPRP z&`rQYz^hbz?GK6ije3m+a=hE|smOqryQdo|vy0OJ7f|2AL?Bfma{OlU8Z#OSDi=NV zy12_oqY0M&Ph36&ziv0KXug@;GU?1Dq*^Gck0}j(Z`o{5lW0W(K>`wi5P|0i93*gn zz#%2+L?c2jr30=fXh$2A+&6!l4{Oh-L0ot%k?2oQalQcLa5B~YEIdeM+m&S48BPP? z%2q1xsO^WUEcmdB+U>@Y*xmkc2#$R;h`&=IYjh)gaJdkAMo+@Tr9$}E=uS8@YC`ca zG+Lyb`nUm6v%mZzmC6-YX*V3cTCCi>s&*D%OC@6<@0Z02Tq{R#?~})Y-<1mdwdK%x z?J=eDI#z~1DN#JXN=Ss$qh@q~xJS=XJV@~n#pfx$K=DP2FHt;9@rcrO`E17?-c1yyA!DPO?M)dXWYwZ?^)P-*AahiJ#u{)|9=mL?v~KjdD;rA zt+!}v@@^iw0Lh;hq3>hNPff?*= zk9t&=lGL>gl+GGH@{Orl7SCGZyJo2;r)pP(?eb^lUit}2?V$NS(@Kh0&^C}a>zTw9 zc|?#$sp`_Yq=8!Jsg1Jheh1pxByv(njz_PeYy)tlTsoH6E;rGNN-W_(k-KxC*$2V53706}I?Y`AH5sh~m=lB^!LoGO{WUUDU^} z4V!Kmo{)c2fXs5M6)l(lQG{W<1Vh4?(DK-tOD(qyP4ZYF?ti}kYwuZ6?#x$1A+pPv z4wM|X`No!Qw+x-~IpXCuv8uMAjutQhbj%G7(56N<(eG;0^h?dhxpaunz4=Z%joMCk?mFwlTA9 PT0>fst;O=w^~myXhQ15c delta 1520 zcmYk6e@qi+7{~9qyOvToC{TVXSmehj1Ef=?;2Z=u-2h=rZDMAjD^Q>cMN138G5Mh= za|(`U?r?76;@swDw0MhT`^ROQOBSPmB18Yu&1__{qzs}Y%jVwqEhb*m&-ZGFvv>Z7a@RBoEI}&z2J0ymb0>{C8v7q>>tUSLe0_Sp{4T`exVVqfy zIn~(_(dHP7uu3q;n&ZeP-kgATlL?mL$?6Tk%ku>TSI-#}@wVwReU!b%<7{@Vqu+tr(cR!+BQmJP)?)6 zRqts~_HWjRc(&pPd{nUxvgX$iSg#Z{?*Nm@ z5QxLzA$hFKGOVPT7+M;dy^Xb0+6>Ju$I~86UKxvL+|QiO+%2ewfa6-ee`?snSmlv$ z%&U^pqUg}kZ0IcIp7)H=xRXrbZlq|Ua8WcZ+E1rBJ#2 z)vaXojp^}=YulLWuj`nuTT<)w4pz^S`tFDxbz|F@c8mq;Q%m~e*c#>w9bd!t7q6z` z*72jLzmkgg$2)L*A_iM0Ud7)ghH&_X4c{cS{-%zqQgE{fnBnN8j_IXID>G!=O2?$h zbfx^(lHt=S9X@D_!gr^`@t=OGX^MX;8!-IqR7{-Cr)Mj^>1^n$>V(ryF!h|E9h7EQ zz(<%pn+6{fCP(Q=_hcSV{!DVvXD?$I56<3K*OAg)uk8M7J_ywhlTO}y6&XtqUfgRq1Z=Zqo|SJ$bls29c|BnZV38={A|ll z38Rn;BceQ72;uV2>j^URV28Z;99gBU2Hogf9z<$2+V^&z}cr^xGnEg{(b7ZzC`pSsB%CJ#B~-8VWW9yY*7K zFSyBzg>(1HwlY9CF3*>NC&x!y7bxh;D7rU4S+njPv79`JMm}H}k&kVG5>sG&>N`Yn zmf{@2ooJh_#bft4YixFp$L*~4dK@h_oBO=1D~Gj7jl9YX3szNGK*dg8wRWLcy-Tfn z86MTjj&c|TpS)NOo6;`QtBFl^r^|hwUY)**-BGlud7-Jf*4yZK(S1d(sesDd(n!c1 p() isActiveTask(task)), "All filtered tasks should be active"); assert(activeTasks.some((task) => task.operation === "delete"), "Delete should count once it uses the shared task flow"); assert(activeTasks.some((task) => task.status === "cancelling"), "Cancelling tasks should remain visible while stopping"); - assert(activeTaskChipLabel(activeTasks.length) === "5 active tasks", "Chip label should reflect active task count"); + assert(activeTaskChipLabel(activeTasks) === "5 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"); @@ -378,13 +385,45 @@ class UiSmokeGoldenTest(unittest.TestCase): 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 === 5, "Popover should render only active file-action tasks"); + const moveRow = elements["header-task-popover-list"].children[1]; + const moveProgress = moveRow.children[3]; + const moveCurrent = moveRow.children[4]; + assert(moveProgress.textContent === "1/3", "Popover should show done/total progress when available"); + assert(moveCurrent.textContent === "b.mkv", "Popover should show compact current item"); + const cancellingRow = elements["header-task-popover-list"].children[4]; + const cancellingProgress = cancellingRow.children[3]; + const cancellingCurrent = cancellingRow.children[4]; + const cancellingSubtext = cancellingRow.children[5]; + assert(cancellingProgress.textContent === "1/4", "Cancelling tasks should keep progress visible"); + assert(cancellingCurrent.textContent === "nested/final-file.txt", "Cancelling tasks should show current item"); + assert(cancellingSubtext.textContent === "Stopping after current item...", "Cancelling tasks should explain stop semantics"); const firstActionButton = elements["header-task-popover-list"].children[0].children[3].children[0]; - const cancellingActionButton = elements["header-task-popover-list"].children[4].children[3].children[0]; + const cancellingActionButton = elements["header-task-popover-list"].children[4].children[6].children[0]; assert(firstActionButton.textContent === "Stop", "Queued/running tasks should expose a Stop action"); assert(!firstActionButton.disabled, "Queued/running tasks should be cancellable"); assert(cancellingActionButton.textContent === "Stopping...", "Cancelling tasks should show stopping state"); assert(cancellingActionButton.disabled, "Cancelling tasks should not expose a second stop action"); + updateHeaderTaskState([ + {{ id: "single-copy", operation: "copy", status: "running", source: "/src/a", destination: "/dst/a", done_items: 7, total_items: 20, current_item: "season1/episode07.mkv" }}, + ]); + assert(elements["header-task-chip-label"].textContent === "Copy 7/20", "Single copy task should show compact item progress in chip"); + + updateHeaderTaskState([ + {{ id: "single-dup", operation: "duplicate", status: "running", source: "/src/a", destination: "/dst/a copy", done_items: 3, total_items: 12, current_item: "nested/file.txt" }}, + ]); + assert(elements["header-task-chip-label"].textContent === "Duplicate 3/12", "Single duplicate task should show compact item progress in chip"); + + updateHeaderTaskState([ + {{ id: "single-move", operation: "move", status: "running", source: "/src/dir", destination: "/dst/dir", done_items: 0, total_items: 1, current_item: "Folder" }}, + ]); + assert(elements["header-task-chip-label"].textContent === "Move running", "Single move task should stay coarse in chip"); + + updateHeaderTaskState([ + {{ id: "single-cancelling", operation: "copy", status: "cancelling", source: "/src/a", destination: "/dst/a", done_items: 2, total_items: 5, current_item: "nested/file.txt" }}, + ]); + assert(elements["header-task-chip-label"].textContent === "Copy cancelling", "Single cancelling task should surface cancelling state in chip"); + 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" }}, @@ -416,7 +455,13 @@ class UiSmokeGoldenTest(unittest.TestCase): self._extract_js_function(app_js, "activeTasksFromItems"), self._extract_js_function(app_js, "taskIsCancellable"), self._extract_js_function(app_js, "cancelTaskRequest"), + self._extract_js_function(app_js, "formatTaskOperationLabel"), + self._extract_js_function(app_js, "hasMeaningfulItemProgress"), + self._extract_js_function(app_js, "canShowChipItemProgress"), + self._extract_js_function(app_js, "compactTaskCurrentItem"), self._extract_js_function(app_js, "activeTaskChipLabel"), + self._extract_js_function(app_js, "taskProgressText"), + self._extract_js_function(app_js, "taskProgressSubtext"), self._extract_js_function(app_js, "headerTaskRenderKey"), self._extract_js_function(app_js, "shouldPollHeaderTasks"), self._extract_js_function(app_js, "stopHeaderTaskPolling"), @@ -527,7 +572,7 @@ class UiSmokeGoldenTest(unittest.TestCase): {{ 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(elements["header-task-chip-label"].textContent === "Copy running", "Single active task should show compact task status"); assert(headerTaskState.activeItems.length === 1, "Snapshot should store active task state"); applyTaskSnapshot([ @@ -810,7 +855,13 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('function activeTasksFromItems(items)', app_js) self.assertIn('function taskIsCancellable(task)', app_js) self.assertIn('async function cancelTaskRequest(taskId)', app_js) - self.assertIn('function activeTaskChipLabel(count)', app_js) + self.assertIn('function formatTaskOperationLabel(task)', app_js) + self.assertIn('function hasMeaningfulItemProgress(task)', app_js) + self.assertIn('function canShowChipItemProgress(task)', app_js) + self.assertIn('function compactTaskCurrentItem(task)', app_js) + self.assertIn('function activeTaskChipLabel(items)', app_js) + self.assertIn('function taskProgressText(task)', app_js) + self.assertIn('function taskProgressSubtext(task)', app_js) self.assertIn('function shouldPollHeaderTasks()', app_js) self.assertIn('function scheduleHeaderTaskPolling()', app_js) self.assertIn('function setHeaderTaskPopoverOpen(nextOpen)', app_js) @@ -819,6 +870,10 @@ class UiSmokeGoldenTest(unittest.TestCase): 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('return task.operation === "copy" || task.operation === "duplicate";', app_js) + self.assertIn('return `${action} ${task.done_items}/${task.total_items}`;', app_js) + self.assertIn('return `${action} running`;', app_js) + self.assertIn('return "Stopping after current item...";', 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) diff --git a/webui/html/app.js b/webui/html/app.js index 07e281a..b6bd5fc 100644 --- a/webui/html/app.js +++ b/webui/html/app.js @@ -3895,8 +3895,78 @@ async function cancelTaskRequest(taskId) { return apiRequest("POST", `/api/tasks/${encodeURIComponent(taskId)}/cancel`); } -function activeTaskChipLabel(count) { - return `${count} active task${count === 1 ? "" : "s"}`; +function formatTaskOperationLabel(task) { + const operation = String(task?.operation || ""); + if (!operation) { + return "Task"; + } + return operation.charAt(0).toUpperCase() + operation.slice(1); +} + +function hasMeaningfulItemProgress(task) { + return typeof task?.done_items === "number" && typeof task?.total_items === "number" && task.total_items > 0; +} + +function canShowChipItemProgress(task) { + if (!hasMeaningfulItemProgress(task)) { + return false; + } + return task.operation === "copy" || task.operation === "duplicate"; +} + +function compactTaskCurrentItem(task) { + if (!task?.current_item) { + return ""; + } + const value = String(task.current_item).replace(/\\/g, "/"); + if (value.length <= 44) { + return value; + } + const parts = value.split("/").filter(Boolean); + if (parts.length >= 2) { + const shortened = `.../${parts.slice(-2).join("/")}`; + if (shortened.length <= 44) { + return shortened; + } + } + return `...${value.slice(-41)}`; +} + +function activeTaskChipLabel(items) { + const count = Array.isArray(items) ? items.length : 0; + if (count !== 1) { + return `${count} active task${count === 1 ? "" : "s"}`; + } + const task = items[0]; + const action = formatTaskOperationLabel(task); + if (task.status === "cancelling") { + return `${action} cancelling`; + } + if (canShowChipItemProgress(task)) { + return `${action} ${task.done_items}/${task.total_items}`; + } + if (task.status === "queued") { + return `${action} queued`; + } + return `${action} running`; +} + +function taskProgressText(task) { + if (!hasMeaningfulItemProgress(task)) { + return ""; + } + return `${task.done_items}/${task.total_items}`; +} + +function taskProgressSubtext(task) { + if (task?.status === "cancelling") { + return "Stopping after current item..."; + } + const progress = taskProgressText(task); + if (progress) { + return `${progress} items processed`; + } + return ""; } function headerTaskRenderKey(items) { @@ -3984,6 +4054,28 @@ function renderHeaderTaskPopover(items) { meta.className = "header-task-item-meta"; meta.textContent = line.meta; row.append(title, path, meta); + const progressText = taskProgressText(task); + if (progressText) { + const progress = document.createElement("div"); + progress.className = "header-task-item-progress"; + progress.textContent = progressText; + row.append(progress); + } + const currentItem = compactTaskCurrentItem(task); + if (currentItem) { + const current = document.createElement("div"); + current.className = "header-task-item-current"; + current.textContent = currentItem; + current.title = String(task.current_item); + row.append(current); + } + const subtext = taskProgressSubtext(task); + if (subtext) { + const note = document.createElement("div"); + note.className = "header-task-item-subtext"; + note.textContent = subtext; + row.append(note); + } if (taskIsCancellable(task) || task.status === "cancelling") { const actions = document.createElement("div"); actions.className = "header-task-item-actions"; @@ -4020,7 +4112,7 @@ function renderHeaderTaskChip(items) { } const hasActiveTasks = Array.isArray(items) && items.length > 0; elements.container.classList.toggle("hidden", !hasActiveTasks); - elements.chipLabel.textContent = activeTaskChipLabel(items.length); + elements.chipLabel.textContent = activeTaskChipLabel(items); if (!hasActiveTasks) { headerTaskState.lastRenderKey = ""; setHeaderTaskPopoverOpen(false); diff --git a/webui/html/base.css b/webui/html/base.css index af5eea1..731dcaa 100644 --- a/webui/html/base.css +++ b/webui/html/base.css @@ -157,6 +157,27 @@ body { word-break: break-word; } +.header-task-item-progress { + margin-top: 5px; + font-size: 12px; + font-weight: 700; + color: var(--color-text-primary); +} + +.header-task-item-current, +.header-task-item-subtext { + margin-top: 4px; + font-size: 12px; + color: var(--color-text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.header-task-item-current { + color: var(--color-text-primary); +} + .header-task-item-actions { margin-top: 8px; display: flex;