Repository navigation
Expand file tree
/
Copy pathTaskStatusBar.tsx
More file actions
177 lines (170 loc) Β· 7 KB
/
Copy pathTaskStatusBar.tsx
File metadata and controls
177 lines (170 loc) Β· 7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { Loader2, X, ChevronDown, ChevronUp } from "lucide-react";
import { usePrefersReducedMotion } from "../../hooks/usePrefersReducedMotion";
import { useTasks } from "../../hooks/useTasks";
import { cancelTask, type TaskSnapshot } from "../../lib/tauri/tasks";
/**
* One place that says what is running, and lets the user stop it
* (issue #601).
*
* Before this, a scan reported into the Library view, an analysis sweep
* into a settings card, and a mirror walk or a backup nowhere at all β
* so "why is this machine busy" had no answer anywhere in the app.
*
* Three decisions worth keeping:
*
* - **Subscribe before snapshotting** β see [`useTasks`], which the
* settings card reads too.
* - **A task with no `cancellable` gets no button**, rather than a
* disabled one. A backup's only honest stopping point is "after the
* current profile", which for most people is the end; a greyed
* control there just asks to be clicked.
* - **Collapsed to one line when several things run.** The bar sits
* above the player and must not grow into a panel β three
* simultaneous tasks are unusual but a scan plus its auto-analysis is
* not.
*/
export function TaskStatusBar() {
const { t } = useTranslation();
const tasks = useTasks();
const [expanded, setExpanded] = useState(false);
const stop = useCallback((id: number) => {
void cancelTask(id).catch((err) =>
console.warn("[TaskStatusBar] cancel failed", err),
);
}, []);
if (tasks.length === 0) return null;
const shown = expanded ? tasks : tasks.slice(0, 1);
const hidden = tasks.length - shown.length;
return (
// Not a live region. The counters inside change several times a
// second, and `aria-live` on a container that holds them makes a
// screen reader read the whole bar out over and over β which is
// worse than silence for the user it was meant to help. The
// progress semantics live on each row's `role="progressbar"`, which
// assistive technology reports on demand.
<section
aria-label={t("tasks.barLabel")}
className="shrink-0 border-t border-zinc-200 dark:border-zinc-800 bg-zinc-50 dark:bg-zinc-900 px-4 py-2 space-y-2"
>
{shown.map((task) => (
<TaskRow key={task.id} task={task} onStop={stop} />
))}
{tasks.length > 1 && (
<button
type="button"
onClick={() => setExpanded((value) => !value)}
className="flex items-center gap-1 text-[11px] text-zinc-500 hover:text-zinc-800 dark:text-zinc-400 dark:hover:text-zinc-100 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 rounded"
aria-expanded={expanded}
>
{expanded ? <ChevronDown size={12} /> : <ChevronUp size={12} />}
<span>
{/* `tasks.more` has no entry of its own in the locale
files and is not supposed to: passing `count` makes
i18next resolve the CLDR plural suffixes, so what the
17 files carry is `more_one` / `more_other` (and the
four Russian and six Arabic categories). Looking for
the bare key finds nothing and means nothing. */}
{expanded
? t("tasks.collapse")
: t("tasks.more", { count: hidden })}
</span>
</button>
)}
</section>
);
}
function TaskRow({
task,
onStop,
}: {
task: TaskSnapshot;
onStop: (id: number) => void;
}) {
const { t } = useTranslation();
// Both indicators here are ambient, continuous motion β the kind
// `prefers-reduced-motion` exists for. The counter and the bar still
// say everything the animation was decorating.
const reducedMotion = usePrefersReducedMotion();
// `0` is the wire's way of saying "no idea how much there is" β a
// mirror walk does not know its page count until the server answers.
const determinate = task.total > 0;
const percent = determinate
? Math.min(100, Math.round((task.current / task.total) * 100))
: 0;
// `defaultValue` rather than a bare key: a task kind added on the
// backend without its 17 locale entries should read as itself, not as
// a raw `tasks.kinds.foo` path in the middle of the interface.
const label = t(`tasks.kinds.${task.kind}`, { defaultValue: task.kind });
return (
<div className="flex items-center gap-3">
<Loader2
size={14}
className={`shrink-0 text-emerald-500 ${reducedMotion ? "" : "animate-spin"}`}
aria-hidden="true"
/>
<div className="min-w-0 flex-1">
<div className="flex items-baseline gap-2">
<span className="text-xs font-medium text-zinc-800 dark:text-zinc-100 shrink-0">
{label}
</span>
{task.detail && (
<span
className="text-[11px] text-zinc-500 dark:text-zinc-400 truncate"
title={task.detail}
>
{task.detail}
</span>
)}
{/* With no total yet, the count alone: a scan listing a slow
share knows how many files it has found long before it
knows how many there are, and saying nothing made a walk of
several minutes look frozen. */}
{(determinate || task.current > 0) && (
<span className="text-[11px] text-zinc-500 dark:text-zinc-400 ml-auto shrink-0 tabular-nums">
{determinate ? `${task.current} / ${task.total}` : task.current}
</span>
)}
</div>
<div
className="mt-1 h-1 rounded-full bg-zinc-200 dark:bg-zinc-800 overflow-hidden"
role="progressbar"
aria-valuenow={determinate ? percent : undefined}
aria-valuemin={0}
aria-valuemax={100}
aria-label={label}
>
<div
className={
determinate
? `h-full bg-emerald-500 ${
reducedMotion ? "" : "transition-[width] duration-300"
}`
: `h-full w-1/3 bg-emerald-500 ${
reducedMotion ? "opacity-70" : "animate-pulse"
}`
}
style={determinate ? { width: `${percent}%` } : undefined}
/>
</div>
</div>
{task.cancellable && (
<button
type="button"
onClick={() => onStop(task.id)}
disabled={task.cancelling}
aria-label={t("tasks.cancel", { task: label })}
title={
task.cancelling
? t("tasks.stopping")
: t("tasks.cancel", { task: label })
}
className="shrink-0 p-1.5 rounded-lg text-zinc-500 hover:text-zinc-900 hover:bg-zinc-200 disabled:opacity-40 disabled:hover:bg-transparent dark:text-zinc-400 dark:hover:text-zinc-100 dark:hover:bg-zinc-800 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500"
>
<X size={14} aria-hidden="true" />
</button>
)}
</div>
);
}