Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,8 @@ project-specific; CI remains the authority for mechanical formatting rules.
- Keep commits focused and use short imperative English subjects. Preserve
unrelated working-tree changes and never commit private operating notes from
outside this repository.
- The repository accepts squash merges only. Keep pull-request commits focused,
then merge through GitHub's squash path rather than creating a merge commit.
- Update this file when verified packaging, migration, rollback, or store-review
behavior establishes a new repository-wide invariant. Do not encode an
untested publishing assumption as policy.
Expand Down
269 changes: 236 additions & 33 deletions bin/pdrive-ui

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion docs/DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,9 @@ display is unavailable:
```

Exercise both languages, minimum width, zero and active transfer fixtures,
Preferences dirty-state behavior, issue review and content-height fitting.
Preferences dirty-state behavior, issue review and content-height fitting. Use
an implausible stale rclone speed/ETA pair beside zero process-owned TCP traffic
as a regression fixture; the active row must not present it as live activity.
Never run the state-writing watchdog against a live deployment from a sandbox
that cannot access its user bus, FUSE mount or RC socket.

Expand Down
26 changes: 20 additions & 6 deletions docs/OPERATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -234,11 +234,23 @@ derived from the selected live-metrics interval and the 150 retained samples,
so changing the interval updates both the poll note and displayed time range.

The Queue card shows the complete remaining VFS backlog, subtracting bytes
already reported for each matching active transfer. Its `HH:MM:SS` ETA uses an
exponentially smoothed process-owned upload rate. PDrive waits for three useful
samples and returns to **ETA calculating** after a prolonged traffic gap; it
never turns an idle API request or a stale transfer counter into a precise
completion promise. Multi-day and 100-GiB uploads retain their full hour count.
already reported for each matching active transfer. Its ETA uses an
exponentially smoothed process-owned upload rate. When exactly one queued file
matches one active transfer, both views share that estimator, so they cannot
show contradictory speeds or completion times. PDrive waits for three useful
samples before showing a number. While useful bytes are flowing but the sample
window is still young it shows **ETA calculating**; without current traffic it
shows **⏸ ETA waiting**. It never turns an idle API request or a stale rclone
transfer counter into a precise completion promise. Normal estimates use `HH:MM:SS`, multi-day uploads
show days and hours, and extreme near-pause estimates above 100 days use a
rounded day count in compact cards. Hovering the value reveals the fuller
estimate.

A previous upload failure remains available in History and issue review while
the retry is being observed. After three fresh process-owned traffic samples,
the live banner changes from **Attention** to **Recovering**. It falls back to
the conservative warning when traffic becomes stale and never overrides an
unrelated critical condition.

The Capacity card separates the Proton account from the local cache filesystem.
It shows Proton cloud used, total and free values exposed by the mounted remote beside
Expand Down Expand Up @@ -358,7 +370,9 @@ or a one-pixel theme difference while remaining independent of GTK shadow and
header-bar dimensions. A session-autostart window stays hidden until the user
opens it; hidden, unmapped allocations are never used for content fitting.
Opening it restores the compact content height before the same visible-window
fit runs. Smaller screens retain normal scrolling.
fit runs. Each opening can request at most one growth resize after the first
dashboard state arrives, so an allocation that has not caught up cannot add the
same overflow repeatedly. Smaller screens retain normal scrolling.

On X11 Cinnamon the tray uses GTK StatusIcon so a left click opens/focuses the
Control Center and a right click opens the existing Open, Open `/pdrive`, and
Expand Down
Binary file modified docs/assets/pdrive-control-center.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/assets/pdrive-transfers.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
64 changes: 63 additions & 1 deletion tests/test-ui-preferences.sh
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,8 @@ class EtaTracker:
upload_eta_rate = 0.0
upload_eta_samples = 0
upload_eta_last_progress = 0.0
upload_eta_signature = ()
upload_eta_seconds = -1

@staticmethod
def refresh_interval_seconds():
Expand All @@ -219,8 +221,68 @@ module.PDriveWindow.queue_eta_detail(eta_tracker, near_pause_queue, 20 * 1024, 4
near_pause_eta = module.PDriveWindow.queue_eta_detail(
eta_tracker, near_pause_queue, 20 * 1024, 4242, 14.0
)
assert "ETA ≈" in near_pause_eta
assert "≈" in near_pause_eta
assert "calculating" not in near_pause_eta
assert module.compact_eta_duration(99 * 86400) == "99d 0h"
assert module.compact_eta_duration((100 * 86400) + 1) == "101d"
assert module.upload_eta_ready(3, 20 * 1024, 14.0, 14.0, 2)
assert not module.upload_eta_ready(2, 20 * 1024, 14.0, 14.0, 2)
assert not module.upload_eta_ready(3, 20 * 1024, 14.0, 45.0, 2)

waiting_tracker = EtaTracker()
waiting_eta = module.PDriveWindow.queue_eta_detail(
waiting_tracker, near_pause_queue, 0, 4242, 10.0
)
assert "⏸ ETA waiting" in waiting_eta

single_name = "demo/large.img"
single_queue = {
"count": 1,
"active": 1,
"bytes": 20 * 1024 * 1024,
"remaining_bytes": 10 * 1024 * 1024,
"items": [{"name": single_name, "size": 20 * 1024 * 1024, "uploading": True}],
}
single_transfers = {
"active": [
{
"name": single_name,
"size": 20 * 1024 * 1024,
"bytes": 10 * 1024 * 1024,
"speed": 900 * 1024,
"eta_seconds": 999999,
}
]
}
single_metrics = module.verified_single_transfer_metrics(
single_transfers,
single_queue,
20 * 1024,
20 * 1024,
True,
)
assert single_metrics == {"speed": 20 * 1024, "eta_seconds": 512, "estimate_ready": True}
assert module.backend_transfer_metrics(single_transfers["active"][0]) == (0.0, -1)
assert module.backend_transfer_metrics({**single_transfers["active"][0], "eta_seconds": 0}) == (0.0, -1)
assert module.verified_single_transfer_metrics(
{"active": single_transfers["active"] * 2},
{**single_queue, "count": 2},
20 * 1024,
20 * 1024,
True,
) is None

identity_tracker = EtaTracker()
for sample_time in (10.0, 12.0, 14.0):
module.PDriveWindow.queue_eta_detail(identity_tracker, single_queue, 20 * 1024, 4242, sample_time)
assert identity_tracker.upload_eta_samples == 3
changed_queue = {
**single_queue,
"items": [{"name": "demo/replacement.img", "size": 20 * 1024 * 1024, "uploading": True}],
}
changed_detail = module.PDriveWindow.queue_eta_detail(identity_tracker, changed_queue, 20 * 1024, 4242, 16.0)
assert "calculating" in changed_detail
assert identity_tracker.upload_eta_samples == 1

assert module.bandwidth_slider_position("off") == module.BANDWIDTH_SLIDER_UNLIMITED
assert module.bandwidth_slider_position("0") == module.BANDWIDTH_SLIDER_UNLIMITED
Expand Down
148 changes: 146 additions & 2 deletions tests/test-ui-widgets.sh
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import importlib.machinery
import importlib.util
import copy
import sys
import time

loader = importlib.machinery.SourceFileLoader("pdrive_ui_widget_test", sys.argv[1])
spec = importlib.util.spec_from_loader(loader.name, loader)
Expand Down Expand Up @@ -276,9 +277,91 @@ assert "used" in window.capacity_card.remote_detail.get_text()
assert "free" in window.capacity_card.local_value.get_text()
assert "VFS cache used" in window.capacity_card.local_detail.get_text()
assert "pending upload" in window.cache_card.detail.get_text()
assert "ETA " in window.queue_card.detail.get_text()
assert "" in window.queue_card.detail.get_text()
assert "calculating" not in window.queue_card.detail.get_text()

recovery_state = copy.deepcopy(module.demo_state())
recovery_active = recovery_state["transfers"]["active"][0]
recovery_active["speed"] = 897.4 * 1024
recovery_active["eta_seconds"] = 9_223_372_036
recovery_state["queue"].update(
{
"count": 1,
"active": 1,
"failed": 1,
"max_tries": 2,
"bytes": recovery_active["size"],
"remaining_bytes": recovery_active["size"] - recovery_active["bytes"],
"items": [
{
"name": recovery_active["name"],
"size": recovery_active["size"],
"tries": 2,
"uploading": True,
}
],
}
)
recovery_state["health"].update(
{
"status": "warning",
"reason_code": "persistent-upload-failure",
"summary": "At least one file remains queued after multiple upload attempts.",
}
)
recovery_state["network_io"]["send_speed"] = 4 * 1024 * 1024
for _sample in range(3):
window.apply_state(recovery_state)
assert window.status_title.get_text() == "Recovering"
assert window.status_frame.get_style_context().has_class("status-working")
assert "verified process traffic" in window.status_summary.get_text()
queue_eta = window.queue_card.detail.get_text().split("≈", 1)[1]
active_labels = [
widget.get_text()
for widget in descendants(window.active_list)
if isinstance(widget, module.Gtk.Label)
]
assert "4.0 MiB/s" in active_labels
active_detail = next(text for text in active_labels if "· ≈" in text)
assert active_detail.endswith(queue_eta), (active_detail, queue_eta)
assert not any("897.4 KiB/s" in text for text in active_labels)
assert "ETA ≈" in window.queue_card.detail.get_tooltip_text()

stalled_state = copy.deepcopy(recovery_state)
stalled_state["network_io"]["send_speed"] = 0
window.upload_eta_last_progress = time.monotonic() - 31
window.apply_state(stalled_state)
assert window.status_title.get_text() == "Attention"
assert "⏸ ETA waiting" in window.queue_card.detail.get_text()
stalled_labels = [
widget.get_text()
for widget in descendants(window.active_list)
if isinstance(widget, module.Gtk.Label)
]
assert "0 B/s" in stalled_labels
assert any("⏸ ETA waiting" in text for text in stalled_labels)

window.upload_eta_pid = 0
window.upload_eta_signature = ()
window.upload_eta_samples = 0
window.upload_eta_rate = 0
window.upload_eta_last_progress = 0
window.apply_state(recovery_state)
assert window.status_title.get_text() == "Attention"

critical_state = copy.deepcopy(recovery_state)
critical_state["health"].update(
{
"status": "critical",
"reason_code": "service-inactive",
"summary": "The Proton Drive service is inactive.",
}
)
window.apply_state(critical_state)
window.apply_state(critical_state)
window.apply_state(critical_state)
assert window.status_title.get_text() == "Problem"

stress_state = copy.deepcopy(module.demo_state())
stress_state["queue"].update(
{
Expand All @@ -299,11 +382,13 @@ assert window.queue_card.value.get_text() == "12345"
assert not window.queue_card.value.get_layout().is_ellipsized()
queue_stress_detail = window.queue_card.detail.get_text()
assert "12.0 TiB" in queue_stress_detail, queue_stress_detail
assert "ETA ≈" in queue_stress_detail, queue_stress_detail
assert "≈" in queue_stress_detail, queue_stress_detail
assert not window.queue_card.detail.get_layout().is_ellipsized(), (
queue_stress_detail,
window.queue_card.detail.get_allocated_width(),
)
assert queue_stress_detail.endswith("d"), queue_stress_detail
assert "ETA ≈" in window.queue_card.detail.get_tooltip_text()
assert window.retention_button.get_sensitive()
assert window.retention_button.get_tooltip_text() == "Change cache retention"
assert window.retention_button.get_events() & module.Gdk.EventMask.ENTER_NOTIFY_MASK
Expand Down Expand Up @@ -504,6 +589,65 @@ assert opened_configuration_dialogs == [
]

assert module.tray_supports_distinct_clicks()


class FixedAdjustment:
@staticmethod
def get_upper():
return 820

@staticmethod
def get_page_size():
return 720


class FixedScroller:
@staticmethod
def get_vadjustment():
return FixedAdjustment()


class ContentFitTracker:
closed = False
setup_required = False
current_state = {"health": {"status": "ready"}}
content_fit_source = 1
content_fit_completed = False
overview_scroller = FixedScroller()
root = None

def __init__(self):
self.resizes = []

@staticmethod
def get_visible():
return True

@staticmethod
def get_mapped():
return True

@staticmethod
def get_size():
return (820, 720)

@staticmethod
def get_window():
return None

def resize(self, width, height):
self.resizes.append((width, height))


fit_tracker = ContentFitTracker()
assert module.PDriveWindow.fit_content_height(fit_tracker) == module.GLib.SOURCE_REMOVE
assert fit_tracker.resizes == [(820, 824)]
assert fit_tracker.content_fit_completed
# Reusing the stale adjustment cannot compound the first resize.
fit_tracker.content_fit_source = 1
assert module.PDriveWindow.fit_content_height(fit_tracker) == module.GLib.SOURCE_REMOVE
assert fit_tracker.resizes == [(820, 824)]

app.demo = False
window.demo = False
refreshes = []
Expand Down
Loading