diff --git a/PKGBUILD b/PKGBUILD
index 051d3b1..e0d0d23 100644
--- a/PKGBUILD
+++ b/PKGBUILD
@@ -68,6 +68,7 @@ package() {
nextsync-activity-ok
nextsync-activity-warning
nextsync-conflict-warning
+ nextsync-menu-info
nextsync-menu-log
nextsync-menu-open
nextsync-menu-quit
diff --git a/data/icons/nextsync-menu-info.svg b/data/icons/nextsync-menu-info.svg
new file mode 100644
index 0000000..180ba30
--- /dev/null
+++ b/data/icons/nextsync-menu-info.svg
@@ -0,0 +1 @@
+
diff --git a/data/icons/nextsync-menu-log.svg b/data/icons/nextsync-menu-log.svg
index 86da8bd..31abb32 100644
--- a/data/icons/nextsync-menu-log.svg
+++ b/data/icons/nextsync-menu-log.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
diff --git a/data/icons/nextsync-menu-open.svg b/data/icons/nextsync-menu-open.svg
index 86da8bd..10bc25f 100644
--- a/data/icons/nextsync-menu-open.svg
+++ b/data/icons/nextsync-menu-open.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/data/icons/nextsync-menu-quit.svg b/data/icons/nextsync-menu-quit.svg
index 11e97b1..698988c 100644
--- a/data/icons/nextsync-menu-quit.svg
+++ b/data/icons/nextsync-menu-quit.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/src/core/account_runtime.rs b/src/core/account_runtime.rs
index bf73ae6..a9690bd 100644
--- a/src/core/account_runtime.rs
+++ b/src/core/account_runtime.rs
@@ -785,16 +785,31 @@ impl AccountRuntime {
// drive their own triggers). Without this the app sits idle until an
// event or interval fires, so a fresh install never compares the
// trees and the not-yet-synchronized state sticks until a manual run.
- let schedulers: Vec<_> = self
- .folders
- .values()
- .map(|folder| folder.scheduler())
- .collect();
- for scheduler in schedulers {
- scheduler.request(Trigger::Startup);
+ //
+ // Issue #154: request the runs in the configured folder order, not
+ // the HashMap's (random) iteration order. The shared one-at-a-time
+ // permit wakes its waiters FIFO, so the request order IS the run
+ // order; following the configuration keeps the startup backlog
+ // stable and matching the folder list the window shows.
+ for runtime in self.ordered_folders() {
+ runtime.scheduler().request(Trigger::Startup);
}
}
+ /// The folder runtimes in the account's configured order.
+ ///
+ /// `self.folders` is a HashMap, so its iteration order is random per
+ /// process; any fan-out whose order is user-visible (the startup sync
+ /// requests and the watcher target list, issue #154) must follow the
+ /// configuration instead.
+ fn ordered_folders(&self) -> Vec<&FolderRuntime> {
+ self.account
+ .folders
+ .iter()
+ .filter_map(|folder| self.folders.get(&folder.id))
+ .collect()
+ }
+
/// Wire the GLib main-loop consumers for every folder runtime: the local
/// filesystem watcher and the live progress forwarder.
///
@@ -863,10 +878,14 @@ impl AccountRuntime {
/// Refresh the shared scheduler list watched by the system callbacks from
/// the current folder runtimes (folders can be added or removed at runtime).
+ ///
+ /// The resume and remote-push callbacks request one sync per folder
+ /// through this list, so it follows the configured folder order (issue
+ /// #154), exactly like the startup fan-out.
fn sync_targets(&self) {
let schedulers: Vec = self
- .folders
- .values()
+ .ordered_folders()
+ .iter()
.map(|folder| folder.scheduler())
.collect();
self.targets.borrow_mut().schedulers = schedulers;
@@ -1487,6 +1506,53 @@ mod tests {
assert_eq!(runtime.state().snapshot().state, AppState::Unconfigured);
}
+ /// Issue #154: the startup fan-out must follow the configured folder
+ /// order. `self.folders` is a HashMap (random iteration order per
+ /// process), so the startup requests used to go out in a different order
+ /// on every launch and the shared permit's FIFO ran the backlog in that
+ /// random order; the window lists folders in configuration order, hence
+ /// the visible hopping.
+ #[test]
+ fn startup_requests_follow_the_configured_folder_order() {
+ let source = fake_source();
+ let mut account = sample_account(true);
+ account.folders = vec![
+ FolderConfig {
+ id: "f-one".to_string(),
+ local_root: "/tmp/nsync-order-1".to_string(),
+ remote_path: "/one".to_string(),
+ space_id: None,
+ size_confirmed: false,
+ },
+ FolderConfig {
+ id: "f-two".to_string(),
+ local_root: "/tmp/nsync-order-2".to_string(),
+ remote_path: "/two".to_string(),
+ space_id: None,
+ size_confirmed: false,
+ },
+ FolderConfig {
+ id: "f-three".to_string(),
+ local_root: "/tmp/nsync-order-3".to_string(),
+ remote_path: "/three".to_string(),
+ space_id: None,
+ size_confirmed: false,
+ },
+ ];
+ let mut runtime =
+ AccountRuntime::new(account, NetworkConfig::default(), source, None, false);
+ runtime.start_without_watchers();
+ assert_eq!(runtime.folders().len(), 3);
+ // The same ordering source `mount_watchers` uses for the startup
+ // Trigger::Startup requests: configuration order, never HashMap order.
+ let order: Vec<&str> = runtime
+ .ordered_folders()
+ .iter()
+ .map(|runtime| runtime.folder.id.as_str())
+ .collect();
+ assert_eq!(order, vec!["f-one", "f-two", "f-three"]);
+ }
+
#[test]
fn manager_starts_every_account_and_removes_one() {
let source = fake_source();
diff --git a/src/core/scheduler.rs b/src/core/scheduler.rs
index 817f55c..8ef943b 100644
--- a/src/core/scheduler.rs
+++ b/src/core/scheduler.rs
@@ -1247,6 +1247,49 @@ mod tests {
assert_eq!(runner_second.0.borrow().start_calls, 1);
}
+ /// Issue #154: the startup backlog runs strictly one folder at a time in
+ /// the exact order the startup triggers were requested. Every scheduler
+ /// defers its start through one idle hop (dispatched FIFO, like the GLib
+ /// equal-priority idle sources in production) and the shared permit
+ /// wakes its waiters oldest-first, so the request order IS the run order
+ /// across all accounts and folders.
+ #[test]
+ fn startup_backlog_runs_strictly_in_request_order() {
+ let permit = SyncPermit::try_new(1).unwrap();
+ let (first, source_first, runner_first) = make_scheduler(Some(permit.clone()));
+ let (second, source_second, runner_second) = make_scheduler(Some(permit.clone()));
+ let (third, source_third, runner_third) = make_scheduler(Some(permit));
+
+ // The startup fan-out requests one run per folder; the idles then
+ // fire in request order.
+ first.request(Trigger::Startup);
+ second.request(Trigger::Startup);
+ third.request(Trigger::Startup);
+ run_idle(&source_first);
+ run_idle(&source_second);
+ run_idle(&source_third);
+
+ // Exactly one run started; the rest of the backlog waits on the
+ // permit, oldest request first.
+ assert_eq!(runner_first.0.borrow().start_calls, 1);
+ assert_eq!(runner_second.0.borrow().start_calls, 0);
+ assert_eq!(runner_third.0.borrow().start_calls, 0);
+
+ // Finishing a run wakes only the oldest waiter: the second folder
+ // runs next, never the third.
+ finish(&runner_first, SyncOutcome::Success);
+ assert_eq!(source_second.borrow().pending(), 1);
+ assert_eq!(source_third.borrow().pending(), 0);
+ run_idle(&source_second);
+ assert_eq!(runner_second.0.borrow().start_calls, 1);
+ assert_eq!(runner_third.0.borrow().start_calls, 0);
+
+ finish(&runner_second, SyncOutcome::Success);
+ assert_eq!(source_third.borrow().pending(), 1);
+ run_idle(&source_third);
+ assert_eq!(runner_third.0.borrow().start_calls, 1);
+ }
+
#[test]
fn external_engine_on_the_folder_aborts_the_run_with_a_clear_error() {
let (scheduler, source, runner) = make_scheduler(None);
diff --git a/src/main.rs b/src/main.rs
index 417f4e6..1262a56 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -265,6 +265,18 @@ fn main() {
}
}
})),
+ // Issue #155: the same About dialog the hamburger menu opens.
+ // With minimize-to-tray the main window may be hidden; the
+ // dialog is transient for it and GTK still presents it on its
+ // own, so no standalone fallback is needed.
+ open_about: Rc::new({
+ let weak = weak.clone();
+ move || {
+ if let Some(main) = weak.upgrade() {
+ main.borrow_mut().show_about();
+ }
+ }
+ }),
pause_all: Rc::new({
let weak = weak.clone();
move |paused| {
diff --git a/src/ui/tray.rs b/src/ui/tray.rs
index 972b64a..d7a037b 100644
--- a/src/ui/tray.rs
+++ b/src/ui/tray.rs
@@ -55,6 +55,8 @@ pub enum TrayAction {
Conflicts,
/// Pause or resume every account at once (issue #42).
PauseAll(bool),
+ /// Present the About dialog (issue #155).
+ About,
/// Quit the application.
Quit,
}
@@ -73,6 +75,8 @@ pub struct TrayCallbacks {
pub pause_all: Rc,
/// Whether every account is currently paused (drives the menu label).
pub all_paused: Rc bool>,
+ /// Present the About dialog (issue #155).
+ pub open_about: Rc,
/// Quit the application.
pub quit: Rc,
}
@@ -114,8 +118,8 @@ pub fn status_icon_key_to_name(icon_key: &str) -> &'static str {
}
}
-/// Number of items in the tray menu (Open, Log, Quit).
-pub const MENU_ITEM_COUNT: usize = 3;
+/// Number of items in the tray menu (Open, Log, About, Quit).
+pub const MENU_ITEM_COUNT: usize = 4;
/// The StatusNotifier item. Only `Send` data lives here, satisfying the
/// `ksni::Tray` bound; user actions leave through the [`TrayAction`] channel.
@@ -151,9 +155,10 @@ impl TrayItem {
self.all_paused = paused;
}
- /// The menu items: Open, Settings, Conflicts (when wired) and Quit,
- /// following the v0.4.0 tray (`_layout_data` item ids 1, 7, 8 plus the
- /// conflicts entry `application.py` wires via `open_conflicts`).
+ /// The menu items: Open, Log (when wired), About and Quit, following
+ /// the v0.4.0 tray (`_layout_data` item ids 1, 7, 8 plus the conflicts
+ /// entry `application.py` wires via `open_conflicts`); About joins from
+ /// the window's hamburger menu (issue #155).
///
/// The callbacks run on the ksni service thread, so they only post a
/// [`TrayAction`] with `try_send` (async-channel 2.x `Sender::send` is an
@@ -161,8 +166,9 @@ impl TrayItem {
fn build_menu(&self) -> Vec