Files
proton-drive-linux/crates/pdfs-gui/src/main.rs
T
nvrl e9577f953f fix(cli,core,fuse,gui): say why the mount keeps failing, and back off
A mount that failed on every try retried every 5 s forever, and the
reason was only in the journal: the app and the tray said Connecting...
for days (B263, issue #40). The retry now doubles to a 2-minute ceiling,
and the last failure is kept in mount-failure.json for the app, the
tray, pdfs diagnose and systemctl status.

The early returns in mount left the workers they had started running
beside the next attempt (B264). They are now stopped on every error
path, without telling systemd the daemon is stopping.
2026-10-31 10:49:01 +01:00

772 lines
29 KiB
Rust

//! `pdfs-tray` — system-tray front-end for the Proton Drive Linux client.
//!
//! A StatusNotifierItem (SNI) tray icon whose icon and tooltip say what the
//! drive is doing (synced, syncing, paused, offline, needs attention), with a
//! menu to open the app or the folder, pause sync, connect, hide the icon and
//! stop Proton Drive. It talks to the running mount daemon over the same
//! control socket the CLI uses, so the tray is a thin presentation layer over
//! [`pdfs_core::control`]. Anything that needs a dialog opens `pdfs-app`.
//!
//! GTK4 has no tray widget (GNOME dropped the systray), so the tray itself is an
//! SNI item via the `ksni` crate.
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{Duration, Instant};
use anyhow::{Context, Result};
use ksni::blocking::TrayMethods;
use ksni::menu::StandardItem;
use ksni::{MenuItem, Status, ToolTip, Tray};
use pdfs_core::auth;
use pdfs_core::config::AppDirs;
use pdfs_core::control::{
Event, Feed, JobItem, Request, Response, TransferDirection, TransferItem, daemon_outdated,
follow, send,
};
use pdfs_core::service;
mod i18n;
use i18n::{gettext, gettext_f, ngettext_f};
/// How long the tray lets a burst of events arrive before it repaints. Progress
/// moves several times a second, which is more than a tray menu can show.
const COALESCE: Duration = Duration::from_secs(1);
/// The one word the icon says about the drive, derived from each poll.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Phase {
/// Mounted, online, nothing queued.
Synced,
/// Mounted with changes queued or bytes moving.
Syncing,
Paused,
/// Mounted but the API is unreachable: cached files only.
Offline,
/// Changes keep failing; the user has to look.
Attention,
/// Mounted, but Proton refused the session: only signing in again helps.
SessionExpired,
/// Signed in, but no mount daemon is running.
Disconnected,
SignedOut,
}
impl Phase {
/// A stock symbolic icon for the phase. Stock names follow the panel's
/// theme and colour, and need nothing installed beside the binary.
fn icon(self) -> &'static str {
match self {
Phase::Synced => "folder-remote-symbolic",
Phase::Syncing => "view-refresh-symbolic",
Phase::Paused => "media-playback-pause-symbolic",
Phase::Offline => "network-offline-symbolic",
Phase::Attention => "dialog-warning-symbolic",
Phase::SessionExpired => "dialog-password-symbolic",
Phase::Disconnected | Phase::SignedOut => "network-offline-symbolic",
}
}
}
/// Snapshot of what the tray knows about the daemon, recomputed each poll.
#[derive(Clone)]
struct DriveState {
/// Human-readable first menu line ("Mounted at …", "Not running", …), also
/// the tooltip.
line: String,
phase: Phase,
/// Whether a mount daemon is currently serving the control socket.
mounted: bool,
/// Mountpoint to act on (from the daemon when mounted, else the default).
mountpoint: PathBuf,
/// One-line sync summary of in-flight transfers, empty when idle. Shown as a
/// disabled menu line under the status line.
sync: String,
/// Whether the user has sync paused; decides Pause vs Resume.
paused: bool,
/// Queued changes that keep failing, for the "N issues" item.
failing: u64,
/// Whether the daemon is an older release than the tray, left running by
/// an upgrade; offers the restart that finishes it.
outdated: bool,
}
/// Summarise a work snapshot into one menu line, or empty when idle. A single
/// transfer names the file and its percentage; several collapse to counts so the
/// menu stays one line regardless of queue depth. With nothing moving bytes, a
/// running job speaks for itself — a scan or an index rebuild is still work
/// going on, and the menu saying nothing there reads as "finished". The icon is
/// [`busy`]'s call, not this line's.
fn sync_line(items: &[TransferItem], jobs: &[JobItem]) -> String {
match items {
[] => match jobs.first() {
Some(j) if j.total > 0 => {
let (done, total) = (j.done.to_string(), j.total.to_string());
// Translators: {title} names a running job such as a scan; {done} and {total} are counts.
gettext_f(
"{title} ({done} of {total})",
&[("title", &j.title), ("done", &done), ("total", &total)],
)
}
// Translators: {title} names a running job whose size is not known yet.
Some(j) => gettext_f("{title}…", &[("title", &j.title)]),
None => String::new(),
},
[t] => {
let arrow = match t.direction {
TransferDirection::Download => "↓",
TransferDirection::Upload => "↑",
};
if t.bytes_total == 0 {
// Translators: one transfer of unknown size; {arrow} is ↑ or ↓, {name} a file name.
gettext_f("{arrow} {name}…", &[("arrow", arrow), ("name", &t.name)])
} else {
let pct = (t.bytes_completed * 100 / t.bytes_total.max(1)).min(100);
// Translators: one transfer; {arrow} is ↑ or ↓, {name} a file name, {percent} how much is done.
gettext_f(
"{arrow} {name} ({percent}%)",
&[
("arrow", arrow),
("name", &t.name),
("percent", &pct.to_string()),
],
)
}
}
_ => {
let down = items
.iter()
.filter(|t| t.direction == TransferDirection::Download)
.count();
let up = items.len() - down;
match (down, up) {
// Translators: {n} files are downloading; keep the arrow.
(d, 0) => ngettext_f("↓ {n} downloading", "↓ {n} downloading", d as u64, &[]),
// Translators: {n} files are uploading; keep the arrow.
(0, u) => ngettext_f("↑ {n} uploading", "↑ {n} uploading", u as u64, &[]),
(d, u) => format!("↓ {d} · ↑ {u}"),
}
}
}
}
struct DriveTray {
state: DriveState,
socket: PathBuf,
}
/// Ask the daemon for its status, falling back to the stored session so the
/// menu can still say "Logged in …" / "Not logged in" when no mount is running.
fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState {
match send(socket, &Request::Status) {
Ok(status @ Response::Status { .. }) => {
// Same daemon is up, so a cheap follow-up request says what moves.
let (items, jobs) = match send(socket, &Request::GetQueueStatus) {
Ok(Response::Transfers { items, jobs }) => (items, jobs),
_ => (Vec::new(), Vec::new()),
};
status_state(&status, &items, &jobs, default_mountpoint)
}
// Socket answered but with something unexpected — treat as up but odd.
Ok(_) => unexpected_reply(default_mountpoint),
// The daemon is up but stuck: say so rather than "not connected", and
// keep the mount actions, which still work on the folder itself.
Err(e) if e.is_timeout() => DriveState {
line: gettext("Proton Drive isn't responding"),
phase: Phase::Attention,
mounted: true,
mountpoint: default_mountpoint.to_path_buf(),
sync: String::new(),
paused: false,
failing: 0,
outdated: false,
},
// No daemon: describe login state instead so the menu is still useful.
Err(_) => {
let (line, phase) = match auth::load() {
Ok(s) if matches!(auth::key_state(), Ok(auth::KeyState::Locked)) => (
// Translators: {username} is the Proton account name. The
// account has a mailbox password that has to be entered.
gettext_f(
"Signed in as {username} — locked",
&[("username", &s.username)],
),
Phase::Attention,
),
// A daemon whose mount fails on every try never opens the
// socket; the app says why (B263).
Ok(s)
if socket
.parent()
.and_then(pdfs_core::mountfail::read)
.is_some() =>
{
(
// Translators: {username} is the Proton account name. The
// service keeps failing to start; the app shows the error.
gettext_f(
"Signed in as {username} — can't open your Drive",
&[("username", &s.username)],
),
Phase::Attention,
)
}
Ok(s) => (
// Translators: {username} is the Proton account name.
gettext_f(
"Signed in as {username} — not connected",
&[("username", &s.username)],
),
Phase::Disconnected,
),
Err(pdfs_core::Error::NotLoggedIn) => (gettext("Not signed in"), Phase::SignedOut),
Err(e) => (
// Translators: {error} is an error message, in English.
gettext_f("Error: {error}", &[("error", &e.to_string())]),
Phase::Attention,
),
};
DriveState {
line,
phase,
mounted: false,
mountpoint: default_mountpoint.to_path_buf(),
sync: String::new(),
paused: false,
failing: 0,
outdated: false,
}
}
}
}
/// What the tray says when the daemon answers with something it did not ask
/// for: up, but odd.
fn unexpected_reply(default_mountpoint: &Path) -> DriveState {
DriveState {
line: gettext("Proton Drive sent an unexpected reply"),
phase: Phase::Attention,
mounted: true,
mountpoint: default_mountpoint.to_path_buf(),
sync: String::new(),
paused: false,
failing: 0,
outdated: false,
}
}
/// The state a running daemon's [`Response::Status`] describes, with `items`
/// and `jobs` saying what it is working on right now.
fn status_state(
status: &Response,
items: &[TransferItem],
jobs: &[JobItem],
default_mountpoint: &Path,
) -> DriveState {
let Response::Status {
mountpoint,
pinned,
online,
pending_uploads,
pending_changes,
failing_ops,
paused,
session_expired,
tokens_unsaved,
version,
..
} = status
else {
return unexpected_reply(default_mountpoint);
};
let (pinned, online, failing_ops, paused) = (*pinned, *online, *failing_ops, *paused);
let queued = i18n::pending_summary(*pending_uploads, *pending_changes);
let outdated = daemon_outdated(version);
let phase = if *session_expired {
Phase::SessionExpired
} else if *tokens_unsaved || outdated {
Phase::Attention
} else {
phase_of(
paused,
failing_ops,
online,
queued.is_some() || busy(items, jobs),
)
};
DriveState {
line: match (online, queued) {
// Outranks everything: it is why nothing else moves, and the one
// thing that fixes it is the user's to do.
_ if *session_expired => gettext("Session expired — sign in again"),
// Sync goes on meanwhile, but a reboot ends it until the next login.
_ if *tokens_unsaved => gettext("Sign-in not saved — unlock your keyring"),
// An upgrade replaced the files but not the running service, which
// keeps the old release's bugs until it restarts.
_ if outdated => gettext("Update not finished — restart Proton Drive"),
_ if paused => gettext("Sync paused"),
_ if failing_ops > 0 => ngettext_f(
"{n} change needs attention",
"{n} changes need attention",
failing_ops,
&[],
),
// Translators: {mountpoint} is a folder path; {n} is the number of pinned items.
(true, None) => ngettext_f(
"Up to date in {mountpoint} ({n} pinned)",
"Up to date in {mountpoint} ({n} pinned)",
pinned as u64,
&[("mountpoint", mountpoint)],
),
// Translators: {queued} is a queue summary such as "3 uploads queued"; {n} is the number of pinned items.
(true, Some(q)) => ngettext_f(
"Syncing — {queued} ({n} pinned)",
"Syncing — {queued} ({n} pinned)",
pinned as u64,
&[("queued", &q)],
),
// Translators: {n} is the number of pinned items.
(false, None) => ngettext_f(
"Offline — cached files only ({n} pinned)",
"Offline — cached files only ({n} pinned)",
pinned as u64,
&[],
),
// Translators: {queued} is a queue summary such as "3 uploads queued".
(false, Some(q)) => gettext_f("Offline — {queued}", &[("queued", &q)]),
},
phase,
mounted: true,
paused,
mountpoint: PathBuf::from(mountpoint),
sync: sync_line(items, jobs),
failing: failing_ops,
outdated,
}
}
/// Whether the daemon is changing something: bytes moving, or a job the user
/// is waiting on. A mirror folder's routine scan and the like are background
/// jobs; they still get the menu line, but the icon spinning every two minutes
/// for a scan that finds nothing reads as constant syncing.
fn busy(items: &[TransferItem], jobs: &[JobItem]) -> bool {
!items.is_empty() || jobs.iter().any(|j| !j.background)
}
/// The phase a running mount is in. A pause is the user's own doing and says
/// so first; failures outrank everything else the daemon reports.
fn phase_of(paused: bool, failing: u64, online: bool, busy: bool) -> Phase {
if paused {
Phase::Paused
} else if failing > 0 {
Phase::Attention
} else if !online {
Phase::Offline
} else if busy {
Phase::Syncing
} else {
Phase::Synced
}
}
/// "3 issues — View": the menu item that opens the Sync page's queue.
fn issues_label(failing: u64) -> String {
// Translators: a menu item that opens the list of failing changes.
ngettext_f("{n} issue — View", "{n} issues — View", failing, &[])
}
fn open_folder(mountpoint: &Path) {
pdfs_core::opener::open_default(mountpoint, true);
}
/// Launch (or, since it's `SingleMainWindow`, raise) the desktop app with
/// `args`. The app binary lives next to the tray binary; fall back to the bare
/// name so a PATH install still works.
fn open_manager(args: &[&str]) {
let exe = std::env::current_exe()
.ok()
.and_then(|p| p.parent().map(|d| d.join("pdfs-app")))
.filter(|p| p.exists())
.unwrap_or_else(|| PathBuf::from("pdfs-app"));
match Command::new(&exe).args(args).spawn() {
Ok(child) => pdfs_core::opener::reap(child),
Err(e) => tracing::error!("failed to launch {}: {e}", exe.display()),
}
}
/// Remember that the user hid the icon, so the next login's autostart and the
/// app both leave it away, then exit.
fn hide_tray() {
if let Ok(dirs) = AppDirs::new() {
let mut config = dirs.load_config();
config.tray_hidden = true;
if let Err(e) = dirs.save_config(&config) {
tracing::warn!("hide tray icon: {e}");
}
}
std::process::exit(0);
}
impl Tray for DriveTray {
fn id(&self) -> String {
"io.narl.proton-drive-linux".into()
}
fn title(&self) -> String {
"Proton Drive".into()
}
fn icon_name(&self) -> String {
self.state.phase.icon().into()
}
fn status(&self) -> Status {
match self.state.phase {
Phase::Attention | Phase::SessionExpired => Status::NeedsAttention,
_ => Status::Active,
}
}
fn tool_tip(&self) -> ToolTip {
let mut description = self.state.line.clone();
if !self.state.sync.is_empty() {
description = format!("{description}\n{}", self.state.sync);
}
ToolTip {
title: "Proton Drive".into(),
description,
..Default::default()
}
}
/// A left click opens the app, as it does for most tray icons.
fn activate(&mut self, _x: i32, _y: i32) {
open_manager(&[]);
}
fn menu(&self) -> Vec<MenuItem<Self>> {
// Value captured into the action closure (which only gets `&mut Self`).
let open_mp = self.state.mountpoint.clone();
let label = |text: &str| -> MenuItem<Self> {
StandardItem {
label: text.to_string(),
enabled: false,
..Default::default()
}
.into()
};
let item = |text: &str, run: fn(&mut Self)| -> MenuItem<Self> {
StandardItem {
label: text.to_string(),
activate: Box::new(run),
..Default::default()
}
.into()
};
let mut items: Vec<MenuItem<Self>> = vec![label(&self.state.line)];
// Live sync status, shown only while something is transferring.
if !self.state.sync.is_empty() {
items.push(label(&self.state.sync));
}
if self.state.failing > 0 {
items.push(item(&issues_label(self.state.failing), |_| {
open_manager(&["--page", "locations"])
}));
}
items.push(MenuItem::Separator);
match self.state.phase {
// Nothing to mount without a session; the app's sign-in page is the
// way in.
Phase::SignedOut => items.push(item(&gettext("Sign In…"), |_| open_manager(&[]))),
Phase::SessionExpired => items.push(item(&gettext("Sign In Again…"), |_| {
open_manager(&["--page", "login"])
})),
_ => items.push(item(&gettext("Open Proton Drive"), |_| open_manager(&[]))),
}
if self.state.outdated {
items.push(item(&gettext("Restart Proton Drive"), |this| {
service::restart();
this.state.line = gettext("Connecting…");
}));
}
if self.state.mounted {
items.push(
StandardItem {
label: gettext("Open Folder"),
activate: Box::new(move |_: &mut Self| open_folder(&open_mp)),
..Default::default()
}
.into(),
);
// Pause holds uploads back but leaves the mount readable, so it
// sits apart from Stop, which takes the whole mount away.
let paused = self.state.paused;
items.push(
StandardItem {
label: if paused {
gettext("Resume Sync")
} else {
gettext("Pause Sync")
},
activate: Box::new(move |this: &mut Self| {
let request = Request::SetSyncPaused {
paused: !paused,
until: None,
};
match send(&this.socket, &request) {
Ok(Response::Ok { .. }) => this.state.paused = !paused,
Ok(other) => tracing::warn!("pause sync: {other:?}"),
Err(e) => tracing::warn!("pause sync: {e}"),
}
}),
..Default::default()
}
.into(),
);
} else if self.state.phase == Phase::Disconnected {
// Enable+start the service so it mounts now and on future logins.
items.push(item(&gettext("Connect"), |this| {
service::enable_start();
this.state.line = gettext("Connecting…");
}));
}
items.push(MenuItem::Separator);
items.push(item(&gettext("Hide Tray Icon"), |_| hide_tray()));
if self.state.mounted {
// Stopping unmounts the drive, so the app asks first; the tray has no
// GTK of its own to ask with.
items.push(item(&gettext("Stop Proton Drive…"), |_| {
open_manager(&["--confirm-stop"])
}));
} else {
items.push(item(&gettext("Quit"), |_| std::process::exit(0)));
}
items
}
}
fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
)
.init();
i18n::init();
let dirs = AppDirs::new().context("resolve app dirs")?;
// Hidden from its own menu: the session's autostart still runs the tray, and
// this is where that choice is kept.
if dirs.load_config().tray_hidden {
tracing::info!("tray icon is hidden in the config; exiting.");
return Ok(());
}
// The socket lives in the state dir, which nothing has made yet when the app
// starts the tray on a fresh install (B194).
dirs.ensure().context("create the app's directories")?;
// Ensure only one instance of the tray runs.
let tray_sock = dirs.tray_socket();
if std::os::unix::net::UnixStream::connect(&tray_sock).is_ok() {
tracing::info!("Another instance of pdfs-tray is already running; exiting.");
return Ok(());
}
let _ = std::fs::remove_file(&tray_sock);
let lock_socket = std::os::unix::net::UnixListener::bind(&tray_sock)
.context("failed to bind tray single-instance socket")?;
// Owner-only like the control socket (B6). This one only guards single
// instancing, so a failure is logged rather than fatal — but there is no
// reason to leave it reachable by other local users either.
if let Err(e) = pdfs_core::config::restrict_socket(&tray_sock) {
tracing::warn!(error = %e, "could not restrict tray socket permissions");
}
// The app asks the tray to leave through its socket, when the user hides
// the icon from Preferences or stops Proton Drive.
std::thread::spawn(move || {
for stream in lock_socket.incoming().flatten() {
let mut line = String::new();
if BufReader::new(stream).read_line(&mut line).is_ok()
&& line.trim() == pdfs_core::tray::QUIT
{
std::process::exit(0);
}
}
});
let socket = dirs.control_socket();
let default_mountpoint = dirs.default_mountpoint();
let tray = DriveTray {
state: poll_state(&socket, &default_mountpoint),
socket: socket.clone(),
};
// The tray autostarts with the session and can beat the desktop's SNI
// watcher onto the bus. Without `assume_sni_available` that race is a hard
// spawn error; with it the item waits and registers once the watcher shows.
let handle = tray
.assume_sni_available(true)
.spawn()
.context("start tray service")?;
// Follow the daemon forever, pushing each fresh state into the tray so the
// menu reflects mount/login changes made elsewhere (e.g. via the CLI).
let (tx, rx) = std::sync::mpsc::channel();
let feed_socket = socket.clone();
std::thread::spawn(move || follow(&feed_socket, |feed| tx.send(feed).is_ok()));
let mut heard = Heard::default();
while let Ok(feed) = rx.recv() {
heard.take(feed);
let until = Instant::now() + COALESCE;
while let Ok(feed) = rx.recv_timeout(until.saturating_duration_since(Instant::now())) {
heard.take(feed);
}
let st = match &heard.status {
Some(status) => status_state(status, &heard.items, &heard.jobs, &default_mountpoint),
// Lost the daemon: ask once, which describes the sign-in instead.
None => poll_state(&socket, &default_mountpoint),
};
handle.update(move |t: &mut DriveTray| t.state = st);
}
Ok(())
}
/// What the tray has heard from the daemon's event feed.
#[derive(Default)]
struct Heard {
/// The last status, or `None` once the daemon has gone.
status: Option<Response>,
items: Vec<TransferItem>,
jobs: Vec<JobItem>,
}
impl Heard {
fn take(&mut self, feed: Feed) {
match feed {
Feed::Event(Event::Status(status)) => self.status = Some(*status),
Feed::Event(Event::Transfers { items, jobs }) => {
self.items = items;
self.jobs = jobs;
}
Feed::Disconnected => *self = Self::default(),
Feed::Connected | Feed::Event(Event::Changed { .. } | Event::Heartbeat) => {}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_pause_outranks_failures_and_failures_outrank_the_rest() {
assert_eq!(phase_of(true, 3, false, true), Phase::Paused);
assert_eq!(phase_of(false, 3, false, true), Phase::Attention);
assert_eq!(phase_of(false, 0, false, true), Phase::Offline);
assert_eq!(phase_of(false, 0, true, true), Phase::Syncing);
assert_eq!(phase_of(false, 0, true, false), Phase::Synced);
}
fn status(extra: &str) -> Response {
status_from(env!("CARGO_PKG_VERSION"), extra)
}
fn status_from(version: &str, extra: &str) -> Response {
serde_json::from_str(&format!(
r#"{{"Status":{{"username":"u","mountpoint":"/m","pinned":0,"used":0,"budget":0,
"pins":[],"online":false,"pending_uploads":3,"failing_ops":2,"paused":true,
"version":"{version}"{extra}}}}}"#
))
.unwrap()
}
/// Issue #35: a refused session read as "Offline — 3 uploads queued" for
/// good, and nothing said that signing in again was the way out.
#[test]
fn a_refused_session_outranks_everything_else() {
let state = status_state(
&status(r#","session_expired":true"#),
&[],
&[],
Path::new("/m"),
);
assert_eq!(state.phase, Phase::SessionExpired);
assert_eq!(state.line, "Session expired — sign in again");
}
/// B61: the keyring refusing the refreshed tokens was only in the log.
#[test]
fn a_sign_in_the_keyring_refused_needs_attention() {
let state = status_state(
&status(r#","tokens_unsaved":true"#),
&[],
&[],
Path::new("/m"),
);
assert_eq!(state.phase, Phase::Attention);
assert_eq!(state.line, "Sign-in not saved — unlock your keyring");
}
/// #33: a daemon an upgrade left running on the old release is named,
/// above a pause, and the menu offers the restart that finishes it.
#[test]
fn a_daemon_older_than_the_tray_needs_a_restart() {
let state = status_state(&status_from("0.1.0", ""), &[], &[], Path::new("/m"));
assert_eq!(state.phase, Phase::Attention);
assert_eq!(state.line, "Update not finished — restart Proton Drive");
assert!(state.outdated);
}
#[test]
fn a_daemon_that_does_not_say_has_a_live_session() {
let state = status_state(&status(""), &[], &[], Path::new("/m"));
assert_eq!(state.phase, Phase::Paused);
}
fn job(title: &str, background: bool) -> JobItem {
JobItem {
title: title.to_string(),
detail: String::new(),
done: 0,
total: 0,
background,
}
}
#[test]
fn a_routine_scan_is_not_busy_but_a_sync_is() {
assert!(!busy(&[], &[]));
assert!(!busy(&[], &[job("Checking Documents", true)]));
assert!(busy(
&[],
&[
job("Checking Documents", true),
job("Syncing Photos", false)
]
));
assert!(busy(&[], &[job("Uploading files", false)]));
}
#[test]
fn a_job_from_an_older_daemon_counts_as_busy() {
let job: JobItem = serde_json::from_str(
r#"{"title":"Checking Documents","detail":"","done":0,"total":0}"#,
)
.unwrap();
assert!(!job.background);
}
#[test]
fn the_issues_item_counts_in_the_singular_and_plural() {
assert_eq!(issues_label(1), "1 issue — View");
assert_eq!(issues_label(4), "4 issues — View");
}
}