diff --git a/src/core/account_runtime.rs b/src/core/account_runtime.rs index 508176a..2f8640f 100644 --- a/src/core/account_runtime.rs +++ b/src/core/account_runtime.rs @@ -17,6 +17,7 @@ use std::cell::RefCell; use std::collections::{HashMap, HashSet}; use std::rc::Rc; +use std::sync::Arc; use crate::core::debounce::TimeoutSource; use crate::core::delete_guard::DeleteGuard; @@ -289,7 +290,10 @@ impl FolderRuntime { exclude_file, executable, tx, - ); + ) + .with_remote_ensurer(Arc::new(|account, folder, password| { + crate::nextcloud::sync_engine::ProductionRemoteEnsurer::run(account, folder, password) + })); (Box::new(engine), rx) } diff --git a/src/core/log.rs b/src/core/log.rs index 5b4b056..2f93d08 100644 --- a/src/core/log.rs +++ b/src/core/log.rs @@ -1,10 +1,12 @@ //! Application logger with live subscription and on-disk daily files. //! //! Task 5.4: ports `storage/log.py` (v0.4.0). The [`LogBuffer`] is the real -//! logger the UI consumes: `ui/log_view.rs` (LogWindow) subscribes to new -//! lines and seeds its buffer from [`LogBuffer::tail`], and the conflict -//! resolver's Recent tab will use [`LogBuffer::subscribe`] and -//! [`LogBuffer::recent_lines`] (mirroring `ui/conflict_resolver.py`). +//! logger the app consumes: emitters (the per-run outcome lines, the update +//! checker) append through it, and the conflict resolver's Recent tab uses +//! [`LogBuffer::subscribe`] and [`LogBuffer::recent_lines`] (mirroring +//! `ui/conflict_resolver.py`). The interactive log window was removed by +//! user decision (issue #15); the daily files under `$XDG_STATE_HOME` are +//! what "check the log" refers to. //! //! The buffer keeps the last `live_history_lines` lines in memory (the Python //! `deque(maxlen=...)`), so `tail` works without a subscription. When @@ -20,12 +22,12 @@ //! - The daily date defaults to UTC (`utc_date_string`, no `chrono` //! dependency) instead of `dt.date.today()` (local). `date_provider` is //! injectable for tests and for a future local-time implementation. -//! - The log directory is created lazily on the first disk write (or by -//! `LogWindow::open_folder`) instead of eagerly in the constructor, so -//! building a `LogBuffer` has no filesystem side effects. +//! - The log directory is created lazily on the first disk write instead of +//! eagerly in the constructor, so building a `LogBuffer` has no filesystem +//! side effects. //! - `Subscription` mirrors the `state.rs` pattern but does **not** invoke the //! callback with the current history on subscribe (the Python `subscribe` -//! only registers; the LogWindow seeds from `tail` explicitly). +//! only registers; consumers seed from `tail` explicitly). //! - Subscribers unsubscribed during a notification stop receiving in the same //! pass (the Python snapshot `tuple()` would still notify them once). This is //! unobservable in practice and only differs under adversarial callbacks. diff --git a/src/nextcloud/api.rs b/src/nextcloud/api.rs index b4c4936..fe0d850 100644 --- a/src/nextcloud/api.rs +++ b/src/nextcloud/api.rs @@ -176,6 +176,52 @@ impl NextcloudApi { Ok(display_name) } + /// Create the remote folder (and any missing parents) over WebDAV MKCOL. + /// + /// `nextcloudcmd` fails silently (exit 1, no output) when `--path` points + /// at a folder that does not exist on the server, so the app must create + /// the target itself before the first sync. Idempotent per segment: + /// 201 (created) and 405 (already exists) both succeed; 401/403 map to + /// [`ApiError::AuthRejected`]; an empty `remote_path` (the account root) + /// is a no-op. + pub fn ensure_remote_folder( + &self, + server: &str, + username: &str, + password: &str, + remote_path: &str, + ) -> Result<(), ApiError> { + let path = remote_path.trim_matches('/'); + if path.is_empty() { + return Ok(()); + } + let base = dav_base(server, username); + let authorization = basic_authorization(username, password); + let mut accumulated = String::new(); + for segment in path.split('/') { + if segment.is_empty() { + continue; + } + if !accumulated.is_empty() { + accumulated.push('/'); + } + accumulated.push_str(segment); + let url = format!("{base}/{accumulated}"); + let response = self.http.request( + "MKCOL", + &url, + &[("Authorization", authorization.as_str())], + None, + )?; + match response.status { + 201 | 405 => {} + 401 | 403 => return Err(ApiError::AuthRejected), + status => return Err(ApiError::Http { status }), + } + } + Ok(()) + } + /// Probe whether a remote folder exists and holds at least one entry, /// using a shallow PROPFIND (Depth 1, no file bodies). /// @@ -593,6 +639,101 @@ mod tests { // ---- probe_remote ------------------------------------------------------- + /// Fake returning a scripted status per request (MKCOL sequences). + struct ScriptedHttp { + statuses: Rc>>, + requests: Rc>>, + } + + impl ScriptedHttp { + fn new(statuses: &[u16]) -> Self { + Self { + statuses: Rc::new(RefCell::new(statuses.iter().copied().collect())), + requests: Rc::new(RefCell::new(Vec::new())), + } + } + } + + impl HttpClient for ScriptedHttp { + fn request( + &self, + method: &str, + url: &str, + headers: &[(&str, &str)], + body: Option<&[u8]>, + ) -> Result { + self.requests.borrow_mut().push(RecordedRequest { + method: method.to_owned(), + url: url.to_owned(), + headers: headers + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(), + body: body.map(<[u8]>::to_vec), + }); + let status = self.statuses.borrow_mut().pop_front().unwrap_or(500); + Ok(HttpResponse { + status, + body: Vec::new(), + }) + } + } + + #[test] + fn ensure_remote_folder_noops_on_account_root() { + let http = ScriptedHttp::new(&[]); + let requests = http.requests.clone(); + let api = NextcloudApi::with_http(Box::new(http)); + api.ensure_remote_folder("https://cloud.example.com", "alice", "pw", "") + .unwrap(); + assert!(requests.borrow().is_empty()); + } + + #[test] + fn ensure_remote_folder_creates_each_segment() { + let http = ScriptedHttp::new(&[201, 201]); + let requests = http.requests.clone(); + let api = NextcloudApi::with_http(Box::new(http)); + api.ensure_remote_folder("https://cloud.example.com", "alice", "pw", "/a/b") + .unwrap(); + let urls: Vec = requests + .borrow() + .iter() + .map(|request| request.url.clone()) + .collect(); + let base = "https://cloud.example.com/remote.php/dav/files/alice"; + assert_eq!(urls, vec![format!("{base}/a"), format!("{base}/a/b")]); + assert!(requests.borrow().iter().all(|r| r.method == "MKCOL")); + } + + #[test] + fn ensure_remote_folder_treats_405_as_existing() { + let http = ScriptedHttp::new(&[405]); + let api = NextcloudApi::with_http(Box::new(http)); + api.ensure_remote_folder("https://cloud.example.com", "alice", "pw", "/docs") + .unwrap(); + } + + #[test] + fn ensure_remote_folder_maps_401_to_auth_rejected() { + let http = ScriptedHttp::new(&[401]); + let api = NextcloudApi::with_http(Box::new(http)); + assert!(matches!( + api.ensure_remote_folder("https://cloud.example.com", "alice", "pw", "/docs"), + Err(ApiError::AuthRejected) + )); + } + + #[test] + fn ensure_remote_folder_surfaces_unexpected_status() { + let http = ScriptedHttp::new(&[500]); + let api = NextcloudApi::with_http(Box::new(http)); + assert!(matches!( + api.ensure_remote_folder("https://cloud.example.com", "alice", "pw", "/docs"), + Err(ApiError::Http { status: 500 }) + )); + } + #[test] fn probe_empty_folder_returns_false() { let http = FakeHttp::new(207, EMPTY_PROPFIND); diff --git a/src/nextcloud/sync_engine.rs b/src/nextcloud/sync_engine.rs index eb77d9d..3470a35 100644 --- a/src/nextcloud/sync_engine.rs +++ b/src/nextcloud/sync_engine.rs @@ -40,9 +40,10 @@ use std::time::{Duration, Instant}; use crate::core::scheduler::{SyncOutcome, SyncRunner}; use crate::core::triggers::Trigger; +use crate::nextcloud::api::{ApiError, NextcloudApi}; use crate::nextcloud::command::{BoundedOutputCapture, Classification, DEFAULT_MAX_LINES}; use crate::nextcloud::credentials::CredentialsStore; -use crate::nextcloud::driver::{driver_for, DriverContext}; +use crate::nextcloud::driver::{driver_for, DriverContext, Provider}; use crate::storage::config::{AccountConfig, FolderConfig, NetworkConfig}; use crate::util::redact::Redact; @@ -116,6 +117,40 @@ impl SyncResult { } } +/// Creates the remote folder before a sync when one is configured. +/// +/// Production installs [`ProductionRemoteEnsurer`] (WebDAV MKCOL through +/// [`NextcloudApi`](crate::nextcloud::api::NextcloudApi)); tests leave it +/// unset to stay hermetic. +pub type RemoteEnsurer = + Arc Result<(), ApiError> + Send + Sync>; + +/// Production [`RemoteEnsurer`]: MKCOL the folder's remote path (Nextcloud +/// accounts only; OpenCloud spaces are managed by `opencloudcmd` itself). +#[derive(Default)] +pub struct ProductionRemoteEnsurer; + +impl ProductionRemoteEnsurer { + /// Run the ensure step for one folder pair. + pub fn run( + account: &AccountConfig, + folder: &FolderConfig, + password: &str, + ) -> Result<(), ApiError> { + if account.provider != Provider::Nextcloud + || folder.remote_path.trim_matches('/').is_empty() + { + return Ok(()); + } + NextcloudApi::new().ensure_remote_folder( + &account.server_url, + &account.login_name, + password, + &folder.remote_path, + ) + } +} + /// Spawns and drains `nextcloudcmd`, reporting [`SyncProgress`] and the final /// [`SyncOutcome`]. Implements the scheduler's [`SyncRunner`]. pub struct SyncEngine { @@ -127,6 +162,7 @@ pub struct SyncEngine { progress: async_channel::Sender, credentials: Arc, process: Arc>>, + remote_ensurer: Option, } impl SyncEngine { @@ -152,6 +188,7 @@ impl SyncEngine { progress, credentials: Arc::new(KeyringCredentialSource), process: Arc::new(Mutex::new(None)), + remote_ensurer: None, } } @@ -161,6 +198,12 @@ impl SyncEngine { self } + /// Install the remote-folder ensure step (production wiring). + pub fn with_remote_ensurer(mut self, ensurer: RemoteEnsurer) -> Self { + self.remote_ensurer = Some(ensurer); + self + } + /// Whether a reconciliation is currently running. pub fn is_running(&self) -> bool { self.process @@ -180,12 +223,14 @@ impl SyncRunner for SyncEngine { let progress = self.progress.clone(); let credentials = Arc::clone(&self.credentials); let process = Arc::clone(&self.process); + let remote_ensurer = self.remote_ensurer.clone(); let inputs = EngineInputs { account, folder, network, exclude_file, executable, + remote_ensurer, }; glib::spawn_future_local(async move { let run = @@ -232,6 +277,7 @@ struct EngineInputs { network: NetworkConfig, exclude_file: Option, executable: Option, + remote_ensurer: Option, } /// Run the whole reconciliation on the blocking thread pool. @@ -249,6 +295,14 @@ fn engine_thread( return EngineRun::Direct(SyncOutcome::AuthFailed); } }; + // `nextcloudcmd` exits 1 with no output when the remote folder does not + // exist; create it (and its parents) first. Auth rejection surfaces as + // such; anything else falls through and lets nextcloudcmd report. + if let Some(ensure) = inputs.remote_ensurer.as_ref() { + if let Err(ApiError::AuthRejected) = ensure(&inputs.account, &inputs.folder, &password) { + return EngineRun::Direct(SyncOutcome::AuthFailed); + } + } let driver = driver_for(inputs.account.provider); let ctx = DriverContext::from_folder( &inputs.account, @@ -717,4 +771,75 @@ mod tests { let progress = SyncProgress::new("upload", "/a"); assert_eq!(describe_progress(Some(&progress)), "upload: /a"); } + + #[test] + fn remote_ensurer_auth_rejection_short_circuits_to_auth_failed() { + // The fake "binary" is /bin/false: if the engine spawned it, the run + // would end Failed. An AuthRejected ensurer must prevent the spawn + // entirely and classify the outcome as AuthFailed. + let (progress_tx, _progress_rx) = async_channel::unbounded(); + let engine = SyncEngine::new( + account(), + folder(), + NetworkConfig::default(), + None, + Some(PathBuf::from("/bin/false")), + progress_tx, + ) + .with_credentials(Arc::new(FakeCredentials(CredentialLookup::Found( + "secret".to_string(), + )))) + .with_remote_ensurer(Arc::new(|_account, _folder, _password| { + Err(ApiError::AuthRejected) + })); + let (outcome, _) = run_engine(engine, &async_channel::unbounded().1); + assert_eq!(outcome, SyncOutcome::AuthFailed); + } + + #[test] + fn remote_ensurer_non_auth_error_does_not_block_the_run() { + let dir = tempfile::tempdir().unwrap(); + let script = write_script(dir.path(), "fake-ensure-ok", "#!/bin/sh\nexit 0\n"); + let (progress_tx, _progress_rx) = async_channel::unbounded(); + let engine = SyncEngine::new( + account(), + folder(), + NetworkConfig::default(), + None, + Some(script), + progress_tx, + ) + .with_credentials(Arc::new(FakeCredentials(CredentialLookup::Found( + "secret".to_string(), + )))) + .with_remote_ensurer(Arc::new(|_account, _folder, _password| { + Err(ApiError::Http { status: 500 }) + })); + let (outcome, _) = run_engine(engine, &async_channel::unbounded().1); + assert_eq!(outcome, SyncOutcome::Success); + } + + #[test] + fn production_ensurer_skips_opencloud_and_account_root() { + let mut opencloud = account(); + opencloud.provider = Provider::OpenCloud; + // Neither case touches the network: both return Ok without an HTTP + // stack (a live request would fail with a transport error instead). + assert!(ProductionRemoteEnsurer::run(&opencloud, &folder(), "pw").is_ok()); + let mut root_folder = folder(); + root_folder.remote_path = String::new(); + assert!(ProductionRemoteEnsurer::run(&account(), &root_folder, "pw").is_ok()); + } + + #[test] + fn production_ensurer_hits_a_real_server_for_nextcloud_paths() { + // cloud.example.com does not resolve: a network-touching run must + // surface a Transport error (proof the MKCOL path is reached). + let mut remote = folder(); + remote.remote_path = "/docs".to_string(); + assert!(matches!( + ProductionRemoteEnsurer::run(&account(), &remote, "pw"), + Err(ApiError::Transport) + )); + } } diff --git a/src/ui/log_view.rs b/src/ui/log_view.rs deleted file mode 100644 index 17cda20..0000000 --- a/src/ui/log_view.rs +++ /dev/null @@ -1,240 +0,0 @@ -//! Log viewer window. -//! -//! Task 5.4: replaces the placeholder with a `LogWindow` replicating -//! `ui/log_view.py` (v0.4.0). An `Adw.Window` with a header bar and a bottom -//! action bar hosts a monospace `gtk4::TextView` seeded from -//! [`LogBuffer::tail`], a copy button, an "open log folder" button and an -//! auto-scroll toggle. New lines arrive through a live subscription; the -//! buffer is trimmed to [`MAX_BUFFER_LINES`] / [`TRIM_TO_LINES`] and the -//! subscription is released on close. -//! -//! The `LogBuffer` is passed by shared reference (`LogBuffer` is `Clone` over -//! shared `Rc` state), so the window holds its own clone while other -//! components keep appending. -//! -//! # Deviations from `ui/log_view.py` (motivated) -//! - Strings go through [`t`] (Task 6.1); strings the catalog does not carry -//! fall back to English. -//! - The subscription callback only receives the trimmed line; the text view -//! joins lines with `\n` (the Python inserts the prefix `\n` when non-empty, -//! which is equivalent). -//! - `close-request` returns `Propagation::Proceed` (allow close) after -//! unsubscribing, matching the Python `return False`. -//! - On subscribe the window seeds its buffer from `LogBuffer::tail(500)` -//! instead of relying on an initial callback (the Python `subscribe` does -//! not replay history either). - -use std::cell::{Cell, RefCell}; -use std::rc::Rc; - -use gtk4::prelude::*; -use libadwaita::prelude::*; - -use crate::core::log::{LogBuffer, Subscription}; -use crate::util::i18n::t; - -/// Hard cap for the text view buffer, in lines. -pub const MAX_BUFFER_LINES: i32 = 2_000; -/// Trim target after the hard cap is exceeded, in lines. -pub const TRIM_TO_LINES: i32 = 1_500; -/// How many history lines the window seeds from on open (Python `tail(500)`). -pub const SEED_LINES: usize = 500; - -/// Log viewer window. Clone-safe: all GTK widgets are shared refs. -#[derive(Clone)] -pub struct LogWindow { - window: libadwaita::Window, - buffer: gtk4::TextBuffer, - view: gtk4::TextView, - auto_scroll: Rc>, - subscription: Rc>>, -} - -impl LogWindow { - /// Build the log window as a non-modal transient of `parent`, subscribed - /// to `logger` for new lines. - pub fn new(parent: Option<&impl IsA>, logger: &LogBuffer) -> Self { - let window = libadwaita::Window::new(); - window.set_title(Some(t("Synchronization Log"))); - window.set_default_size(820, 560); - window.set_transient_for(parent); - - let toolbar = libadwaita::ToolbarView::new(); - let header = gtk4::HeaderBar::new(); - toolbar.add_top_bar(&header); - - let buffer = gtk4::TextBuffer::new(None); - buffer.set_text(&logger.tail(SEED_LINES).join("\n")); - let view = gtk4::TextView::builder() - .editable(false) - .cursor_visible(false) - .monospace(true) - .left_margin(12) - .right_margin(12) - .top_margin(12) - .bottom_margin(12) - .build(); - view.set_buffer(Some(&buffer)); - - let scroller = gtk4::ScrolledWindow::builder() - .vexpand(true) - .hexpand(true) - .build(); - scroller.set_child(Some(&view)); - toolbar.set_content(Some(&scroller)); - - let action_bar = gtk4::ActionBar::new(); - let auto_scroll = Rc::new(Cell::new(true)); - let auto = gtk4::CheckButton::with_label(t("Auto-scroll")); - auto.set_active(true); - let auto_scroll_guard = auto_scroll.clone(); - auto.connect_toggled(move |button| auto_scroll_guard.set(button.is_active())); - action_bar.pack_start(&auto); - - let copy_button = gtk4::Button::with_label(t("Copy")); - let copy_buffer = buffer.clone(); - copy_button.connect_clicked(move |_| { - let (start, end) = copy_buffer.bounds(); - let text = copy_buffer.text(&start, &end, true); - if let Some(display) = gtk4::gdk::Display::default() { - display.clipboard().set_text(&text); - } - }); - action_bar.pack_end(©_button); - - let folder_button = gtk4::Button::with_label(t("Open Log Folder")); - let directory = logger.directory(); - folder_button.connect_clicked(move |_| { - if let Err(error) = std::fs::create_dir_all(&directory) { - eprintln!("Could not create log directory: {error}"); - return; - } - let uri = gio::File::for_path(&directory).uri(); - let _ = gio::AppInfo::launch_default_for_uri(&uri, None::<&gio::AppLaunchContext>); - }); - action_bar.pack_end(&folder_button); - - toolbar.add_bottom_bar(&action_bar); - window.set_content(Some(&toolbar)); - - let log_window = LogWindow { - window, - buffer, - view, - auto_scroll, - subscription: Rc::new(RefCell::new(None)), - }; - - let subscription = { - let buffer = log_window.buffer.clone(); - let view = log_window.view.clone(); - let auto_scroll = log_window.auto_scroll.clone(); - logger.subscribe(move |line| { - append_line(&buffer, &view, &auto_scroll, line); - }) - }; - *log_window.subscription.borrow_mut() = Some(subscription); - - let close_self = log_window.clone(); - log_window.window.connect_close_request(move |_| { - close_self.unsubscribe(); - glib::Propagation::Proceed - }); - - log_window - } - - /// The underlying window widget. - pub fn window(&self) -> &libadwaita::Window { - &self.window - } - - /// Present the window to the user. - pub fn present(&self) { - self.window.present(); - } - - /// Release the log subscription (also runs on close). Idempotent. - pub fn unsubscribe(&self) { - if let Some(mut subscription) = self.subscription.borrow_mut().take() { - subscription.unsubscribe(); - } - } -} - -/// Append one log line to the text buffer, trim it and optionally scroll to -/// the end on the main loop. -fn append_line( - buffer: >k4::TextBuffer, - view: >k4::TextView, - auto_scroll: &Cell, - line: &str, -) { - let mut end = buffer.end_iter(); - let prefix = if buffer.char_count() > 0 { "\n" } else { "" }; - buffer.insert(&mut end, &format!("{prefix}{line}")); - trim_buffer(buffer); - if auto_scroll.get() { - let buffer = buffer.clone(); - let view = view.clone(); - glib::idle_add_local(move || { - let mark = buffer.create_mark(None, &buffer.end_iter(), false); - view.scroll_mark_onscreen(&mark); - buffer.delete_mark(&mark); - glib::ControlFlow::Break - }); - } -} - -/// Keep the text buffer under `MAX_BUFFER_LINES`, trimming from the top down to -/// `TRIM_TO_LINES`. -fn trim_buffer(buffer: >k4::TextBuffer) { - let line_count = buffer.line_count(); - if line_count <= MAX_BUFFER_LINES { - return; - } - let remove_lines = line_count - TRIM_TO_LINES; - let mut start = buffer.start_iter(); - let mut end = buffer - .iter_at_line(remove_lines) - .unwrap_or_else(|| buffer.end_iter()); - buffer.delete(&mut start, &mut end); -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::core::log::LogBufferOptions; - use crate::ui::test_helpers::gtk_smoke; - use crate::util::i18n::{reset_locale, set_locale, Locale}; - - #[test] - fn log_window_title_translates_to_spanish() { - set_locale(Locale::Spanish); - assert_eq!(t("Synchronization Log"), "Registro de sincronización"); - assert_eq!(t("Auto-scroll"), "Desplazamiento automático"); - assert_eq!(t("Open Log Folder"), "Abrir carpeta de registros"); - reset_locale(); - } - - #[test] - fn log_window_smoke_builds_subscribes_and_appends() { - gtk_smoke(|| { - set_locale(Locale::English); - let directory = tempfile::tempdir().unwrap(); - let logger = LogBuffer::with_options(LogBufferOptions { - directory: directory.path().to_path_buf(), - ..Default::default() - }); - let log_window = LogWindow::new(None::<>k4::Window>, &logger); - assert_eq!( - log_window.window().title().as_deref(), - Some("Synchronization Log") - ); - logger.append("hello from smoke"); - log_window.present(); - log_window.unsubscribe(); - reset_locale(); - }); - } -} diff --git a/src/ui/main_window.rs b/src/ui/main_window.rs index 097224b..4af9c7b 100644 --- a/src/ui/main_window.rs +++ b/src/ui/main_window.rs @@ -325,7 +325,6 @@ pub struct MainWindow { /// Outer stack that slides the settings view over the sync view. root_stack: gtk4::Stack, setup_window: Option, - log_window: Option, conflicts_window: Option, about_dialog: Option, checking_dialog: Option, @@ -428,19 +427,6 @@ impl MainWindow { hamburger.insert_action_group("app", Some(&actions)); header.pack_start(&hamburger); - let log_button = gtk4::Button::builder() - .icon_name("view-list-symbolic") - .tooltip_text(t("Synchronization Log")) - .css_classes(["flat"]) - .build(); - let log_weak = self_weak.clone(); - log_button.connect_clicked(move |_button| { - if let Some(main) = log_weak.upgrade() { - main.borrow_mut().show_log(); - } - }); - header.pack_end(&log_button); - toolbar.add_top_bar(&header); let toast_overlay = libadwaita::ToastOverlay::new(); @@ -519,7 +505,6 @@ impl MainWindow { settings_view: None, root_stack, setup_window: None, - log_window: None, conflicts_window: None, about_dialog: None, checking_dialog: None, @@ -579,17 +564,6 @@ impl MainWindow { } /// Open (or bring to front) the account setup wizard. - /// Open (or bring to front) the live synchronization log window. - pub fn show_log(&mut self) { - if let Some(window) = &self.log_window { - window.present(); - return; - } - let window = crate::ui::log_view::LogWindow::new(Some(&self.window), &self.logger); - window.present(); - self.log_window = Some(window); - } - /// Open (or bring to front) the activity/conflicts window for the active /// account's first synchronized folder. pub fn show_conflicts(&mut self) { @@ -845,6 +819,12 @@ impl MainWindow { /// active account (called after Settings mutates folders). fn refresh_after_config_change(&mut self) { self.config = self.config_store.load().unwrap_or_default(); + // Reconcile the folder runtimes first: the sync view reads the + // runtimes (not the config), so an add/remove made in Settings would + // otherwise stay invisible until restart. + for account in self.config.accounts.clone() { + self.account_manager.sync_folders(&account); + } self.refresh_sidebar(); let account_id = self.active_account_id.clone(); self.present_account(account_id.as_deref()); @@ -1395,4 +1375,80 @@ mod tests { reset_locale(); }); } + + /// Adding a folder through the store (what Settings does) plus the + /// `on_folder_changed` callback path must repaint the sync view without + /// a restart (issue #13). + #[test] + fn folder_add_and_remove_refresh_in_place() { + crate::ui::test_helpers::gtk_smoke(|| { + use crate::storage::config::FolderConfig; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("settings.json"); + let store = ConfigStore::with_path(path.clone()); + + let mut account = window_account(); + account.folders.clear(); + let account_id = store.add_account(&account).unwrap(); + account.id = account_id.clone(); + + let app = libadwaita::Application::builder() + .application_id("io.github.gnacho.nextsync") + .build(); + let mut manager = AccountManager::new(std::rc::Rc::new(std::cell::RefCell::new( + crate::core::debounce::FakeTimeoutSource::default(), + ))); + let config = crate::storage::config::Config { + accounts: vec![account.clone()], + ..Default::default() + }; + manager.start(&config); + let mut window = MainWindow::new( + &app, + config, + store.clone(), + manager, + crate::core::log::LogBuffer::new(), + None, + Weak::new(), + ); + window.present_account(Some(&account_id)); + let view = window.account_view.as_ref().expect("account view"); + assert!( + view._account_runtime.account.folders.is_empty(), + "starts with no folders" + ); + + // What Settings' Add Folder flow does: mutate the store, then + // fire on_folder_changed (refresh_after_config_change). + store + .add_folder( + &account_id, + &FolderConfig { + id: String::new(), + local_root: dir.path().join("one").to_string_lossy().into_owned(), + remote_path: "/one".to_string(), + space_id: None, + }, + ) + .unwrap(); + window.refresh_after_config_change(); + let view = window.account_view.as_ref().expect("account view"); + assert_eq!( + view._account_runtime.account.folders.len(), + 1, + "added folder is visible without restart" + ); + + // And the remove path (trash button in the folder list). + let folder_id = view._account_runtime.account.folders[0].id.clone(); + store.remove_folder(&account_id, &folder_id).unwrap(); + window.refresh_after_config_change(); + let view = window.account_view.as_ref().expect("account view"); + assert!( + view._account_runtime.account.folders.is_empty(), + "removed folder disappears without restart" + ); + }); + } } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 166dfd0..00a0ad9 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -4,7 +4,6 @@ pub mod about; pub mod activity; pub mod conflict_resolver; pub mod folder_status; -pub mod log_view; pub mod main_window; pub mod settings; pub mod setup;