Skip to main content

tree_space/ui/
app.rs

1//! The root component: hosts the panel as one or more dock windows — a left
2//! dock, a right dock, or both — each containing its own stack of split panes,
3//! and applies single-instance launch requests.
4//!
5//! Dock model
6//! ──────────
7//! [`Dock`] is one layer-shell window anchored to one screen edge. It owns a
8//! vertical stack of [`Pane`]s. A [`Pane`] is a top bar (hamburger menu +
9//! editable path entry), an optional filter row, and its own `Tree` component.
10//! Panes have globally stable ids (never reused after close) so toolbar/tree
11//! messages survive dock rebuilds and pane removals.
12//!
13//! The first dock is the *primary* dock: its window is the relm4 root window
14//! (rendered by `view!`), its container is `App::pane_container`. Additional
15//! docks are created on demand when a launch request names a side not present
16//! yet; they are ordinary `gtk::Window`s built imperatively.
17//!
18//! Split-pane layout within a dock
19//! ───────────────────────────────
20//! The GTK widget tree is rebuilt from scratch when a dock's pane count
21//! changes. Panes are nested with `gtk::Paned`:
22//!
23//!   panes = [A, B, C]
24//!   widget tree = Box { Paned { A, Paned { B, C } } }
25//!
26//! Each pane is a vertical `Box { toolbar, filter-bar, tree }`. The tree
27//! widget's `vexpand` is set to `true` so each half fills its allocation.
28//! Every pane has its own filter bar (hidden until requested from that pane's
29//! top-bar menu); it applies only to that pane's tree.
30//!
31//! Launch requests
32//! ───────────────
33//! A second `tree-space` invocation is forwarded over the instance socket (see
34//! [`crate::ipc`]) and arrives as [`AppMsg::LaunchRequest`]:
35//!   * no path            → show/hide the whole panel (toggle), or with
36//!     `--side X` ensure a dock exists on X and show it
37//!   * with path(s)       → add a pane for each path (never a duplicate of an
38//!     already-open directory), then show the panel
39
40use std::{cell::{Cell, RefCell}, collections::HashMap, path::{Path, PathBuf}, rc::Rc};
41
42use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell};
43use relm4::gtk::{gdk, gio, glib, prelude::*};
44use relm4::prelude::*;
45
46use crate::cmd::{Command, WidthArg};
47use crate::config::{
48    Bookmark, BuiltinAction, Config, ContextAction, PANEL_MAX_WIDTH, PANEL_MIN_WIDTH, PanelConfig,
49    PanelLayer, PanelSide, SessionState, ShortcutTarget, StartupRoot, bookmark_file_path,
50    load_stylesheet, save_bookmarks_to_path,
51};
52use crate::fs::SortKey;
53use crate::ipc;
54use crate::ui::bookmarks::{self, BookmarkEvent, MoveTarget};
55use crate::ui::toolbar::{PaneShortcuts, Toolbar, ToolbarInit, ToolbarMsg, ToolbarOutput};
56use crate::ui::tree::{Tree, TreeInit, TreeOutput, TreeMsg};
57
58/// One entry in a pane's history: a directory it showed, or the bookmarks view.
59#[derive(Debug, Clone, PartialEq, Eq)]
60enum ViewEntry {
61    Dir(PathBuf),
62    Bookmarks,
63}
64
65/// A pane's back/forward navigation history.
66///
67/// Views are recorded in visit order with a cursor into the list. Going back
68/// moves the cursor left, forward moves it right; visiting a *new* view (not via
69/// back/forward) truncates the forward tail and appends. This is a pure data
70/// structure so the rules can be unit-tested without a display.
71#[derive(Debug, Default, Clone, PartialEq, Eq)]
72struct NavHistory {
73    entries: Vec<ViewEntry>,
74    /// Index of the current entry. Meaningless while `entries` is empty.
75    cursor: usize,
76    /// Set while a back/forward navigation is in flight, so the `RootChanged`
77    /// (or view switch) it produces is not itself recorded as a new visit.
78    navigating: bool,
79}
80
81impl NavHistory {
82    /// Record a newly shown view. A repeat of the current entry is ignored; any
83    /// forward history is dropped. While a back/forward is navigating this is a
84    /// no-op (the target is already in the list).
85    fn record(&mut self, entry: ViewEntry) {
86        if self.navigating {
87            return;
88        }
89        if self.entries.get(self.cursor).is_some_and(|cur| cur == &entry) {
90            return;
91        }
92        if self.entries.is_empty() {
93            self.entries.push(entry);
94            self.cursor = 0;
95            return;
96        }
97        self.entries.truncate(self.cursor + 1);
98        self.entries.push(entry);
99        self.cursor = self.entries.len() - 1;
100    }
101
102    fn can_back(&self) -> bool {
103        self.cursor > 0 && self.cursor < self.entries.len()
104    }
105
106    fn can_forward(&self) -> bool {
107        !self.entries.is_empty() && self.cursor + 1 < self.entries.len()
108    }
109
110    /// Step back one entry and return it, arming `navigating`.
111    fn back(&mut self) -> Option<ViewEntry> {
112        if !self.can_back() {
113            return None;
114        }
115        self.cursor -= 1;
116        self.navigating = true;
117        Some(self.entries[self.cursor].clone())
118    }
119
120    /// Step forward one entry and return it, arming `navigating`.
121    fn forward(&mut self) -> Option<ViewEntry> {
122        if !self.can_forward() {
123            return None;
124        }
125        self.cursor += 1;
126        self.navigating = true;
127        Some(self.entries[self.cursor].clone())
128    }
129
130    /// Clear the in-flight flag once the resulting view has been shown.
131    fn finish_navigation(&mut self) {
132        self.navigating = false;
133    }
134}
135
136/// One split view inside a dock: its own top bar above its own body.
137pub struct Pane {
138    id: u64,
139    toolbar: Controller<Toolbar>,
140    tree: Controller<Tree>,
141    /// The directory this pane currently shows, if any. Owned here (rather than
142    /// in a parallel `Vec` on the dock) so a pane and its root can never drift.
143    root: Option<PathBuf>,
144    /// The canonical form of `root`, cached so launch deduplication does not
145    /// `canonicalize` every pane on every request. Refreshed on `RootChanged`.
146    canonical_root: Option<PathBuf>,
147    /// Back/forward history for this pane's root changes.
148    history: NavHistory,
149    /// This pane's own filter row (hidden until requested from its top bar).
150    /// Only this pane's tree receives the filter.
151    filter_bar: gtk::Box,
152    filter_entry: gtk::SearchEntry,
153    /// Switches the pane body between the tree and the bookmarks view. A pane
154    /// created without a directory starts on the bookmarks page (a "new panel"
155    /// suggesting places to jump to).
156    body: gtk::Stack,
157    /// The bookmarks list container (the bookmarks page of `body`), refilled
158    /// whenever the list changes.
159    bookmarks_list: gtk::Box,
160    /// Case-insensitive filter applied to the bookmarks list. Kept per pane so
161    /// the filter bar works on the bookmarks view too.
162    bookmark_filter: String,
163    /// Shared drag state for the bookmarks list (survives row rebuilds).
164    bookmark_drag: Rc<bookmarks::BookmarkDrag>,
165    /// Keyboard cursor and selection for the bookmarks list.
166    bookmark_nav: Rc<bookmarks::BookmarkNav>,
167    /// The pane overlay: its main child is the `{ toolbar, filter_bar?, body }`
168    /// vertical box, and its overlay children hold panes' popovers (e.g. the
169    /// path-entry completion dropdown). Built once and reused across split/close
170    /// rebuilds, so the tree and toolbar widgets never need to be reparented
171    /// (which would trip `gtk_box_append: child has a parent`).
172    widget: gtk::Overlay,
173    /// The navigation-toolbar buttons when `[panel] nav_toolbar` is enabled;
174    /// their enabled state tracks the pane's root and history.
175    nav_buttons: Option<NavButtons>,
176}
177
178/// The optional navigation toolbar's buttons.
179struct NavButtons {
180    up: gtk::Button,
181    back: gtk::Button,
182    forward: gtk::Button,
183}
184
185impl Pane {
186    /// Record `root` as this pane's directory and refresh its cached canonical
187    /// form.
188    fn set_root(&mut self, root: PathBuf) {
189        self.canonical_root = std::fs::canonicalize(&root).ok();
190        self.root = Some(root);
191        self.refresh_nav();
192    }
193
194    /// Show the bookmarks view in this pane's body (and switch its hamburger).
195    fn show_bookmarks(&self) {
196        self.body.set_visible_child_name("bookmarks");
197        self.toolbar.emit(ToolbarMsg::SetBookmarks(true));
198        self.refresh_nav();
199    }
200
201    /// Show the tree in this pane's body (and switch its hamburger).
202    fn show_tree(&self) {
203        self.body.set_visible_child_name("tree");
204        self.toolbar.emit(ToolbarMsg::SetBookmarks(false));
205        self.refresh_nav();
206    }
207
208    /// Whether this pane is currently on the bookmarks view.
209    fn on_bookmarks(&self) -> bool {
210        self.body.visible_child_name().as_deref() == Some("bookmarks")
211    }
212
213    /// Enable the navigation toolbar's buttons to match what the pane can
214    /// actually do: Up needs a parent directory and a tree (not the bookmarks
215    /// view); Back/Forward need history in that direction. A no-op when the
216    /// toolbar is disabled.
217    fn refresh_nav(&self) {
218        let Some(nav) = &self.nav_buttons else {
219            return;
220        };
221        let has_parent = !self.on_bookmarks()
222            && self.root.as_deref().and_then(Path::parent).is_some();
223        nav.up.set_sensitive(has_parent);
224        nav.back.set_sensitive(self.history.can_back());
225        nav.forward.set_sensitive(self.history.can_forward());
226    }
227}
228
229/// One layer-shell dock window anchored to a screen edge, with its panes.
230struct Dock {
231    side: PanelSide,
232    /// The dock's window. For the primary dock this is the relm4 root window;
233    /// for additional docks an imperatively-built `gtk::Window`.
234    window: gtk::Window,
235    /// The vertical box holding the pane stack (for the primary dock this is
236    /// `App::pane_container`, referenced by `view!`).
237    container: gtk::Box,
238    panes: Vec<Pane>,
239    /// Id of the pane most recently interacted with in this dock.
240    active_pane: Option<u64>,
241}
242
243/// What an invocation (or the hamburger "Collapse") wants to do to dock
244/// visibility. Pure data, so the show/hide rules can be unit-tested without a
245/// display (see the `visibility` tests below).
246#[derive(Debug, Clone, Copy, PartialEq, Eq)]
247pub enum VisibilityIntent {
248    /// Show every dock.
249    ShowAll,
250    /// Hide every dock.
251    HideAll,
252    /// Toggle every dock: hide them all when any is visible, else show all.
253    ToggleAll,
254    /// Show the named side, creating its dock if needed.
255    ShowSide(PanelSide),
256    /// Hide the named side (a no-op when that dock does not exist).
257    HideSide(PanelSide),
258    /// Toggle the named side, creating and showing it if it does not exist.
259    ToggleSide(PanelSide),
260}
261
262/// The resolved effect of a [`VisibilityIntent`]: which sides should be shown
263/// afterwards, and whether a missing dock should be created to satisfy it.
264///
265/// This is the entire visibility state machine as a pure function; the app
266/// method [`App::apply_visibility`] only carries out the plan.
267#[derive(Debug, Clone, PartialEq, Eq)]
268pub struct VisibilityPlan {
269    /// Sides that should be visible afterwards.
270    pub show: Vec<PanelSide>,
271    /// Sides that should be hidden afterwards.
272    pub hide: Vec<PanelSide>,
273    /// Create and seed a dock on this side when it does not already exist.
274    pub create: Option<PanelSide>,
275    /// Seed an empty dock with a default pane (so showing it is not an empty
276    /// shell). Only used with `create`/`show`.
277    pub seed: bool,
278}
279
280/// Resolve `intent` against the dock sides that currently exist and the sides
281/// currently shown. `show`/`hide` name the sides to act on; `create` names a
282/// side whose dock must be created first.
283pub fn resolve_visibility(
284    intent: VisibilityIntent,
285    existing: &[PanelSide],
286    shown: &[PanelSide],
287) -> VisibilityPlan {
288    match intent {
289        VisibilityIntent::ShowAll => VisibilityPlan {
290            show: existing.to_vec(),
291            hide: Vec::new(),
292            create: None,
293            seed: true,
294        },
295        VisibilityIntent::HideAll => VisibilityPlan {
296            show: Vec::new(),
297            hide: PanelSide::ALL.to_vec(),
298            create: None,
299            seed: false,
300        },
301        VisibilityIntent::ToggleAll => {
302            if shown.is_empty() {
303                VisibilityPlan {
304                    show: existing.to_vec(),
305                    hide: Vec::new(),
306                    create: None,
307                    seed: true,
308                }
309            } else {
310                VisibilityPlan {
311                    show: Vec::new(),
312                    hide: PanelSide::ALL.to_vec(),
313                    create: None,
314                    seed: false,
315                }
316            }
317        }
318        VisibilityIntent::ShowSide(side) => VisibilityPlan {
319            show: vec![side],
320            hide: Vec::new(),
321            create: (!existing.contains(&side)).then_some(side),
322            seed: true,
323        },
324        VisibilityIntent::HideSide(side) => VisibilityPlan {
325            show: Vec::new(),
326            hide: vec![side],
327            create: None,
328            seed: false,
329        },
330        VisibilityIntent::ToggleSide(side) => {
331            if !existing.contains(&side) {
332                VisibilityPlan {
333                    show: vec![side],
334                    hide: Vec::new(),
335                    create: Some(side),
336                    seed: true,
337                }
338            } else if shown.contains(&side) {
339                VisibilityPlan {
340                    show: Vec::new(),
341                    hide: vec![side],
342                    create: None,
343                    seed: false,
344                }
345            } else {
346                VisibilityPlan {
347                    show: vec![side],
348                    hide: Vec::new(),
349                    create: None,
350                    seed: true,
351                }
352            }
353        }
354    }
355}
356
357/// Messages handled by the app itself.
358#[derive(Debug)]
359pub enum AppMsg {
360    /// Output from the top bar of pane `id`.
361    PaneToolbar { id: u64, out: ToolbarOutput },
362    /// Output from the tree of pane `id`.
363    PaneTree { id: u64, out: TreeOutput },
364    /// A pane-menu shortcut fired anywhere in pane `id` (the tree, the path
365    /// entry, or the filter bar), resolved from the `[pane_menu]` accelerators.
366    PaneShortcut { id: u64, action: ContextAction },
367    /// The folder-picker launched for pane `id` returned.
368    OpenFolderPicked { id: u64, path: Option<PathBuf> },
369    /// A new `tree-space` invocation was forwarded by the instance socket.
370    LaunchRequest { command: Command },
371    /// Split pane `id`'s dock, seeding the new pane from `id`'s root.
372    SplitFromPane { id: u64 },
373    /// Split pane `id`'s dock with a specific root (context-menu "Open in Split
374    /// View").
375    OpenSplitFrom { id: u64, root: PathBuf },
376    /// Open `root` in a pane on the dock opposite pane `id`'s (context-menu
377    /// "In {other} panel").
378    OpenOppositeFrom { id: u64, root: PathBuf },
379    /// Remove pane `id` (closes the dock, or the whole app, when it is the
380    /// last pane).
381    ClosePane { id: u64 },
382    /// Pane `id`'s filter text changed (empty string clears the filter).
383    FilterChanged { id: u64, filter: String },
384    /// Close pane `id`'s filter bar and cancel its filter.
385    FilterClosed { id: u64 },
386    /// Quit requested (window close). Tears media down before the window closes.
387    Shutdown,
388    /// Change the width of `side`'s dock by `delta` px (drag / keyboard).
389    ResizeBy { side: PanelSide, delta: i32 },
390    /// Persist the width after an interactive resize finishes.
391    ResizeCommit,
392    /// Move keyboard focus into pane `id`'s tree (after it is allocated).
393    FocusPane { id: u64 },
394    /// Focus the active pane of every visible dock (a window was just mapped,
395    /// e.g. the launch surface), so the keyboard works without a click.
396    FocusVisible,
397    /// The bookmarks view in pane `id` reported a user action.
398    BookmarkEvent { id: u64, event: BookmarkEvent },
399    /// The bookmark editor for the entry at `index_path` was saved.
400    BookmarkEditSaved { index_path: Vec<usize>, name: String, path: Option<PathBuf> },
401    /// A new leaf bookmark was created from the bookmarks view.
402    BookmarkAdded { name: String, path: PathBuf },
403    /// A new (empty) bookmark folder was created from the bookmarks view.
404    BookmarkFolderAdded { name: String },
405}
406
407/// The init payload: the parsed invocation plus the instance socket, if this
408/// process became the single server.
409pub struct AppInit {
410    pub command: Command,
411    pub listener: Option<std::os::unix::net::UnixListener>,
412}
413
414/// Root state.
415pub struct App {
416    config: Config,
417    status: String,
418    window: gtk::Window,
419    file_dialog: gtk::FileDialog,
420    /// All dock windows. Index 0 is the primary dock (the root window).
421    docks: Vec<Dock>,
422    /// The primary dock's container child (referenced by `view!`).
423    pane_container: gtk::Box,
424    /// The panel's side for launches that do not specify one.
425    primary_side: PanelSide,
426    /// Next pane id; bumped on every pane creation so ids never repeat.
427    next_id: u64,
428    /// Whether any dock window is currently shown (drives no-arg toggling).
429    visible: bool,
430    /// Per-side dock width in pixels. Starts from session state (falling back to
431    /// `[panel] width`) and changes on interactive resize — one entry per side,
432    /// so the two docks are sized independently without parallel scalar fields.
433    widths: HashMap<PanelSide, u32>,
434    /// Most recently opened root, kept so a width save never drops it.
435    last_root: Option<PathBuf>,
436    /// The bookmarks list, loaded from the bookmarks file and written back
437    /// whenever it changes.
438    bookmarks: Vec<Bookmark>,
439    /// Monotonic id for the debounced width save: a scheduled save only writes
440    /// if it is still the latest (no `SourceId` juggling — removing a one-shot
441    /// source that has already fired panics).
442    save_generation: Rc<Cell<u64>>,
443    /// A handle to this component's own input, kept so helpers can schedule a
444    /// deferred [`AppMsg::FocusPane`] (focus must land after the newly-shown
445    /// body page is laid out).
446    sender: ComponentSender<App>,
447}
448
449#[relm4::component(pub)]
450impl SimpleComponent for App {
451    type Init = AppInit;
452    type Input = AppMsg;
453    type Output = ();
454
455    view! {
456        gtk::Window {
457            set_default_size: (model.config.panel.width as i32, 520),
458
459            gtk::Box {
460                set_orientation: gtk::Orientation::Vertical,
461                set_spacing: 0,
462                add_css_class: "panel",
463
464                append: &model.pane_container,
465
466                append: status = &gtk::Label {
467                    #[watch]
468                    set_label: &model.status,
469                    set_halign: gtk::Align::Start,
470                    set_ellipsize: gtk::pango::EllipsizeMode::End,
471                    add_css_class: "status-bar",
472                }
473            }
474        }
475    }
476
477    fn init(
478        init: Self::Init,
479        root: Self::Root,
480        sender: ComponentSender<Self>,
481    ) -> ComponentParts<Self> {
482        let loaded = Config::load();
483        let mut config = loaded.config;
484        let parent = root.clone();
485
486        // Bookmarks are runtime data kept in their own file beside the config.
487        // Materialize it (home by default) on first launch.
488        let (bookmarks, bookmark_problem) = crate::config::load_bookmarks(&config.bookmarks);
489        if let Err(problem) = crate::config::ensure_bookmarks_file(&config.bookmarks, &bookmarks) {
490            eprintln!("tree-space: could not create bookmarks file: {problem:?}");
491        }
492
493        let session = SessionState::load();
494        // An interactive resize is sticky across launches; the configured
495        // `[panel] width` is only the initial default for each side.
496        let default_width = config.panel.width;
497        let widths: HashMap<PanelSide, u32> = [
498            (
499                PanelSide::Left,
500                session.left_width.unwrap_or(default_width).clamp(PANEL_MIN_WIDTH, PANEL_MAX_WIDTH),
501            ),
502            (
503                PanelSide::Right,
504                session.right_width.unwrap_or(default_width).clamp(PANEL_MIN_WIDTH, PANEL_MAX_WIDTH),
505            ),
506        ]
507        .into_iter()
508        .collect();
509
510        let primary_side = init.command.side.unwrap_or(config.panel.side);
511        // Keep `config.panel.width` meaningful for the primary dock.
512        config.panel.width = widths[&primary_side];
513        let primary_width = config.panel.width;
514        // Build the primary dock's initial panes from the invocation. With no
515        // roots, resolve the configured startup directory; a `bookmarks` startup
516        // (the default) resolves to none, so the pane opens the bookmarks view.
517        let roots = if !init.command.roots.is_empty() {
518            init.command.roots.clone()
519        } else if config.startup.is_bookmarks() {
520            Vec::new()
521        } else {
522            let last = session.last_root.clone().filter(|p| p.is_dir());
523            let fallback = config
524                .startup
525                .resolve(last)
526                .filter(|p| p.is_dir())
527                .or_else(home_dir);
528            if let Some(p) = fallback { vec![p] } else { Vec::new() }
529        };
530
531        let mut panes: Vec<Pane> = Vec::new();
532        let mut next_id = 0u64;
533        for root in roots.iter() {
534            let pane =
535                make_pane(&config, parent.clone(), next_id, primary_side, primary_width, sender.clone());
536            pane.tree.emit(TreeMsg::OpenRoot(root.clone()));
537            panes.push(pane);
538            panes.last_mut().unwrap().set_root(root.clone());
539            next_id += 1;
540        }
541        // Guarantee at least one pane. With no roots it opens the bookmarks view
542        // (a "new panel" suggesting places to jump to).
543        if panes.is_empty() {
544            let mut pane = make_pane(
545                &config,
546                parent.clone(),
547                next_id,
548                primary_side,
549                primary_width,
550                sender.clone(),
551            );
552            pane.show_bookmarks();
553            pane.history.record(ViewEntry::Bookmarks);
554            pane.refresh_nav();
555            panes.push(pane);
556            next_id += 1;
557        }
558
559        let pane_container = gtk::Box::new(gtk::Orientation::Vertical, 0);
560        pane_container.set_vexpand(true);
561        fill_pane_container(&pane_container, &panes);
562
563        let mut status = String::new();
564        if let Some(problem) = loaded.problem {
565            status = format!("config: {problem:?}");
566        } else if let Some(problem) = bookmark_problem {
567            status = format!("bookmarks: {problem:?}");
568        }
569
570        let mut model = App {
571            config,
572            status,
573            window: root.clone(),
574            file_dialog: gtk::FileDialog::new(),
575            docks: vec![Dock {
576                side: primary_side,
577                window: root.clone(),
578                container: pane_container.clone(),
579                panes,
580                active_pane: None,
581            }],
582            pane_container,
583            primary_side,
584            next_id,
585            visible: !init.command.hidden,
586            widths,
587            last_root: session.last_root.clone(),
588            bookmarks,
589            save_generation: Rc::new(Cell::new(0)),
590            sender: sender.clone(),
591        };
592
593        init_layer_window(&model.window, &model.config, primary_side, primary_width);
594        install_css(&model.config);
595        // Interactive resize only makes sense for a docked layer surface; a
596        // plain fallback window is resized like any other window.
597        if layer_shell_available() {
598            attach_resize_controls(&model.window, primary_side, &sender);
599        }
600
601        // Serve the instance socket: every new `tree-space` invocation delivers
602        // a Command here, hopped onto the UI thread by the main context.
603        if let Some(listener) = init.listener {
604            let ipc_sender = sender.input_sender().clone();
605            ipc::spawn_listener(listener, move |command| {
606                let sender = ipc_sender.clone();
607                glib::MainContext::default().invoke(move || {
608                    let _ = sender.send(AppMsg::LaunchRequest { command });
609                });
610            });
611        }
612
613        let widgets = view_output!();
614
615        // A mapped surface takes keyboard focus, so launching the panel leaves
616        // the keyboard on the first row without a click (the compositor grants
617        // the layer's keyboard on map; this focuses the row within it).
618        {
619            let sender = sender.clone();
620            model.window.connect_map(move |_| {
621                let sender = sender.clone();
622                glib::idle_add_local_once(move || sender.input(AppMsg::FocusVisible));
623            });
624        }
625
626        // Populate every pane's bookmarks list now that the order is settled.
627        model.refresh_all_bookmarks(&sender);
628
629        // Quitting while a video thumbnail is playing is a shutdown race:
630        // `GtkMediaFile` renders through GStreamer's GL sink, and exiting with
631        // that context live lets NVIDIA's at-exit EGL teardown unmap GPU memory
632        // while the `gstglcontext` thread is still issuing GL calls (SIGSEGV in
633        // the driver). Intercept the first close, tear the media down on the
634        // main loop, and only let the window close once that has settled.
635        let shutting_down = Rc::new(Cell::new(false));
636        let flag = shutting_down.clone();
637        let shutdown_sender = sender.clone();
638        model.window.connect_close_request(move |_| {
639            if flag.get() {
640                return glib::Propagation::Proceed;
641            }
642            flag.set(true);
643            shutdown_sender.input(AppMsg::Shutdown);
644            glib::Propagation::Stop
645        });
646
647        ComponentParts { model, widgets }
648    }
649
650    fn update(&mut self, msg: Self::Input, sender: ComponentSender<Self>) {
651        match msg {
652            AppMsg::PaneToolbar { id, out } => match out {
653                ToolbarOutput::OpenFolder => {
654                    self.set_active(id);
655                    self.show_open_folder(id, &sender);
656                }
657                ToolbarOutput::NavigateTo(path) => {
658                    self.open_root_in_pane(id, path);
659                }
660                ToolbarOutput::FilterRequested => {
661                    self.set_active(id);
662                    self.open_filter(id);
663                }
664                ToolbarOutput::SplitView => {
665                    sender.input(AppMsg::SplitFromPane { id });
666                }
667                ToolbarOutput::Collapse => {
668                    self.collapse_dock(id);
669                }
670                ToolbarOutput::ClosePane => {
671                    sender.input(AppMsg::ClosePane { id });
672                }
673                ToolbarOutput::PaneItem(target) => {
674                    self.set_active(id);
675                    // A pane-level builtin (Up/Back/Forward, ...) is performed by
676                    // the app; everything else is a tree action.
677                    if let ShortcutTarget::Builtin(action) = &target
678                        && action.is_pane_action()
679                    {
680                        self.dispatch_pane_builtin(id, *action, &sender);
681                    } else if let Some((di, pi)) = self.dock_pane_of(id)
682                        && let Some(msg) = pane_item_message(&target)
683                    {
684                        self.docks[di].panes[pi].tree.emit(msg);
685                    }
686                }
687            },
688
689            AppMsg::PaneTree { id, out } => match out {
690                TreeOutput::Status(message) => {
691                    self.set_active(id);
692                    self.status = message;
693                }
694                TreeOutput::OpenFolderRequested => {
695                    self.set_active(id);
696                    self.show_open_folder(id, &sender);
697                }
698                TreeOutput::OpenSplit(root) => {
699                    self.set_active(id);
700                    sender.input(AppMsg::OpenSplitFrom { id, root });
701                }
702                TreeOutput::OpenOpposite(root) => {
703                    self.set_active(id);
704                    sender.input(AppMsg::OpenOppositeFrom { id, root });
705                }
706                TreeOutput::PaneAction(action) => {
707                    self.dispatch_pane_builtin(id, action, &sender);
708                }
709                TreeOutput::AddBookmark(path) => {
710                    self.set_active(id);
711                    self.add_bookmark(path);
712                    self.refresh_all_bookmarks(&sender);
713                }
714                TreeOutput::RootChanged(root) => {
715                    self.set_active(id);
716                    if let Some((di, pi)) = self.dock_pane_of(id) {
717                        let pane = &mut self.docks[di].panes[pi];
718                        // Record the visit for back/forward, then clear the
719                        // in-flight flag a back/forward navigation sets (so its
720                        // own root change is not recorded as a fresh visit).
721                        pane.history.record(ViewEntry::Dir(root.clone()));
722                        pane.history.finish_navigation();
723                        pane.show_tree();
724                        pane.set_root(root.clone());
725                        pane.toolbar.emit(ToolbarMsg::SetRoot(root.clone()));
726                    }
727                    self.status = String::new();
728                    // Persist the most recently opened root for the next launch.
729                    self.last_root = Some(root);
730                    self.persist_session();
731                }
732            },
733
734            AppMsg::PaneShortcut { id, action } => {
735                self.run_pane_shortcut(id, action, &sender);
736            }
737
738            AppMsg::OpenFolderPicked { id, path: Some(path) } => {
739                self.open_root_in_pane(id, path);
740            }
741            AppMsg::OpenFolderPicked { path: None, .. } => {}
742
743            AppMsg::LaunchRequest { command } => {
744                self.handle_launch(command, &sender);
745            }
746
747            AppMsg::SplitFromPane { id } => {
748                // A new panel with no path opens the bookmarks view, so the
749                // split suggests places to jump to.
750                let di = self.dock_pane_of(id).map(|(di, _)| di).unwrap_or(0);
751                self.add_pane(di, None, &sender);
752            }
753
754            AppMsg::OpenSplitFrom { id, root } => {
755                if let Some((di, _pi)) = self.dock_pane_of(id) {
756                    self.add_pane(di, Some(root), &sender);
757                }
758            }
759
760            AppMsg::OpenOppositeFrom { id, root } => {
761                if let Some((di, _pi)) = self.dock_pane_of(id) {
762                    let side = self.docks[di].side.opposite();
763                    let target = self.ensure_dock(side, false, &sender);
764                    self.add_pane(target, Some(root), &sender);
765                    // A freshly created dock starts hidden; reveal it (unless
766                    // the panel is currently toggled off).
767                    self.set_docks_visible(self.visible);
768                }
769            }
770
771            AppMsg::ClosePane { id } => {
772                let Some((di, pi)) = self.dock_pane_of(id) else {
773                    return;
774                };
775                self.docks[di].panes.remove(pi);
776                if self.docks[di].active_pane == Some(id) {
777                    self.docks[di].active_pane = self.docks[di].panes.last().map(|p| p.id);
778                }
779                // The program only exits once the *last* pane anywhere closes.
780                let panes_left: usize = self.docks.iter().map(|d| d.panes.len()).sum();
781                if panes_left == 0 {
782                    self.window.close();
783                    return;
784                }
785                if self.docks[di].panes.is_empty() {
786                    if di == 0 {
787                        // The primary dock is the relm4 root window; closing it
788                        // tears the whole app down. Hide it instead, and show it
789                        // again on the next launch/toggle.
790                        self.visible = false;
791                        self.docks[di].window.set_visible(false);
792                    } else {
793                        let dock = self.docks.remove(di);
794                        dock.window.close();
795                    }
796                } else {
797                    fill_pane_container(&self.docks[di].container.clone(), &self.docks[di].panes);
798                }
799            }
800
801            AppMsg::FilterChanged { id, filter } => {
802                if let Some((di, pi)) = self.dock_pane_of(id) {
803                    if self.docks[di].panes[pi].on_bookmarks() {
804                        self.docks[di].panes[pi].bookmark_filter = filter;
805                        self.refresh_all_bookmarks(&sender);
806                    } else {
807                        self.docks[di].panes[pi].tree.emit(TreeMsg::SetFilter(filter));
808                    }
809                }
810            }
811            AppMsg::FilterClosed { id } => {
812                if let Some((di, pi)) = self.dock_pane_of(id) {
813                    let pane = &mut self.docks[di].panes[pi];
814                    pane.filter_entry.set_text("");
815                    pane.tree.emit(TreeMsg::SetFilter(String::new()));
816                    pane.bookmark_filter.clear();
817                    pane.filter_bar.set_visible(false);
818                }
819                self.refresh_all_bookmarks(&sender);
820            }
821
822            AppMsg::FocusPane { id } => {
823                self.focus_pane(id);
824            }
825
826            AppMsg::FocusVisible => {
827                let ids: Vec<u64> = self
828                    .docks
829                    .iter()
830                    .filter(|dock| dock.window.is_visible())
831                    .filter_map(|dock| dock.panes.last().map(|pane| pane.id))
832                    .collect();
833                for id in ids {
834                    self.focus_pane(id);
835                }
836            }
837
838            AppMsg::Shutdown => {
839                // Release every video stream synchronously, walking the widget
840                // trees rather than the model so that panes already detached
841                // from `self.docks` (a closed last pane keeps its widgets alive
842                // until the window is destroyed) are covered too.
843                for dock in &self.docks {
844                    stop_video_widgets(dock.window.upcast_ref());
845                }
846                // Drop the cached media (and thumbnails) on the main loop.
847                for dock in &self.docks {
848                    for pane in &dock.panes {
849                        pane.tree.emit(TreeMsg::Shutdown);
850                    }
851                }
852                // Let the message above run and GStreamer wind its GL context
853                // down before the window closes and the process exits.
854                let window = self.window.clone();
855                glib::timeout_add_local_once(std::time::Duration::from_millis(SHUTDOWN_GRACE_MS), move || {
856                    window.close();
857                });
858            }
859
860            AppMsg::ResizeBy { side, delta } => self.resize_by(side, delta),
861            AppMsg::ResizeCommit => self.persist_session(),
862
863            AppMsg::BookmarkEvent { id, event } => match event {
864                BookmarkEvent::Open(path) => self.open_bookmark(id, path, &sender),
865                BookmarkEvent::Toggle(index_path) => {
866                    if let Some(entry) = Bookmark::get_mut(&mut self.bookmarks, &index_path) {
867                        entry.expanded = !entry.expanded;
868                    }
869                    self.refresh_all_bookmarks(&sender);
870                }
871                BookmarkEvent::Edit(index_path) => self.edit_bookmark(id, index_path, &sender),
872                BookmarkEvent::NewBookmark => self.new_bookmark(id, &sender),
873                BookmarkEvent::NewBookmarkFolder => self.new_bookmark_folder(id, &sender),
874                BookmarkEvent::Action { path, target } => {
875                    self.run_bookmark_action(id, path, target);
876                }
877                BookmarkEvent::Move { from, to } => self.move_bookmark(from, to, &sender),
878                BookmarkEvent::Delete(index_path) => {
879                    if Bookmark::remove(&mut self.bookmarks, &index_path) {
880                        self.save_bookmarks();
881                        self.refresh_all_bookmarks(&sender);
882                    }
883                }
884            },
885            AppMsg::BookmarkEditSaved { index_path, name, path } => {
886                if let Some(entry) = Bookmark::get_mut(&mut self.bookmarks, &index_path) {
887                    entry.name = name;
888                    // A folder has no path; a leaf always sets one.
889                    if path.is_some() {
890                        entry.path = path;
891                    }
892                    self.save_bookmarks();
893                    self.refresh_all_bookmarks(&sender);
894                }
895            }
896            AppMsg::BookmarkAdded { name, path } => {
897                if Bookmark::contains_path(&self.bookmarks, &path) {
898                    self.status = format!("{} is already bookmarked", path.display());
899                    return;
900                }
901                self.bookmarks.push(Bookmark::leaf(name, path));
902                self.save_bookmarks();
903                self.refresh_all_bookmarks(&sender);
904            }
905            AppMsg::BookmarkFolderAdded { name } => {
906                self.bookmarks.push(Bookmark {
907                    name,
908                    path: None,
909                    items: Vec::new(),
910                    expanded: false,
911                });
912                self.save_bookmarks();
913                self.refresh_all_bookmarks(&sender);
914            }
915        }
916    }
917}
918
919impl App {
920    /// Apply a forwarded launch request.
921    fn handle_launch(&mut self, command: Command, sender: &ComponentSender<Self>) {
922        let hidden = command.hidden;
923
924        // A width change is a side effect that never touches visibility. It
925        // targets the named side, or the primary side when none is given.
926        if let Some(width) = command.width {
927            let side = command.side.unwrap_or(self.primary_side);
928            match width {
929                WidthArg::To(px) => self.set_width(side, px),
930                WidthArg::By(delta) => self.resize_by(side, delta),
931            }
932            self.persist_session();
933            if command.roots.is_empty() && command.reveal.is_empty() {
934                return;
935            }
936        }
937
938        if command.roots.is_empty() && command.reveal.is_empty() {
939            // `--hidden` always means "hide", never toggle. Otherwise the
940            // command is a show/toggle scoped to a side or to every dock.
941            let intent = match (command.side, hidden) {
942                (Some(side), true) => VisibilityIntent::HideSide(side),
943                (Some(side), false) => VisibilityIntent::ToggleSide(side),
944                (None, true) => VisibilityIntent::HideAll,
945                (None, false) => VisibilityIntent::ToggleAll,
946            };
947            self.apply_visibility(intent, sender);
948            return;
949        }
950
951        for root in &command.roots {
952            if self.find_pane_with_dir(root).is_some() {
953                // The directory is already open in some pane: never spawn a
954                // duplicate — just keep it. The panel is shown below.
955                continue;
956            }
957            let side = command.side.unwrap_or(self.primary_side);
958            let di = self.ensure_dock(side, false, sender);
959            self.add_pane(di, Some(root.clone()), sender);
960        }
961
962        // Reveal each `--select`/file argument: open a pane rooted at the
963        // path's parent and select the path inside it. Reveals always open a
964        // fresh pane on the default side (they carry a specific target, so
965        // reusing an existing pane would lose it).
966        for (root, select) in command.reveal_targets() {
967            if !root.is_dir() {
968                continue;
969            }
970            let side = command.side.unwrap_or(self.primary_side);
971            let di = self.ensure_dock(side, false, sender);
972            let id = self.add_pane(di, Some(root), sender);
973            if let Some(select) = select
974                && let Some((di, pi)) = self.dock_pane_of(id)
975            {
976                self.docks[di].panes[pi].tree.emit(TreeMsg::SelectPath(select));
977            }
978        }
979        // Show the panel, unless this launch asked to stay hidden.
980        self.set_docks_visible(!hidden);
981    }
982
983    /// Carry out a [`VisibilityIntent`] using the pure [`resolve_visibility`]
984    /// plan. Creates a dock when the intent calls for it and seeds an empty one
985    /// so "show" never reveals an empty shell.
986    fn apply_visibility(&mut self, intent: VisibilityIntent, sender: &ComponentSender<Self>) {
987        let existing: Vec<PanelSide> = self.docks.iter().map(|d| d.side).collect();
988        let shown: Vec<PanelSide> = self
989            .docks
990            .iter()
991            .filter(|d| d.window.is_visible())
992            .map(|d| d.side)
993            .collect();
994        let plan = resolve_visibility(intent, &existing, &shown);
995
996        if let Some(side) = plan.create {
997            self.ensure_dock(side, plan.seed, sender);
998        }
999        for side in &plan.show {
1000            // A newly created dock starts hidden; reveal it. An emptied primary
1001            // dock is re-seeded so showing it is not an empty shell.
1002            let di = self.ensure_dock(*side, plan.seed, sender);
1003            self.docks[di].window.set_visible(true);
1004        }
1005        for side in &plan.hide {
1006            if let Some(di) = self.dock_of_side(*side) {
1007                self.docks[di].window.set_visible(false);
1008            }
1009        }
1010        // Any side not named by the plan keeps its current visibility.
1011        self.refresh_visible();
1012        // When a side was just shown, hand keyboard focus to its top pane so
1013        // the panel is immediately usable.
1014        let focus: Vec<u64> = plan
1015            .show
1016            .iter()
1017            .filter_map(|side| self.dock_of_side(*side))
1018            .filter_map(|di| self.docks[di].panes.last())
1019            .map(|pane| pane.id)
1020            .collect();
1021        for id in focus {
1022            self.focus_pane(id);
1023        }
1024    }
1025
1026    /// Set `id` as the active pane in whichever dock holds it.
1027    fn set_active(&mut self, id: u64) {
1028        if let Some((di, _pi)) = self.dock_pane_of(id) {
1029            self.docks[di].active_pane = Some(id);
1030        }
1031    }
1032
1033    /// Hand keyboard focus to pane `id`: the bookmark cursor when the bookmarks
1034    /// view is showing, otherwise the tree's current row. Used on launch, on
1035    /// show, and when a pane becomes active so the keyboard works without a
1036    /// click.
1037    fn focus_pane(&mut self, id: u64) {
1038        let Some((di, pi)) = self.dock_pane_of(id) else {
1039            return;
1040        };
1041        let pane = &self.docks[di].panes[pi];
1042        if pane.on_bookmarks() {
1043            pane.bookmark_nav.focus_start();
1044        } else {
1045            pane.tree.emit(TreeMsg::Focus);
1046        }
1047    }
1048
1049    /// Focus pane `id` on the next idle. Used after a body switch: the new page
1050    /// (and its freshly-loaded tree rows) is not laid out until then, so an
1051    /// immediate `grab_focus` would land on the now-hidden page.
1052    fn focus_pane_later(&self, id: u64) {
1053        let sender = self.sender.clone();
1054        glib::idle_add_local_once(move || sender.input(AppMsg::FocusPane { id }));
1055    }
1056
1057    /// Show the filter bar for pane `id` and focus its entry. The bar was just
1058    /// mapped, so an immediate `grab_focus()` no-ops (GTK applies visibility on
1059    /// the next layout pass) and keystrokes would land in the tree; defer it.
1060    fn open_filter(&mut self, id: u64) {
1061        if let Some((di, pi)) = self.dock_pane_of(id) {
1062            let pane = &mut self.docks[di].panes[pi];
1063            pane.filter_bar.set_visible(true);
1064            let entry = pane.filter_entry.clone();
1065            glib::idle_add_local_once(move || {
1066                entry.grab_focus();
1067            });
1068        }
1069    }
1070
1071    /// Append `path` to the bookmarks (skipping a duplicate path, at any depth)
1072    /// and persist.
1073    fn add_bookmark(&mut self, path: PathBuf) {
1074        if Bookmark::contains_path(&self.bookmarks, &path) {
1075            self.status = format!("{} is already bookmarked", path.display());
1076            return;
1077        }
1078        self.bookmarks.push(Bookmark::leaf(Bookmark::default_name(&path), path.clone()));
1079        self.save_bookmarks();
1080        self.status = format!("Bookmarked {}", path.display());
1081    }
1082
1083    /// Show `path` in pane `id`: switch its body to the tree and load the
1084    /// directory (leaving the bookmarks view, if it was showing).
1085    fn open_root_in_pane(&mut self, id: u64, path: PathBuf) {
1086        if let Some((di, pi)) = self.dock_pane_of(id) {
1087            self.docks[di].panes[pi].show_tree();
1088            self.docks[di].panes[pi].tree.emit(TreeMsg::OpenRoot(path));
1089            self.set_active(id);
1090        }
1091        self.focus_pane_later(id);
1092    }
1093
1094    /// Jump pane `id` to a bookmark's directory, replacing its bookmarks view
1095    /// (or its current directory) with that folder.
1096    fn open_bookmark(&mut self, id: u64, path: PathBuf, _sender: &ComponentSender<Self>) {
1097        let path = crate::config::expand_bookmark_path(&path);
1098        if !path.is_dir() {
1099            self.status = format!("{} is not a directory", path.display());
1100            return;
1101        }
1102        self.open_root_in_pane(id, path);
1103    }
1104
1105    /// Run a bookmark's inherited context action against its directory without
1106    /// opening it. `Open` navigates (a directory) or launches (a file); other
1107    /// path-safe builtins are sent to the pane's tree, which acts on the
1108    /// explicit path; custom commands run against the path.
1109    fn run_bookmark_action(&mut self, id: u64, path: PathBuf, target: ShortcutTarget) {
1110        let path = crate::config::expand_bookmark_path(&path);
1111        let Some((di, pi)) = self.dock_pane_of(id) else {
1112            return;
1113        };
1114        match target {
1115            ShortcutTarget::Builtin(BuiltinAction::Open) => {
1116                if path.is_dir() {
1117                    self.open_root_in_pane(id, path);
1118                } else {
1119                    self.docks[di].panes[pi].tree.emit(TreeMsg::OpenWithDefault(path));
1120                }
1121            }
1122            ShortcutTarget::Builtin(action) => {
1123                if let Some(msg) = crate::ui::tree::path_action_message(action, &path) {
1124                    self.docks[di].panes[pi].tree.emit(msg);
1125                }
1126            }
1127            ShortcutTarget::Command(cmd) => {
1128                self.docks[di].panes[pi]
1129                    .tree
1130                    .emit(TreeMsg::RunCommand { command: cmd.command, path });
1131            }
1132        }
1133    }
1134
1135    /// Switch pane `id` to the bookmarks view (the hamburger "Bookmarks" item).
1136    fn show_bookmarks_view(&mut self, id: u64) {
1137        if let Some((di, pi)) = self.dock_pane_of(id) {
1138            self.docks[di].panes[pi].show_bookmarks();
1139            self.docks[di].panes[pi].history.record(ViewEntry::Bookmarks);
1140            self.docks[di].panes[pi].refresh_nav();
1141            self.set_active(id);
1142        }
1143        self.focus_pane_later(id);
1144    }
1145
1146    /// Open the bookmark editor for the entry at `index_path`, parented to the
1147    /// window of pane `id`.
1148    fn edit_bookmark(&mut self, id: u64, index_path: Vec<usize>, sender: &ComponentSender<Self>) {
1149        let Some(bookmark) = Bookmark::get(&self.bookmarks, &index_path).cloned() else {
1150            return;
1151        };
1152        let Some((di, _pi)) = self.dock_pane_of(id) else { return };
1153        let parent = self.docks[di].window.clone();
1154        let sender = sender.clone();
1155        let path = bookmark.path.as_deref().map(crate::config::expand_bookmark_path);
1156        let title = if bookmark.is_folder() { "Edit Folder" } else { "Edit Bookmark" };
1157        bookmarks::show_bookmark_dialog(
1158            &parent,
1159            title,
1160            &bookmark.name,
1161            path.as_deref(),
1162            move |name, path| {
1163                sender.input(AppMsg::BookmarkEditSaved { index_path: index_path.clone(), name, path });
1164            },
1165        );
1166    }
1167
1168    /// Open the "new bookmark" dialog for pane `id`: a name and a path (with a
1169    /// folder picker), matching the edit dialog.
1170    fn new_bookmark(&mut self, id: u64, sender: &ComponentSender<Self>) {
1171        let Some((di, _pi)) = self.dock_pane_of(id) else { return };
1172        let parent = self.docks[di].window.clone();
1173        let sender = sender.clone();
1174        bookmarks::show_bookmark_dialog(
1175            &parent,
1176            "New Bookmark",
1177            "",
1178            Some(Path::new("")),
1179            move |name, path| {
1180                if let Some(path) = path {
1181                    sender.input(AppMsg::BookmarkAdded { name, path });
1182                }
1183            },
1184        );
1185    }
1186
1187    /// Open the "new folder" dialog for pane `id`: a name only.
1188    fn new_bookmark_folder(&mut self, id: u64, sender: &ComponentSender<Self>) {
1189        let Some((di, _pi)) = self.dock_pane_of(id) else { return };
1190        let parent = self.docks[di].window.clone();
1191        let sender = sender.clone();
1192        bookmarks::show_bookmark_dialog(&parent, "New Folder", "", None, move |name, _path| {
1193            sender.input(AppMsg::BookmarkFolderAdded { name });
1194        });
1195    }
1196
1197    /// Move the bookmark entry at `from` to `to` (drag and drop), then persist.
1198    /// Moving an entry into itself or one of its descendants is refused.
1199    fn move_bookmark(&mut self, from: Vec<usize>, to: MoveTarget, sender: &ComponentSender<Self>) {
1200        if from.is_empty() {
1201            return;
1202        }
1203        // Resolve the destination against the tree *after* the entry is removed,
1204        // so index shifts from the removal are accounted for.
1205        let (parent, index) = match &to {
1206            MoveTarget::Root => (Vec::new(), usize::MAX),
1207            MoveTarget::Into(folder) => {
1208                if folder.starts_with(&from) {
1209                    return;
1210                }
1211                (adjust_path_after_removal(&from, folder), usize::MAX)
1212            }
1213            MoveTarget::Before(leaf) => {
1214                if leaf.is_empty() || leaf.starts_with(&from) {
1215                    return;
1216                }
1217                let parent_orig = leaf[..leaf.len() - 1].to_vec();
1218                let parent = adjust_path_after_removal(&from, &parent_orig);
1219                let mut index = leaf[leaf.len() - 1];
1220                // A removal earlier in the same sibling list shifts the target.
1221                if from.len() == leaf.len()
1222                    && from[..from.len() - 1] == parent_orig[..]
1223                    && from[from.len() - 1] < index
1224                {
1225                    index -= 1;
1226                }
1227                (parent, index)
1228            }
1229        };
1230        let Some(entry) = Bookmark::take(&mut self.bookmarks, &from) else {
1231            return;
1232        };
1233        if !Bookmark::insert(&mut self.bookmarks, &parent, index, entry) {
1234            return;
1235        }
1236        self.save_bookmarks();
1237        self.refresh_all_bookmarks(sender);
1238    }
1239
1240    /// Save the bookmarks list to its file, reporting any failure.
1241    fn save_bookmarks(&mut self) {
1242        let path = bookmark_file_path(&self.config.bookmarks.file);
1243        if let Err(err) = save_bookmarks_to_path(&path, &self.bookmarks) {
1244            self.status = format!("Could not save bookmarks: {err:?}");
1245        }
1246    }
1247
1248    /// Refill every pane's bookmarks list after the list changes. Panes showing
1249    /// the view update in place; hidden ones are ready when next shown. Each
1250    /// pane's own filter and menu config are applied.
1251    fn refresh_all_bookmarks(&mut self, sender: &ComponentSender<Self>) {
1252        let context = self.config.context_menu.clone();
1253        let extras = self.config.bookmarks.context.clone();
1254        let bookmarks = self.bookmarks.clone();
1255        for di in 0..self.docks.len() {
1256            for pi in 0..self.docks[di].panes.len() {
1257                let pane = &self.docks[di].panes[pi];
1258                let menu = bookmarks::BookmarkMenuConfig {
1259                    context: &context,
1260                    extras: &extras,
1261                    side: self.docks[di].side,
1262                };
1263                let id = pane.id;
1264                let filter = pane.bookmark_filter.clone();
1265                let sender = sender.clone();
1266                let on_event: Rc<dyn Fn(BookmarkEvent)> =
1267                    Rc::new(move |event| sender.input(AppMsg::BookmarkEvent { id, event }));
1268                bookmarks::fill_bookmarks(
1269                    &pane.bookmarks_list,
1270                    &bookmarks,
1271                    &filter,
1272                    &menu,
1273                    &pane.bookmark_drag,
1274                    &pane.bookmark_nav,
1275                    on_event,
1276                );
1277            }
1278        }
1279    }
1280
1281    /// Run a pane-level builtin (`Split View`, `Open Folder...`, `Filter...`,
1282    /// `Collapse`, `Close Pane`) against pane `id`. Shared by the toolbar and by
1283    /// keyboard shortcuts forwarded up from the tree.
1284    fn dispatch_pane_builtin(
1285        &mut self,
1286        id: u64,
1287        action: BuiltinAction,
1288        sender: &ComponentSender<Self>,
1289    ) {
1290        self.set_active(id);
1291        match action {
1292            BuiltinAction::OpenFolder => self.show_open_folder(id, sender),
1293            BuiltinAction::Filter => self.open_filter(id),
1294            BuiltinAction::SplitView => sender.input(AppMsg::SplitFromPane { id }),
1295            BuiltinAction::Up => self.go_up(id),
1296            BuiltinAction::Back => self.go_back(id),
1297            BuiltinAction::Forward => self.go_forward(id),
1298            BuiltinAction::Collapse => self.collapse_dock(id),
1299            BuiltinAction::ClosePane => sender.input(AppMsg::ClosePane { id }),
1300            BuiltinAction::ToggleBookmarks => self.show_bookmarks_view(id),
1301            BuiltinAction::NewBookmark => self.new_bookmark(id, sender),
1302            BuiltinAction::NewBookmarkFolder => self.new_bookmark_folder(id, sender),
1303            _ => {}
1304        }
1305    }
1306
1307    /// Open the parent directory of pane `id`'s current root (`Up One Level`).
1308    /// A no-op at the filesystem root.
1309    fn go_up(&mut self, id: u64) {
1310        let Some((di, pi)) = self.dock_pane_of(id) else {
1311            return;
1312        };
1313        let Some(parent) = self.docks[di].panes[pi]
1314            .root
1315            .as_deref()
1316            .and_then(Path::parent)
1317            .map(Path::to_path_buf)
1318        else {
1319            return;
1320        };
1321        self.docks[di].panes[pi].tree.emit(TreeMsg::OpenRoot(parent));
1322        self.focus_pane_later(id);
1323    }
1324
1325    /// Step pane `id` back one entry in its history and show it.
1326    fn go_back(&mut self, id: u64) {
1327        let Some((di, pi)) = self.dock_pane_of(id) else {
1328            return;
1329        };
1330        if let Some(target) = self.docks[di].panes[pi].history.back() {
1331            self.show_view_entry(id, target);
1332        }
1333    }
1334
1335    /// Step pane `id` forward one entry in its history and show it.
1336    fn go_forward(&mut self, id: u64) {
1337        let Some((di, pi)) = self.dock_pane_of(id) else {
1338            return;
1339        };
1340        if let Some(target) = self.docks[di].panes[pi].history.forward() {
1341            self.show_view_entry(id, target);
1342        }
1343    }
1344
1345    /// Show a history entry in pane `id`: a directory (load it in the tree) or
1346    /// the bookmarks view. A back/forward to the bookmarks view produces no
1347    /// `RootChanged`, so its navigation flag is cleared here.
1348    fn show_view_entry(&mut self, id: u64, entry: ViewEntry) {
1349        let Some((di, pi)) = self.dock_pane_of(id) else {
1350            return;
1351        };
1352        match entry {
1353            ViewEntry::Dir(path) => {
1354                self.docks[di].panes[pi].show_tree();
1355                self.docks[di].panes[pi].tree.emit(TreeMsg::OpenRoot(path));
1356            }
1357            ViewEntry::Bookmarks => {
1358                self.docks[di].panes[pi].show_bookmarks();
1359                self.docks[di].panes[pi].history.finish_navigation();
1360                self.docks[di].panes[pi].refresh_nav();
1361            }
1362        }
1363        self.set_active(id);
1364        self.focus_pane_later(id);
1365    }
1366
1367    /// Run a pane-menu shortcut resolved anywhere in pane `id`. Pane builtins go
1368    /// to [`Self::dispatch_pane_builtin`]; view builtins and custom commands are
1369    /// sent to the pane's tree (which targets the open directory).
1370    fn run_pane_shortcut(
1371        &mut self,
1372        id: u64,
1373        action: ContextAction,
1374        sender: &ComponentSender<Self>,
1375    ) {
1376        self.set_active(id);
1377        let Some(target) = ShortcutTarget::from_action(&action) else {
1378            return;
1379        };
1380        if let ShortcutTarget::Builtin(builtin) = &target
1381            && builtin.is_pane_action()
1382        {
1383            self.dispatch_pane_builtin(id, *builtin, sender);
1384            return;
1385        }
1386        if let Some((di, pi)) = self.dock_pane_of(id)
1387            && let Some(msg) = pane_item_message(&target)
1388        {
1389            self.docks[di].panes[pi].tree.emit(msg);
1390        }
1391    }
1392
1393    /// Locate `(dock_index, pane_index)` for a pane id.
1394    fn dock_pane_of(&self, id: u64) -> Option<(usize, usize)> {
1395        self.docks.iter().enumerate().find_map(|(di, dock)| {
1396            dock.panes
1397                .iter()
1398                .position(|pane| pane.id == id)
1399                .map(|pi| (di, pi))
1400        })
1401    }
1402
1403    /// Find a pane (across all docks) that already shows `path`, comparing
1404    /// canonical forms so that spellings like `./x` and `/a/x` dedupe. The
1405    /// canonical form of each pane root is cached on the pane, so this does no
1406    /// filesystem work for the common case.
1407    fn find_pane_with_dir(&self, path: &PathBuf) -> Option<(usize, usize)> {
1408        let canonical = std::fs::canonicalize(path).ok().unwrap_or_else(|| path.clone());
1409        self.docks.iter().enumerate().find_map(|(di, dock)| {
1410            dock.panes.iter().enumerate().find_map(|(pi, pane)| {
1411                if pane.canonical_root.as_deref() == Some(canonical.as_path()) {
1412                    Some((di, pi))
1413                } else {
1414                    None
1415                }
1416            })
1417        })
1418    }
1419
1420    /// Index of the dock on `side`, if one exists.
1421    fn dock_of_side(&self, side: PanelSide) -> Option<usize> {
1422        self.docks.iter().position(|dock| dock.side == side)
1423    }
1424
1425    /// Hide the entire dock that holds pane `id` (the toolbar "Collapse"
1426    /// action). The dock keeps its panes, so it comes back on the next show.
1427    fn collapse_dock(&mut self, id: u64) {
1428        if let Some((di, _)) = self.dock_pane_of(id) {
1429            self.set_dock_visible(di, false);
1430        }
1431    }
1432
1433    /// Show/hide one dock.
1434    fn set_dock_visible(&mut self, di: usize, visible: bool) {
1435        self.docks[di].window.set_visible(visible);
1436        self.refresh_visible();
1437    }
1438
1439    /// Show/hide every dock window.
1440    fn set_docks_visible(&mut self, visible: bool) {
1441        let focus: Vec<u64> = self
1442            .docks
1443            .iter()
1444            .filter_map(|dock| dock.panes.last().map(|pane| pane.id))
1445            .collect();
1446        for dock in &self.docks {
1447            dock.window.set_visible(visible);
1448        }
1449        self.visible = visible;
1450        // Hand keyboard focus to each dock's active pane. The window-map handler
1451        // also does this once the surface is realized, so a just-shown panel
1452        // gets focus even though the grab here may precede allocation.
1453        if visible {
1454            for id in focus {
1455                self.focus_pane(id);
1456            }
1457        }
1458    }
1459
1460    /// Recomputed flag: true while at least one dock is shown. Drives the
1461    /// no-argument toggle.
1462    fn refresh_visible(&mut self) {
1463        self.visible = self.docks.iter().any(|dock| dock.window.is_visible());
1464    }
1465
1466    /// The current width of `side`'s dock.
1467    fn width_for(&self, side: PanelSide) -> u32 {
1468        self.widths.get(&side).copied().unwrap_or(self.config.panel.width)
1469    }
1470
1471    /// Resize `side`'s dock to `width` px, clamped, telling its trees so inline
1472    /// thumbnails re-measure. Saves are debounced.
1473    fn set_width(&mut self, side: PanelSide, width: u32) {
1474        let width = width.clamp(PANEL_MIN_WIDTH, PANEL_MAX_WIDTH);
1475        if width == self.width_for(side) {
1476            return;
1477        }
1478        self.widths.insert(side, width);
1479        let panel = PanelConfig { width, ..self.config.panel };
1480        for dock in self.docks.iter().filter(|dock| dock.side == side) {
1481            apply_window_width(&dock.window, width);
1482            for pane in &dock.panes {
1483                pane.tree.emit(TreeMsg::SetPanel(panel));
1484            }
1485        }
1486        // Keep the config's copy in step with the primary dock.
1487        if side == self.primary_side {
1488            self.config.panel.width = width;
1489        }
1490        self.schedule_save();
1491    }
1492
1493    /// Change `side`'s width by `delta` px (negative narrows).
1494    fn resize_by(&mut self, side: PanelSide, delta: i32) {
1495        let target = (self.width_for(side) as i64 + delta as i64)
1496            .clamp(PANEL_MIN_WIDTH as i64, PANEL_MAX_WIDTH as i64) as u32;
1497        self.set_width(side, target);
1498    }
1499
1500    /// The session state to persist: last root plus both dock widths.
1501    fn session_state(&self) -> SessionState {
1502        SessionState {
1503            last_root: self.last_root.clone(),
1504            left_width: Some(self.width_for(PanelSide::Left)),
1505            right_width: Some(self.width_for(PanelSide::Right)),
1506        }
1507    }
1508
1509    /// Persist session state now. Invalidates any pending debounced save.
1510    fn persist_session(&mut self) {
1511        self.save_generation.set(self.save_generation.get().wrapping_add(1));
1512        if let Err(err) = self.session_state().save() {
1513            self.status = format!("Could not save session: {err}");
1514        }
1515    }
1516
1517    /// Persist session state after a short quiet period, coalescing the many
1518    /// width changes a drag produces into a single write. A later change bumps
1519    /// the generation, so only the newest scheduled save actually writes.
1520    fn schedule_save(&mut self) {
1521        let generation = self.save_generation.get().wrapping_add(1);
1522        self.save_generation.set(generation);
1523        let current = self.save_generation.clone();
1524        let state = self.session_state();
1525        glib::timeout_add_local_once(std::time::Duration::from_millis(SAVE_DEBOUNCE_MS), move || {
1526            if current.get() == generation {
1527                let _ = state.save();
1528            }
1529        });
1530    }
1531
1532    /// Return the index of the dock on `side`, creating it (with a seeded pane
1533    /// from session state) when missing. The primary dock is always the
1534    /// config/default side.
1535    fn ensure_dock(&mut self, side: PanelSide, seed: bool, sender: &ComponentSender<Self>) -> usize {
1536        if let Some((di, _)) = self.docks.iter().enumerate().find(|(_, d)| d.side == side) {
1537            if seed && self.docks[di].panes.is_empty() {
1538                // The dock exists but was emptied; give it a pane again.
1539                let startup = self.config.startup.clone();
1540                if let Some(root) = default_root(&startup) {
1541                    self.add_pane(di, Some(root), sender);
1542                }
1543            }
1544            return di;
1545        }
1546        let config = &self.config;
1547        let width = self.width_for(side);
1548        let window = gtk::Window::new();
1549        init_layer_window(&window, config, side, width);
1550        window.set_default_size(width as i32, 520);
1551        // As with the primary dock, hand focus to this dock's pane once its
1552        // surface is mapped (grab_focus before that no-ops).
1553        {
1554            let sender = sender.clone();
1555            window.connect_map(move |_| {
1556                let sender = sender.clone();
1557                glib::idle_add_local_once(move || sender.input(AppMsg::FocusVisible));
1558            });
1559        }
1560        if layer_shell_available() {
1561            attach_resize_controls(&window, side, sender);
1562        }
1563        // The window's child is the box holding the pane stack.
1564        let outer = gtk::Box::new(gtk::Orientation::Vertical, 0);
1565        outer.set_vexpand(true);
1566        let container = gtk::Box::new(gtk::Orientation::Vertical, 0);
1567        container.set_vexpand(true);
1568        outer.append(&container);
1569        window.set_child(Some(&outer));
1570
1571        let di = self.docks.len();
1572        self.docks.push(Dock {
1573            side,
1574            window,
1575            container,
1576            panes: Vec::new(),
1577            active_pane: None,
1578        });
1579        if seed {
1580            let startup = self.config.startup.clone();
1581            if let Some(root) = default_root(&startup) {
1582                self.add_pane(di, Some(root), sender);
1583            } else {
1584                // No directory: seed the dock with a bookmarks pane.
1585                self.add_pane(di, None, sender);
1586            }
1587        }
1588        di
1589    }
1590
1591    /// Append a pane showing `root` to dock `di`, returning its id. A `None`
1592    /// root opens the bookmarks view instead of a directory.
1593    fn add_pane(&mut self, di: usize, root: Option<PathBuf>, sender: &ComponentSender<Self>) -> u64 {
1594        let id = self.next_id;
1595        self.next_id += 1;
1596        let parent = self.docks[di].window.clone();
1597        let side = self.docks[di].side;
1598        let width = self.width_for(side);
1599        let pane = make_pane(&self.config, parent, id, side, width, sender.clone());
1600        let mut pane = pane;
1601        match &root {
1602            Some(root) => pane.tree.emit(TreeMsg::OpenRoot(root.clone())),
1603            None => {
1604                pane.show_bookmarks();
1605                pane.history.record(ViewEntry::Bookmarks);
1606                pane.refresh_nav();
1607            }
1608        }
1609        if let Some(root) = &root {
1610            pane.set_root(root.clone());
1611        }
1612        let dock = &mut self.docks[di];
1613        dock.panes.push(pane);
1614        dock.active_pane = Some(id);
1615        let container = dock.container.clone();
1616        fill_pane_container(&container, &dock.panes);
1617        // Give the new pane keyboard focus once GTK has allocated it, so the
1618        // app (and each new split) is usable without a click.
1619        let focus_sender = sender.clone();
1620        glib::idle_add_local_once(move || focus_sender.input(AppMsg::FocusPane { id }));
1621        // Populate the new pane's bookmarks list.
1622        self.refresh_all_bookmarks(sender);
1623        id
1624    }
1625
1626    fn show_open_folder(&self, id: u64, sender: &ComponentSender<Self>) {
1627        let sender = sender.clone();
1628        self.file_dialog.select_folder(
1629            None::<&gtk::Window>,
1630            None::<&gio::Cancellable>,
1631            move |result| {
1632                let path = result.ok().and_then(|file| file.path());
1633                sender.input(AppMsg::OpenFolderPicked { id, path });
1634            },
1635        );
1636    }
1637}
1638
1639// ---------------------------------------------------------------------------
1640// helpers
1641// ---------------------------------------------------------------------------
1642
1643/// Map a configurable pane-menu item to the tree message that performs it.
1644/// Pane-level view actions have dedicated messages; custom commands are run
1645/// through the tree's shortcut path (which targets the open directory).
1646fn pane_item_message(target: &ShortcutTarget) -> Option<TreeMsg> {
1647    match target {
1648        ShortcutTarget::Builtin(action) => match action {
1649            BuiltinAction::ToggleHidden => Some(TreeMsg::ToggleHidden),
1650            BuiltinAction::SortByName => Some(TreeMsg::SetSortKey(SortKey::Name)),
1651            BuiltinAction::SortBySize => Some(TreeMsg::SetSortKey(SortKey::Size)),
1652            BuiltinAction::SortByModified => Some(TreeMsg::SetSortKey(SortKey::Modified)),
1653            BuiltinAction::SortByType => Some(TreeMsg::SetSortKey(SortKey::Type)),
1654            BuiltinAction::ToggleSortAscending => Some(TreeMsg::ToggleSortDirection),
1655            other => Some(TreeMsg::RunShortcut {
1656                target: ShortcutTarget::Builtin(*other),
1657            }),
1658        },
1659        ShortcutTarget::Command(_) => Some(TreeMsg::RunShortcut { target: target.clone() }),
1660    }
1661}
1662
1663/// Build the optional navigation toolbar: Up One Level, Back, Forward. Each
1664/// button dispatches its pane builtin through the same path as the hamburger,
1665/// so no new message type is needed.
1666fn build_nav_bar(id: u64, sender: &ComponentSender<App>) -> (gtk::Box, NavButtons) {
1667    let bar = gtk::Box::new(gtk::Orientation::Horizontal, 2);
1668    bar.add_css_class("nav-toolbar");
1669
1670    let up = nav_button("pan-up-symbolic", "Up One Level", BuiltinAction::Up, id, sender);
1671    let back = nav_button("pan-start-symbolic", "Back", BuiltinAction::Back, id, sender);
1672    let forward = nav_button("pan-end-symbolic", "Forward", BuiltinAction::Forward, id, sender);
1673
1674    bar.append(&up);
1675    bar.append(&back);
1676    bar.append(&forward);
1677    (bar, NavButtons { up, back, forward })
1678}
1679
1680/// One flat icon button in the navigation toolbar. Not focusable, so clicking it
1681/// never pulls the keyboard out of the tree or bookmarks list.
1682fn nav_button(
1683    icon: &str,
1684    tooltip: &str,
1685    action: BuiltinAction,
1686    id: u64,
1687    sender: &ComponentSender<App>,
1688) -> gtk::Button {
1689    let button = gtk::Button::from_icon_name(icon);
1690    button.add_css_class("nav-button");
1691    button.add_css_class("flat");
1692    button.set_tooltip_text(Some(tooltip));
1693    button.set_focusable(false);
1694    button.set_can_focus(false);
1695    button.set_valign(gtk::Align::Center);
1696    let sender = sender.clone();
1697    button.connect_clicked(move |_| {
1698        sender.input(AppMsg::PaneToolbar {
1699            id,
1700            out: ToolbarOutput::PaneItem(ShortcutTarget::Builtin(action)),
1701        });
1702    });
1703    button
1704}
1705
1706fn make_pane(
1707    config: &Config,
1708    parent: gtk::Window,
1709    id: u64,
1710    side: PanelSide,
1711    width: u32,
1712    sender: ComponentSender<App>,
1713) -> Pane {
1714    // The pane overlay: the toolbar's completion dropdown is added to it so it
1715    // can draw over the tree while the entry keeps keyboard focus.
1716    let overlay = gtk::Overlay::new();
1717    let toolbar = Toolbar::builder()
1718        .launch(ToolbarInit {
1719            overlay: overlay.clone(),
1720            pane_menu: config.pane_menu.clone(),
1721            bookmarks_menu: config.bookmarks.menu.clone(),
1722        })
1723        .forward(sender.input_sender(), move |out| AppMsg::PaneToolbar { id, out });
1724    let tree = Tree::builder()
1725        .launch(TreeInit {
1726            config: config.tree.clone(),
1727            parent,
1728            menu: config.context_menu.clone(),
1729            side,
1730            panel: PanelConfig { width, ..config.panel },
1731        })
1732        .forward(sender.input_sender(), move |out| AppMsg::PaneTree { id, out });
1733
1734    let widget = gtk::Box::new(gtk::Orientation::Vertical, 0);
1735    widget.append(toolbar.widget());
1736
1737    // ── optional navigation toolbar (up one level / back / forward) ─────────
1738    let nav_buttons = if config.panel.nav_toolbar {
1739        let (bar, buttons) = build_nav_bar(id, &sender);
1740        widget.append(&bar);
1741        Some(buttons)
1742    } else {
1743        None
1744    };
1745
1746    // ── per-pane filter row (hidden until requested) ────────────────────────
1747    let filter_bar = gtk::Box::new(gtk::Orientation::Horizontal, 4);
1748    filter_bar.add_css_class("filter-bar");
1749    filter_bar.set_visible(false);
1750
1751    let filter_entry = gtk::SearchEntry::new();
1752    filter_entry.set_placeholder_text(Some("Filter..."));
1753    filter_entry.set_hexpand(true);
1754    filter_entry.set_valign(gtk::Align::Center);
1755    filter_entry.add_css_class("filter-entry");
1756    {
1757        let s = sender.clone();
1758        filter_entry.connect_search_changed(move |entry| {
1759            s.input(AppMsg::FilterChanged {
1760                id,
1761                filter: entry.text().to_string(),
1762            });
1763        });
1764    }
1765    filter_bar.append(&filter_entry);
1766
1767    let close_btn = gtk::Button::from_icon_name("window-close-symbolic");
1768    close_btn.set_tooltip_text(Some("Clear filter"));
1769    close_btn.set_valign(gtk::Align::Center);
1770    close_btn.add_css_class("flat");
1771    {
1772        let s = sender.clone();
1773        close_btn.connect_clicked(move |_| {
1774            s.input(AppMsg::FilterClosed { id });
1775        });
1776    }
1777    filter_bar.append(&close_btn);
1778
1779    widget.append(&filter_bar);
1780
1781    // ── pane body: tree or bookmarks, switched by `body` ────────────────────
1782    let tree_widget = tree.widget();
1783    tree_widget.set_vexpand(true);
1784
1785    let bookmarks_list = gtk::Box::new(gtk::Orientation::Vertical, 0);
1786    bookmarks_list.add_css_class("bookmarks-list");
1787    let bookmarks_scroll = gtk::ScrolledWindow::new();
1788    bookmarks_scroll.set_policy(gtk::PolicyType::Never, gtk::PolicyType::Automatic);
1789    bookmarks_scroll.set_vexpand(true);
1790    bookmarks_scroll.add_css_class("bookmarks-view");
1791    bookmarks_scroll.set_child(Some(&bookmarks_list));
1792    let bookmark_drag = bookmarks::BookmarkDrag::new();
1793    let bookmark_nav = bookmarks::BookmarkNav::new(&bookmarks_list);
1794    // The blank-area menu and the drag-and-drop wiring (reorder/move entries,
1795    // including in and out of folders).
1796    {
1797        let items = config.bookmarks.blank.clone();
1798        let sender = sender.clone();
1799        let on_event: Rc<dyn Fn(BookmarkEvent)> =
1800            Rc::new(move |event| sender.input(AppMsg::BookmarkEvent { id, event }));
1801        bookmarks::attach_bookmarks_scroller(&bookmarks_scroll, &items, side, &bookmark_drag, &bookmark_nav, on_event);
1802    }
1803
1804    let body = gtk::Stack::new();
1805    body.set_vexpand(true);
1806    body.add_named(tree_widget, Some("tree"));
1807    body.add_named(&bookmarks_scroll, Some("bookmarks"));
1808    body.set_visible_child_name("tree");
1809    widget.append(&body);
1810    widget.set_vexpand(true);
1811
1812    overlay.set_child(Some(&widget));
1813
1814    // Pane-menu shortcuts are resolved at the pane level (capture phase), so
1815    // they work whether focus is on the tree, the path entry, the filter bar, or
1816    // nothing at all. Row shortcuts stay on the tree.
1817    {
1818        // Both menus' shortcuts are live regardless of which body is showing,
1819        // so a binding in either works from the tree or the bookmarks view.
1820        let mut items = config.pane_menu.items.clone();
1821        items.extend(config.bookmarks.menu.iter().cloned());
1822        let shortcuts = Rc::new(PaneShortcuts::compile(&items));
1823        let s = sender.clone();
1824        let keys = gtk::EventControllerKey::new();
1825        keys.set_propagation_phase(gtk::PropagationPhase::Capture);
1826        keys.connect_key_pressed(move |_, key, _, state| {
1827            if let Some(action) = shortcuts.action_for(key, state) {
1828                s.input(AppMsg::PaneShortcut { id, action });
1829                glib::Propagation::Stop
1830            } else {
1831                glib::Propagation::Proceed
1832            }
1833        });
1834        widget.add_controller(keys);
1835    }
1836
1837    Pane {
1838        id,
1839        toolbar,
1840        tree,
1841        root: None,
1842        canonical_root: None,
1843        history: NavHistory::default(),
1844        filter_bar,
1845        filter_entry,
1846        body,
1847        bookmarks_list,
1848        bookmark_filter: String::new(),
1849        bookmark_drag,
1850        bookmark_nav,
1851        nav_buttons,
1852        widget: overlay,
1853    }
1854}
1855
1856/// Remove `w` from whichever parent it currently has, using the parent's own
1857/// removal API instead of a raw `unparent()`.
1858///
1859/// This matters because a `GtkPaned` keeps its start/end child slots alive
1860/// across a raw `unparent()`, and its *deferred* destroy (GTK can keep a
1861/// container alive beyond `container.remove`) then unparents the widget a
1862/// second time — kicking it out of whatever new parent we re-appended it to.
1863fn remove_from_parent(w: &gtk::Widget) {
1864    let Some(parent) = w.parent() else { return };
1865    if let Ok(paned) = parent.clone().downcast::<gtk::Paned>() {
1866        let none: Option<&gtk::Widget> = None;
1867        if paned.start_child().is_some_and(|c| c == *w) {
1868            paned.set_start_child(none);
1869        }
1870        let none: Option<&gtk::Widget> = None;
1871        if paned.end_child().is_some_and(|c| c == *w) {
1872            paned.set_end_child(none);
1873        }
1874    } else if let Ok(b) = parent.downcast::<gtk::Box>() {
1875        b.remove(w);
1876    } else {
1877        w.unparent();
1878    }
1879}
1880
1881fn pane_widget(pane: &Pane) -> gtk::Widget {
1882    pane.widget.clone().upcast()
1883}
1884
1885/// Rebuild a dock's container Box to show its panes in a nested Paned
1886/// structure.
1887///
1888/// Layout for N panes:
1889///   N=1 → container has the single pane widget.
1890///   N=2 → container has Paned { pane[0], pane[1] }
1891///   N=3 → container has Paned { pane[0], Paned { pane[1], pane[2] } }
1892///   etc.
1893///
1894/// Called on every split/close so widget references are always fresh.
1895fn fill_pane_container(container: &gtk::Box, panes: &[Pane]) {
1896    // Detach the pane boxes from their old parents *first*, using each
1897    // parent's removal API. A raw `unparent()` alone is not enough: a Paned
1898    // keeps stale child slots that re-unparent the widget when the old chain
1899    // is finally destroyed (possibly after we have re-appended it).
1900    for pane in panes {
1901        remove_from_parent(pane.widget.upcast_ref());
1902    }
1903    // Now drop the old structure wholesale.
1904    while let Some(child) = container.first_child() {
1905        container.remove(&child);
1906    }
1907
1908    match panes {
1909        [] => {}
1910        [single] => {
1911            let w = pane_widget(single);
1912            w.set_vexpand(true);
1913            container.append(&w);
1914        }
1915        panes => {
1916            // Build a right-nested Paned from the last two, then keep
1917            // wrapping from right to left.
1918            let n = panes.len();
1919            let last_w = pane_widget(&panes[n - 1]);
1920            last_w.set_vexpand(true);
1921            let mut right: gtk::Widget = last_w.clone().upcast();
1922            for pane in panes[..n - 1].iter().rev() {
1923                let left_w = pane_widget(pane);
1924                left_w.set_vexpand(true);
1925                let split = gtk::Paned::new(gtk::Orientation::Vertical);
1926                split.set_vexpand(true);
1927                split.set_wide_handle(true);
1928                // Allow both children to shrink and resize freely.
1929                split.set_shrink_start_child(true);
1930                split.set_shrink_end_child(true);
1931                split.set_resize_start_child(true);
1932                split.set_resize_end_child(true);
1933                split.set_start_child(Some(&left_w));
1934                split.set_end_child(Some(&right));
1935                // Default to 50/50 split: set position after a short delay once
1936                // GTK has allocated space and computed max_position.
1937                {
1938                    let split2 = split.clone();
1939                    relm4::gtk::glib::timeout_add_local_once(
1940                        std::time::Duration::from_millis(PANED_CENTER_DELAY_MS),
1941                        move || {
1942                            // max_position is INT_MAX until the widget is allocated;
1943                            // once it has a real allocation, use half the actual height.
1944                            let alloc = split2.height();
1945                            if alloc > 0 {
1946                                split2.set_position(alloc / 2);
1947                            }
1948                        },
1949                    );
1950                }
1951                right = split.upcast();
1952            }
1953            container.append(&right);
1954        }
1955    }
1956}
1957
1958/// Clear the media stream of every `GtkVideo` under `widget`, releasing its
1959/// GStreamer GL sink. Walking the widget tree (rather than the model) means a
1960/// video also stops when the pane that built it has already been removed from
1961/// `App::docks` — a closed last pane keeps its widgets parented until the
1962/// window itself is destroyed.
1963fn stop_video_widgets(widget: &gtk::Widget) {
1964    if let Some(video) = widget.downcast_ref::<gtk::Video>() {
1965        video.set_media_stream(None::<&gtk::MediaStream>);
1966    }
1967    let mut child = widget.first_child();
1968    while let Some(current) = child {
1969        stop_video_widgets(&current);
1970        child = current.next_sibling();
1971    }
1972}
1973
1974/// Pixels added/removed per keyboard or CLI width increment.
1975const WIDTH_STEP: i32 = 24;
1976
1977/// Quiet period before a debounced session save is written after a resize.
1978const SAVE_DEBOUNCE_MS: u64 = 300;
1979
1980/// Delay before a freshly split `GtkPaned` is centred at 50/50, once GTK has
1981/// allocated it (before allocation `max_position` is `INT_MAX`).
1982const PANED_CENTER_DELAY_MS: u64 = 100;
1983
1984/// Grace period between tearing media down and closing the window on quit,
1985/// letting GStreamer release its GL context before the process exits.
1986const SHUTDOWN_GRACE_MS: u64 = 150;
1987
1988/// Resize a dock window's layer surface to `width`.
1989fn apply_window_width(window: &gtk::Window, width: u32) {
1990    let (_, height) = window.default_size();
1991    // `set_exclusive_zone` only means anything for a layer surface; on the
1992    // plain-window fallback it would warn, so resize the window directly.
1993    if window.is_layer_window() {
1994        window.set_exclusive_zone(width as i32);
1995    }
1996    window.set_size_request(width as i32, -1);
1997    window.set_default_size(width as i32, if height > 0 { height } else { -1 });
1998}
1999
2000/// Wire the interactive width controls into a dock window: Super+right-drag to
2001/// resize and Super+plus/minus to step the width.
2002fn attach_resize_controls(window: &gtk::Window, side: PanelSide, sender: &ComponentSender<App>) {
2003    attach_resize_drag(window, side, sender.input_sender().clone());
2004    attach_resize_keys(window, side, sender.input_sender().clone());
2005}
2006
2007fn attach_resize_drag(window: &gtk::Window, side: PanelSide, sender: relm4::Sender<AppMsg>) {
2008    let drag = gtk::GestureDrag::new();
2009    drag.set_button(gdk::BUTTON_SECONDARY);
2010    drag.set_propagation_phase(gtk::PropagationPhase::Capture);
2011    // (was Super held at drag start, cumulative offset of the last handled update)
2012    let state = Rc::new(RefCell::new((false, 0.0f64)));
2013
2014    let begin_state = state.clone();
2015    drag.connect_drag_begin(move |gesture, _, _| {
2016        let super_held = gesture
2017            .current_event_state()
2018            .contains(gdk::ModifierType::SUPER_MASK);
2019        *begin_state.borrow_mut() = (super_held, 0.0);
2020    });
2021
2022    let update_sender = sender.clone();
2023    let update_state = state.clone();
2024    drag.connect_drag_update(move |_, offset_x, _| {
2025        let mut state = update_state.borrow_mut();
2026        if !state.0 {
2027            return;
2028        }
2029        let step = offset_x - state.1;
2030        state.1 = offset_x;
2031        // A left dock grows as the pointer moves right; a right dock mirrors it.
2032        let widen = match side {
2033            PanelSide::Left => step,
2034            PanelSide::Right => -step,
2035        };
2036        let delta = widen.round() as i32;
2037        if delta != 0 {
2038            let _ = update_sender.send(AppMsg::ResizeBy { side, delta });
2039        }
2040    });
2041
2042    let end_sender = sender;
2043    let end_state = state.clone();
2044    drag.connect_drag_end(move |_, _, _| {
2045        if end_state.borrow().0 {
2046            let _ = end_sender.send(AppMsg::ResizeCommit);
2047        }
2048    });
2049
2050    window.add_controller(drag);
2051}
2052
2053fn attach_resize_keys(window: &gtk::Window, side: PanelSide, sender: relm4::Sender<AppMsg>) {
2054    let keys = gtk::EventControllerKey::new();
2055    keys.set_propagation_phase(gtk::PropagationPhase::Capture);
2056    keys.connect_key_pressed(move |_, key, _, state| {
2057        if !state.contains(gdk::ModifierType::SUPER_MASK) {
2058            return glib::Propagation::Proceed;
2059        }
2060        match key {
2061            gdk::Key::minus | gdk::Key::underscore | gdk::Key::KP_Subtract => {
2062                let _ = sender.send(AppMsg::ResizeBy { side, delta: -WIDTH_STEP });
2063                glib::Propagation::Stop
2064            }
2065            gdk::Key::equal | gdk::Key::plus | gdk::Key::KP_Add => {
2066                let _ = sender.send(AppMsg::ResizeBy { side, delta: WIDTH_STEP });
2067                glib::Propagation::Stop
2068            }
2069            _ => glib::Propagation::Proceed,
2070        }
2071    });
2072    window.add_controller(keys);
2073}
2074
2075/// Whether the running compositor implements the `wlr-layer-shell` protocol.
2076///
2077/// Hyprland, sway, river, niri and other wlroots-based compositors do; GNOME's
2078/// Mutter and KDE's KWin do not. Without it the panel cannot dock, reserve an
2079/// exclusive zone or use layer stacking, so it degrades to an ordinary window
2080/// (see [`init_layer_window`]). Cached after the first call, since it cannot
2081/// change while the app runs.
2082fn layer_shell_available() -> bool {
2083    static SUPPORTED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
2084    *SUPPORTED.get_or_init(gtk4_layer_shell::is_supported)
2085}
2086
2087/// Configure `window` as a layer-shell surface docked to `side`.
2088///
2089/// When the compositor has no `wlr-layer-shell` support the window is left as a
2090/// plain, decorated, freely-floating toplevel (a usable, undocked fallback
2091/// rather than a silently broken layer); check [`layer_shell_available`] when
2092/// docked-only behavior (like interactive resize) must be skipped.
2093fn init_layer_window(window: &gtk::Window, config: &Config, side: PanelSide, width: u32) {
2094    if !layer_shell_available() {
2095        configure_plain_window(window, width);
2096        return;
2097    }
2098    if window.is_layer_window() {
2099        return;
2100    }
2101    window.init_layer_shell();
2102    window.set_layer(layer_of(config.panel.layer));
2103    let edge = match side {
2104        PanelSide::Left => Edge::Left,
2105        PanelSide::Right => Edge::Right,
2106    };
2107    window.set_anchor(Edge::Top, true);
2108    window.set_anchor(Edge::Bottom, true);
2109    window.set_anchor(edge, true);
2110    window.set_exclusive_zone(width as i32);
2111    window.set_size_request(width as i32, -1);
2112    let margin = config.panel.margin as i32;
2113    window.set_margin(Edge::Top, margin);
2114    window.set_margin(Edge::Bottom, margin);
2115    window.set_margin(Edge::Left, margin);
2116    window.set_margin(Edge::Right, margin);
2117
2118    // Keyboard focus is `OnDemand`: the compositor grants it when the layer is
2119    // mapped (so launching or showing the panel focuses it) and while the
2120    // pointer moves over it, and the claim is released the moment the pointer
2121    // leaves.
2122    //
2123    // Releasing on pointer-leave matters because a Hyprland layer surface that
2124    // *keeps* keyboard focus does not update the compositor's notion of the
2125    // focused window (`hyprctl activewindow` still reports the previous one).
2126    // A click only triggers a refocus when the clicked window differs from that
2127    // focused window (`CInputManager::processMouseDownNormal`:
2128    // `focusState()->window() != w`), so a panel that holds the claim makes
2129    // clicking the previously-focused window a no-op — keyboard focus never
2130    // returns to it. Dropping the claim kicks the layer from the seat, so the
2131    // next click refocuses the window normally.
2132    window.set_keyboard_mode(KeyboardMode::OnDemand);
2133    let motion = gtk::EventControllerMotion::new();
2134    {
2135        let kb_window = window.clone();
2136        motion.connect_enter(move |_, _, _| kb_window.set_keyboard_mode(KeyboardMode::OnDemand));
2137    }
2138    {
2139        let kb_window = window.clone();
2140        motion.connect_leave(move |_| {
2141            // A row/menu popover is a child surface, so opening it fires a
2142            // pointer-leave for the panel even though the user has not left it.
2143            // Keep the keyboard claim in that case; the compositor returns
2144            // focus to the panel when the popover closes.
2145            if has_visible_popover(kb_window.upcast_ref()) {
2146                return;
2147            }
2148            kb_window.set_keyboard_mode(KeyboardMode::None);
2149        });
2150    }
2151    window.add_controller(motion);
2152}
2153
2154/// Fall back to a plain, decorated, freely-floating window when the compositor
2155/// has no layer-shell support. The panel cannot dock, so it opens at its
2156/// configured width with a normal title bar; the user places it like any other
2157/// window. Announced once so the reason for the un-docked panel is not a
2158/// mystery.
2159fn configure_plain_window(window: &gtk::Window, width: u32) {
2160    static ANNOUNCED: std::sync::OnceLock<()> = std::sync::OnceLock::new();
2161    ANNOUNCED.get_or_init(|| {
2162        eprintln!(
2163            "tree-space: this compositor does not support wlr-layer-shell, so the panel \
2164             cannot dock; opening it as a normal window instead."
2165        );
2166    });
2167    window.set_title(Some("tree-space"));
2168    window.set_default_size(width as i32, 720);
2169    window.set_size_request(width as i32, -1);
2170}
2171
2172/// Whether any visible [`gtk::Popover`] is open under `widget` (walking the
2173/// widget tree). Used to keep keyboard focus on the panel while its menu is up.
2174fn has_visible_popover(widget: &gtk::Widget) -> bool {
2175    if widget.is::<gtk::Popover>() && widget.is_visible() {
2176        return true;
2177    }
2178    let mut child = widget.first_child();
2179    while let Some(current) = child {
2180        if has_visible_popover(&current) {
2181            return true;
2182        }
2183        child = current.next_sibling();
2184    }
2185    false
2186}
2187
2188fn layer_of(layer: PanelLayer) -> Layer {
2189    use PanelLayer::*;
2190    match layer {
2191        Background => Layer::Background,
2192        Bottom => Layer::Bottom,
2193        Top => Layer::Top,
2194        Overlay => Layer::Overlay,
2195    }
2196}
2197
2198/// Load the user stylesheet (falling back to the shipped default) plus the
2199/// dynamically-sized font rule, which is appended last so `tree.font_size`
2200/// still wins over anything the stylesheet sets.
2201fn install_css(config: &Config) {
2202    let stylesheet = load_stylesheet();
2203    if let Some(problem) = &stylesheet.problem {
2204        eprintln!("tree-space: could not read stylesheet: {problem:?}");
2205    }
2206    let css = format!(
2207        "{}\n.tree-row label, .tree-rename-entry, .tree-menu, .hamburger-menu {{ font-size: {}px; }}",
2208        stylesheet.css, config.tree.font_size
2209    );
2210    let provider = gtk::CssProvider::new();
2211    provider.load_from_string(&css);
2212    if let Some(display) = gdk::Display::default() {
2213        gtk::style_context_add_provider_for_display(
2214            &display,
2215            &provider,
2216            gtk::STYLE_PROVIDER_PRIORITY_APPLICATION,
2217        );
2218    }
2219}
2220
2221fn home_dir() -> Option<PathBuf> {
2222    std::env::var_os("HOME").map(PathBuf::from)
2223}
2224
2225/// The root a freshly-seeded dock should show, resolved from the `[startup]`
2226/// config (last-used directory, home, or a fixed path), falling back to home.
2227fn default_root(startup: &StartupRoot) -> Option<PathBuf> {
2228    // A bookmarks launch opens no directory pane; seeding one would defeat it.
2229    if startup.is_bookmarks() {
2230        return None;
2231    }
2232    let last = SessionState::load().last_root.filter(|p| p.is_dir());
2233    startup.resolve(last).filter(|p| p.is_dir()).or_else(home_dir)
2234}
2235
2236/// Translate an index `path` into the tree that remains after the entry at
2237/// `from` is removed. Index paths that diverge from `from` deeper in a different
2238/// subtree are unaffected; a sibling before the removed entry shifts down one.
2239fn adjust_path_after_removal(from: &[usize], path: &[usize]) -> Vec<usize> {
2240    let mut out = path.to_vec();
2241    for level in 0..path.len().min(from.len()) {
2242        if from[level] < path[level] {
2243            out[level] -= 1;
2244            return out;
2245        } else if from[level] > path[level] {
2246            return out;
2247        }
2248    }
2249    out
2250}
2251
2252#[cfg(test)]
2253mod adjust_path_tests {
2254    use super::adjust_path_after_removal;
2255
2256    #[test]
2257    fn adjusts_sibling_indices_after_a_removal() {
2258        // Removing index 0: later siblings shift down.
2259        assert_eq!(adjust_path_after_removal(&[0], &[2]), vec![1]);
2260        // Removing index 2: earlier siblings are unaffected.
2261        assert_eq!(adjust_path_after_removal(&[2], &[0]), vec![0]);
2262        // A deeper path in a sibling subtree shifts at the first level only.
2263        assert_eq!(adjust_path_after_removal(&[0], &[1, 3]), vec![0, 3]);
2264        // An ancestor of the removed node is unaffected.
2265        assert_eq!(adjust_path_after_removal(&[1, 2], &[1]), vec![1]);
2266        // Diverge at the second level: only that index shifts.
2267        assert_eq!(adjust_path_after_removal(&[1, 0], &[1, 2]), vec![1, 1]);
2268    }
2269}
2270
2271#[cfg(test)]
2272mod visibility_tests {
2273    use super::*;
2274
2275    const L: PanelSide = PanelSide::Left;
2276    const R: PanelSide = PanelSide::Right;
2277
2278    fn plan(intent: VisibilityIntent, existing: &[PanelSide], shown: &[PanelSide]) -> VisibilityPlan {
2279        resolve_visibility(intent, existing, shown)
2280    }
2281
2282    #[test]
2283    fn show_all_shows_both() {
2284        let p = plan(VisibilityIntent::ShowAll, &[L], &[]);
2285        // Only docks that already exist are shown; a second one is not created.
2286        assert_eq!(p.show, vec![L]);
2287        assert!(p.seed);
2288        let both = plan(VisibilityIntent::ShowAll, &[L, R], &[]);
2289        assert_eq!(both.show, vec![L, R]);
2290    }
2291
2292    #[test]
2293    fn hide_all_hides_both() {
2294        let p = plan(VisibilityIntent::HideAll, &[L, R], &[L, R]);
2295        assert_eq!(p.hide, vec![L, R]);
2296        assert!(p.show.is_empty());
2297    }
2298
2299    #[test]
2300    fn toggle_all_shows_when_nothing_visible_and_hides_otherwise() {
2301        let shown = plan(VisibilityIntent::ToggleAll, &[L], &[]);
2302        // Toggling all shows the docks that exist (not a newly created side).
2303        assert_eq!(shown.show, vec![L]);
2304        let hidden = plan(VisibilityIntent::ToggleAll, &[L, R], &[L]);
2305        assert_eq!(hidden.hide, vec![L, R]);
2306    }
2307
2308    #[test]
2309    fn show_side_creates_a_missing_dock() {
2310        let p = plan(VisibilityIntent::ShowSide(R), &[L], &[L]);
2311        assert_eq!(p.create, Some(R));
2312        assert_eq!(p.show, vec![R]);
2313    }
2314
2315    #[test]
2316    fn hide_side_never_creates() {
2317        let p = plan(VisibilityIntent::HideSide(R), &[L], &[L]);
2318        assert_eq!(p.create, None);
2319        assert_eq!(p.hide, vec![R]);
2320    }
2321
2322    #[test]
2323    fn toggle_side_is_granular() {
2324        // Missing -> create and show.
2325        let create = plan(VisibilityIntent::ToggleSide(R), &[L], &[L]);
2326        assert_eq!(create.create, Some(R));
2327        assert_eq!(create.show, vec![R]);
2328        // Shown -> hide.
2329        let hide = plan(VisibilityIntent::ToggleSide(L), &[L, R], &[L, R]);
2330        assert_eq!(hide.hide, vec![L]);
2331        // Hidden -> show.
2332        let show = plan(VisibilityIntent::ToggleSide(L), &[L, R], &[R]);
2333        assert_eq!(show.show, vec![L]);
2334    }
2335
2336    #[test]
2337    fn toggling_one_side_leaves_the_other_alone() {
2338        // Left visible, right hidden: toggling right must not name left at all.
2339        let p = plan(VisibilityIntent::ToggleSide(R), &[L, R], &[L]);
2340        assert_eq!(p.show, vec![R]);
2341        assert!(!p.hide.contains(&L));
2342    }
2343}
2344
2345#[cfg(test)]
2346mod nav_history_tests {
2347    use super::{NavHistory, ViewEntry};
2348    use std::path::PathBuf;
2349
2350    fn dir(s: &str) -> ViewEntry {
2351        ViewEntry::Dir(PathBuf::from(s))
2352    }
2353
2354    #[test]
2355    fn records_visits_in_order_and_navigates_both_ways() {
2356        let mut h = NavHistory::default();
2357        h.record(dir("/a"));
2358        h.record(dir("/b"));
2359        h.record(dir("/c"));
2360        assert!(h.can_back());
2361        assert!(!h.can_forward());
2362
2363        assert_eq!(h.back(), Some(dir("/b")));
2364        h.finish_navigation();
2365        assert_eq!(h.back(), Some(dir("/a")));
2366        h.finish_navigation();
2367        assert!(!h.can_back());
2368        assert!(h.can_forward());
2369
2370        assert_eq!(h.forward(), Some(dir("/b")));
2371        h.finish_navigation();
2372        assert_eq!(h.forward(), Some(dir("/c")));
2373        h.finish_navigation();
2374        assert!(h.can_back());
2375        assert!(!h.can_forward());
2376    }
2377
2378    #[test]
2379    fn a_new_visit_after_going_back_truncates_the_forward_tail() {
2380        let mut h = NavHistory::default();
2381        h.record(dir("/a"));
2382        h.record(dir("/b"));
2383        h.record(dir("/c"));
2384        assert_eq!(h.back(), Some(dir("/b")));
2385        h.finish_navigation();
2386        // Visiting /d from /b drops /c from the forward history.
2387        h.record(dir("/d"));
2388        assert!(!h.can_forward());
2389        assert_eq!(h.back(), Some(dir("/b")));
2390    }
2391
2392    #[test]
2393    fn navigating_does_not_record_and_repeats_are_ignored() {
2394        let mut h = NavHistory::default();
2395        h.record(dir("/a"));
2396        h.record(dir("/b"));
2397        // Back, then the resulting RootChanged; recording must be suppressed.
2398        let target = h.back().unwrap();
2399        assert_eq!(target, dir("/a"));
2400        h.record(target.clone());
2401        h.finish_navigation();
2402        // Still at /a with /b ahead, and no duplicate /a entry was appended.
2403        assert!(h.can_forward());
2404        assert_eq!(h.forward(), Some(dir("/b")));
2405        h.finish_navigation();
2406
2407        // Re-recording the current entry is a no-op.
2408        h.record(dir("/b"));
2409        assert_eq!(h.cursor, 1);
2410        assert_eq!(h.entries.len(), 2);
2411    }
2412
2413    #[test]
2414    fn bookmarks_view_is_a_history_entry() {
2415        let mut h = NavHistory::default();
2416        h.record(ViewEntry::Bookmarks);
2417        h.record(dir("/a"));
2418        // From /a, back goes to the bookmarks view.
2419        assert_eq!(h.back(), Some(ViewEntry::Bookmarks));
2420        h.finish_navigation();
2421        // Re-recording the view we are already on is a no-op.
2422        h.record(ViewEntry::Bookmarks);
2423        assert_eq!(h.entries.len(), 2);
2424        // And forward returns to the directory.
2425        assert_eq!(h.forward(), Some(dir("/a")));
2426        h.finish_navigation();
2427    }
2428
2429    #[test]
2430    fn cannot_navigate_an_empty_history() {
2431        let mut h = NavHistory::default();
2432        assert!(!h.can_back());
2433        assert!(!h.can_forward());
2434        assert_eq!(h.back(), None);
2435        assert_eq!(h.forward(), None);
2436    }
2437}