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::{
41    cell::{Cell, RefCell},
42    collections::{HashMap, HashSet},
43    path::{Path, PathBuf},
44    rc::Rc,
45};
46
47use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell};
48use relm4::gtk::{gdk, gio, glib, prelude::*};
49use relm4::prelude::*;
50
51use crate::cmd::{Command, WidthArg};
52use crate::config::{
53    Bookmark, BuiltinAction, Config, ContextAction, PANEL_MAX_WIDTH, PANEL_MIN_WIDTH, PanelConfig,
54    PanelLayer, PanelSide, SessionState, ShortcutTarget, StartupRoot, WorkspaceMove,
55    bookmark_file_path, load_stylesheet, save_bookmarks_to_path,
56};
57use crate::workspace::Hyprland;
58use crate::fs::SortKey;
59use crate::ipc;
60use crate::ui::bookmarks::{self, BookmarkEvent, MoveTarget};
61use crate::ui::toolbar::{
62    PaneShortcuts, Toolbar, ToolbarInit, ToolbarMsg, ToolbarOutput, parse_accelerator,
63};
64use crate::ui::tree::{Tree, TreeInit, TreeOutput, TreeMsg};
65
66/// One entry in a pane's history: a directory it showed, or the bookmarks view.
67#[derive(Debug, Clone, PartialEq, Eq)]
68enum ViewEntry {
69    Dir(PathBuf),
70    Bookmarks,
71}
72
73/// A pane's back/forward navigation history.
74///
75/// Views are recorded in visit order with a cursor into the list. Going back
76/// moves the cursor left, forward moves it right; visiting a *new* view (not via
77/// back/forward) truncates the forward tail and appends. This is a pure data
78/// structure so the rules can be unit-tested without a display.
79#[derive(Debug, Default, Clone, PartialEq, Eq)]
80struct NavHistory {
81    entries: Vec<ViewEntry>,
82    /// Index of the current entry. Meaningless while `entries` is empty.
83    cursor: usize,
84    /// Set while a back/forward navigation is in flight, so the `RootChanged`
85    /// (or view switch) it produces is not itself recorded as a new visit.
86    navigating: bool,
87}
88
89impl NavHistory {
90    /// Record a newly shown view. A repeat of the current entry is ignored; any
91    /// forward history is dropped. While a back/forward is navigating this is a
92    /// no-op (the target is already in the list).
93    fn record(&mut self, entry: ViewEntry) {
94        if self.navigating {
95            return;
96        }
97        if self.entries.get(self.cursor).is_some_and(|cur| cur == &entry) {
98            return;
99        }
100        if self.entries.is_empty() {
101            self.entries.push(entry);
102            self.cursor = 0;
103            return;
104        }
105        self.entries.truncate(self.cursor + 1);
106        self.entries.push(entry);
107        self.cursor = self.entries.len() - 1;
108    }
109
110    fn can_back(&self) -> bool {
111        self.cursor > 0 && self.cursor < self.entries.len()
112    }
113
114    fn can_forward(&self) -> bool {
115        !self.entries.is_empty() && self.cursor + 1 < self.entries.len()
116    }
117
118    /// Step back one entry and return it, arming `navigating`.
119    fn back(&mut self) -> Option<ViewEntry> {
120        if !self.can_back() {
121            return None;
122        }
123        self.cursor -= 1;
124        self.navigating = true;
125        Some(self.entries[self.cursor].clone())
126    }
127
128    /// Step forward one entry and return it, arming `navigating`.
129    fn forward(&mut self) -> Option<ViewEntry> {
130        if !self.can_forward() {
131            return None;
132        }
133        self.cursor += 1;
134        self.navigating = true;
135        Some(self.entries[self.cursor].clone())
136    }
137
138    /// Clear the in-flight flag once the resulting view has been shown.
139    fn finish_navigation(&mut self) {
140        self.navigating = false;
141    }
142}
143
144/// One split view inside a dock: its own top bar above its own body.
145pub struct Pane {
146    id: u64,
147    toolbar: Controller<Toolbar>,
148    tree: Controller<Tree>,
149    /// The workspace this pane belongs to (a Hyprland workspace name). A pane
150    /// is rendered only while its workspace is the active one.
151    workspace: String,
152    /// The directory this pane currently shows, if any. Owned here (rather than
153    /// in a parallel `Vec` on the dock) so a pane and its root can never drift.
154    root: Option<PathBuf>,
155    /// The canonical form of `root`, cached so launch deduplication does not
156    /// `canonicalize` every pane on every request. Refreshed on `RootChanged`.
157    canonical_root: Option<PathBuf>,
158    /// Back/forward history for this pane's root changes.
159    history: NavHistory,
160    /// This pane's own filter row (hidden until requested from its top bar).
161    /// Only this pane's tree receives the filter.
162    filter_bar: gtk::Box,
163    filter_entry: gtk::SearchEntry,
164    /// Switches the pane body between the tree and the bookmarks view. A pane
165    /// created without a directory starts on the bookmarks page (a "new panel"
166    /// suggesting places to jump to).
167    body: gtk::Stack,
168    /// The bookmarks list container (the bookmarks page of `body`), refilled
169    /// whenever the list changes.
170    bookmarks_list: gtk::Box,
171    /// Case-insensitive filter applied to the bookmarks list. Kept per pane so
172    /// the filter bar works on the bookmarks view too.
173    bookmark_filter: String,
174    /// Shared drag state for the bookmarks list (survives row rebuilds).
175    bookmark_drag: Rc<bookmarks::BookmarkDrag>,
176    /// Keyboard cursor and selection for the bookmarks list.
177    bookmark_nav: Rc<bookmarks::BookmarkNav>,
178    /// The pane overlay: its main child is the `{ toolbar, filter_bar?, body }`
179    /// vertical box, and its overlay children hold panes' popovers (e.g. the
180    /// path-entry completion dropdown). Built once and reused across split/close
181    /// rebuilds, so the tree and toolbar widgets never need to be reparented
182    /// (which would trip `gtk_box_append: child has a parent`).
183    widget: gtk::Overlay,
184    /// The toolbar's drag grip. Kept so a pane-move drag can compute the grip's
185    /// position within whatever window the pane currently lives in (which
186    /// changes when the pane is moved to another dock).
187    grip: gtk::Image,
188    /// The navigation-toolbar buttons when `[panel] nav_toolbar` is enabled;
189    /// their enabled state tracks the pane's root and history.
190    nav_buttons: Option<NavButtons>,
191    /// The pane-menu accelerators, kept so the IPC `--key` command can resolve
192    /// a shortcut against this pane without a real key event.
193    shortcuts: Rc<PaneShortcuts>,
194}
195
196/// The optional navigation toolbar's buttons.
197struct NavButtons {
198    up: gtk::Button,
199    back: gtk::Button,
200    forward: gtk::Button,
201}
202
203impl Pane {
204    /// Record `root` as this pane's directory and refresh its cached canonical
205    /// form.
206    fn set_root(&mut self, root: PathBuf) {
207        self.canonical_root = std::fs::canonicalize(&root).ok();
208        self.root = Some(root);
209        self.refresh_nav();
210    }
211
212    /// Show the bookmarks view in this pane's body (and switch its hamburger).
213    fn show_bookmarks(&self) {
214        // The tree stays alive but hidden; stop its previews so a playing
215        // video or audio file doesn't keep making noise behind the bookmarks.
216        self.tree.emit(TreeMsg::Suspend);
217        self.body.set_visible_child_name("bookmarks");
218        self.toolbar.emit(ToolbarMsg::SetBookmarks(true));
219        self.refresh_nav();
220    }
221
222    /// Show the tree in this pane's body (and switch its hamburger).
223    fn show_tree(&self) {
224        self.body.set_visible_child_name("tree");
225        self.toolbar.emit(ToolbarMsg::SetBookmarks(false));
226        self.refresh_nav();
227    }
228
229    /// Whether this pane is currently on the bookmarks view.
230    fn on_bookmarks(&self) -> bool {
231        self.body.visible_child_name().as_deref() == Some("bookmarks")
232    }
233
234    /// Enable the navigation toolbar's buttons to match what the pane can
235    /// actually do: Up needs a parent directory and a tree (not the bookmarks
236    /// view); Back/Forward need history in that direction. A no-op when the
237    /// toolbar is disabled.
238    fn refresh_nav(&self) {
239        let Some(nav) = &self.nav_buttons else {
240            return;
241        };
242        let has_parent = !self.on_bookmarks()
243            && self.root.as_deref().and_then(Path::parent).is_some();
244        nav.up.set_sensitive(has_parent);
245        nav.back.set_sensitive(self.history.can_back());
246        nav.forward.set_sensitive(self.history.can_forward());
247    }
248}
249
250/// One layer-shell dock window anchored to a screen edge, with its panes.
251///
252/// A dock is identified by its `(side, monitor)` pair, so a second dock can
253/// share a side on another monitor when a pane is dragged there.
254struct Dock {
255    side: PanelSide,
256    /// The monitor the surface landed on, resolved once it is mapped. `None`
257    /// until then (and when the compositor exposes no monitor information).
258    monitor: Option<gdk::Monitor>,
259    /// The dock's window. For the primary dock this is the relm4 root window;
260    /// for additional docks an imperatively-built `gtk::Window`.
261    window: gtk::Window,
262    /// The vertical box holding the pane stack (for the primary dock this is
263    /// `App::pane_container`, referenced by `view!`).
264    container: gtk::Box,
265    panes: Vec<Pane>,
266    /// Id of the pane most recently interacted with in this dock.
267    active_pane: Option<u64>,
268}
269
270/// Live state of a pane-move drag started from a toolbar grip.
271struct PaneDrag {
272    /// The pane being dragged.
273    id: u64,
274    /// The pointer's global position when the drag began; every update is this
275    /// plus the gesture's offset.
276    base: (f64, f64),
277    /// Index of the dock currently highlighted as the drop target, if any.
278    highlighted: Option<usize>,
279}
280
281/// What an invocation (or the hamburger "Collapse") wants to do to dock
282/// visibility. Pure data, so the show/hide rules can be unit-tested without a
283/// display (see the `visibility` tests below).
284#[derive(Debug, Clone, Copy, PartialEq, Eq)]
285pub enum VisibilityIntent {
286    /// Show every dock.
287    ShowAll,
288    /// Hide every dock.
289    HideAll,
290    /// Toggle every dock: hide them all when any is visible, else show all.
291    ToggleAll,
292    /// Show the named side, creating its dock if needed.
293    ShowSide(PanelSide),
294    /// Hide the named side (a no-op when that dock does not exist).
295    HideSide(PanelSide),
296    /// Toggle the named side, creating and showing it if it does not exist.
297    ToggleSide(PanelSide),
298}
299
300/// The resolved effect of a [`VisibilityIntent`]: which sides should be shown
301/// afterwards, and whether a missing dock should be created to satisfy it.
302///
303/// This is the entire visibility state machine as a pure function; the app
304/// method [`App::apply_visibility`] only carries out the plan.
305#[derive(Debug, Clone, PartialEq, Eq)]
306pub struct VisibilityPlan {
307    /// Sides that should be visible afterwards.
308    pub show: Vec<PanelSide>,
309    /// Sides that should be hidden afterwards.
310    pub hide: Vec<PanelSide>,
311    /// Create and seed a dock on this side when it does not already exist.
312    pub create: Option<PanelSide>,
313    /// Seed an empty dock with a default pane (so showing it is not an empty
314    /// shell). Only used with `create`/`show`.
315    pub seed: bool,
316}
317
318/// Resolve `intent` against the dock sides that currently exist and the sides
319/// currently shown. `show`/`hide` name the sides to act on; `create` names a
320/// side whose dock must be created first.
321pub fn resolve_visibility(
322    intent: VisibilityIntent,
323    existing: &[PanelSide],
324    shown: &[PanelSide],
325) -> VisibilityPlan {
326    match intent {
327        VisibilityIntent::ShowAll => VisibilityPlan {
328            show: existing.to_vec(),
329            hide: Vec::new(),
330            create: None,
331            seed: true,
332        },
333        VisibilityIntent::HideAll => VisibilityPlan {
334            show: Vec::new(),
335            hide: PanelSide::ALL.to_vec(),
336            create: None,
337            seed: false,
338        },
339        VisibilityIntent::ToggleAll => {
340            if shown.is_empty() {
341                VisibilityPlan {
342                    show: existing.to_vec(),
343                    hide: Vec::new(),
344                    create: None,
345                    seed: true,
346                }
347            } else {
348                VisibilityPlan {
349                    show: Vec::new(),
350                    hide: PanelSide::ALL.to_vec(),
351                    create: None,
352                    seed: false,
353                }
354            }
355        }
356        VisibilityIntent::ShowSide(side) => VisibilityPlan {
357            show: vec![side],
358            hide: Vec::new(),
359            create: (!existing.contains(&side)).then_some(side),
360            seed: true,
361        },
362        VisibilityIntent::HideSide(side) => VisibilityPlan {
363            show: Vec::new(),
364            hide: vec![side],
365            create: None,
366            seed: false,
367        },
368        VisibilityIntent::ToggleSide(side) => {
369            if !existing.contains(&side) {
370                VisibilityPlan {
371                    show: vec![side],
372                    hide: Vec::new(),
373                    create: Some(side),
374                    seed: true,
375                }
376            } else if shown.contains(&side) {
377                VisibilityPlan {
378                    show: Vec::new(),
379                    hide: vec![side],
380                    create: None,
381                    seed: false,
382                }
383            } else {
384                VisibilityPlan {
385                    show: vec![side],
386                    hide: Vec::new(),
387                    create: None,
388                    seed: true,
389                }
390            }
391        }
392    }
393}
394
395/// Messages handled by the app itself.
396#[derive(Debug)]
397pub enum AppMsg {
398    /// Output from the top bar of pane `id`.
399    PaneToolbar { id: u64, out: ToolbarOutput },
400    /// Output from the tree of pane `id`.
401    PaneTree { id: u64, out: TreeOutput },
402    /// A pane-menu shortcut fired anywhere in pane `id` (the tree, the path
403    /// entry, or the filter bar), resolved from the `[pane_menu]` accelerators.
404    PaneShortcut { id: u64, action: ContextAction },
405    /// The folder-picker launched for pane `id` returned.
406    OpenFolderPicked { id: u64, path: Option<PathBuf> },
407    /// A new `tree-space` invocation was forwarded by the instance socket.
408    LaunchRequest { command: Command },
409    /// Hyprland reported a new active workspace (from the IPC event stream).
410    ActiveWorkspace(String),
411    /// Split pane `id`'s dock, seeding the new pane from `id`'s root.
412    SplitFromPane { id: u64 },
413    /// Split pane `id`'s dock with a specific root (context-menu "Open in Split
414    /// View").
415    OpenSplitFrom { id: u64, root: PathBuf },
416    /// Open `root` in a pane on the dock opposite pane `id`'s (context-menu
417    /// "In {other} panel").
418    OpenOppositeFrom { id: u64, root: PathBuf },
419    /// Remove pane `id` (closes the dock, or the whole app, when it is the
420    /// last pane).
421    ClosePane { id: u64 },
422    /// Pane `id`'s filter text changed (empty string clears the filter).
423    FilterChanged { id: u64, filter: String },
424    /// Close pane `id`'s filter bar and cancel its filter.
425    FilterClosed { id: u64 },
426    /// Quit requested (window close). Tears media down before the window closes.
427    Shutdown,
428    /// Change the width of `side`'s dock by `delta` px (drag / keyboard).
429    ResizeBy { side: PanelSide, delta: i32 },
430    /// Persist the width after an interactive resize finishes.
431    ResizeCommit,
432    /// A pane-move grip drag began: `start` is the press point relative to the
433    /// grip.
434    PaneDragBegin { id: u64, start: (f64, f64) },
435    /// The pointer moved `offset` (relative to the drag start) during a drag.
436    PaneDragUpdate { id: u64, offset: (f64, f64) },
437    /// A pane drag ended at `offset`: move the pane to the target under the
438    /// pointer.
439    PaneDragEnd { id: u64, offset: (f64, f64) },
440    /// Resolve a dock's monitor once its layer surface is mapped.
441    ResolveDockMonitor { di: usize },
442    /// Move keyboard focus into pane `id`'s tree (after it is allocated).
443    FocusPane { id: u64 },
444    /// Focus the active pane of every visible dock (a window was just mapped,
445    /// e.g. the launch surface), so the keyboard works without a click.
446    FocusVisible,
447    /// The bookmarks view in pane `id` reported a user action.
448    BookmarkEvent { id: u64, event: BookmarkEvent },
449    /// The bookmark editor for the entry at `index_path` was saved.
450    BookmarkEditSaved { index_path: Vec<usize>, name: String, path: Option<PathBuf> },
451    /// A new leaf bookmark was created from the bookmarks view.
452    BookmarkAdded { name: String, path: PathBuf },
453    /// A new (empty) bookmark folder was created from the bookmarks view.
454    BookmarkFolderAdded { name: String },
455}
456
457/// The init payload: the parsed invocation plus the instance socket, if this
458/// process became the single server.
459pub struct AppInit {
460    pub command: Command,
461    pub listener: Option<std::os::unix::net::UnixListener>,
462}
463
464/// Root state.
465pub struct App {
466    config: Config,
467    status: String,
468    window: gtk::Window,
469    file_dialog: gtk::FileDialog,
470    /// All dock windows. Index 0 is the primary dock (the root window).
471    docks: Vec<Dock>,
472    /// The primary dock's container child (referenced by `view!`).
473    pane_container: gtk::Box,
474    /// The panel's side for launches that do not specify one.
475    primary_side: PanelSide,
476    /// Hyprland IPC, when running under Hyprland. `None` means no workspace
477    /// scoping: every pane shares the empty workspace name and is always shown.
478    hypr: Option<Hyprland>,
479    /// The active workspace (a Hyprland name), as last reported. Panes are
480    /// shown only while their own workspace matches this.
481    active_workspace: String,
482    /// Next pane id; bumped on every pane creation so ids never repeat.
483    next_id: u64,
484    /// Workspaces where the user explicitly hid the panel (via `ts --hidden`
485    /// or collapsing). A workspace absent from the set is shown. Hiding on one
486    /// workspace never affects the others.
487    hidden_workspaces: HashSet<String>,
488    /// Per-side dock width in pixels. Starts from session state (falling back to
489    /// `[panel] width`) and changes on interactive resize — one entry per side,
490    /// so the two docks are sized independently without parallel scalar fields.
491    widths: HashMap<PanelSide, u32>,
492    /// Most recently opened root, kept so a width save never drops it.
493    last_root: Option<PathBuf>,
494    /// The bookmarks list, loaded from the bookmarks file and written back
495    /// whenever it changes.
496    bookmarks: Vec<Bookmark>,
497    /// The pane-move drag in flight, if any.
498    pane_drag: Option<PaneDrag>,
499    /// Monotonic id for the debounced width save: a scheduled save only writes
500    /// if it is still the latest (no `SourceId` juggling — removing a one-shot
501    /// source that has already fired panics).
502    save_generation: Rc<Cell<u64>>,
503    /// A handle to this component's own input, kept so helpers can schedule a
504    /// deferred [`AppMsg::FocusPane`] (focus must land after the newly-shown
505    /// body page is laid out).
506    sender: ComponentSender<App>,
507}
508
509#[relm4::component(pub)]
510impl SimpleComponent for App {
511    type Init = AppInit;
512    type Input = AppMsg;
513    type Output = ();
514
515    view! {
516        gtk::Window {
517            set_default_size: (model.config.panel.width as i32, 520),
518
519            gtk::Box {
520                set_orientation: gtk::Orientation::Vertical,
521                set_spacing: 0,
522                add_css_class: "panel",
523
524                append: &model.pane_container,
525
526                append: status = &gtk::Label {
527                    #[watch]
528                    set_label: &model.status,
529                    set_halign: gtk::Align::Start,
530                    set_ellipsize: gtk::pango::EllipsizeMode::End,
531                    add_css_class: "status-bar",
532                }
533            }
534        }
535    }
536
537    fn init(
538        init: Self::Init,
539        root: Self::Root,
540        sender: ComponentSender<Self>,
541    ) -> ComponentParts<Self> {
542        let loaded = Config::load();
543        let mut config = loaded.config;
544        let parent = root.clone();
545
546        // Bookmarks are runtime data kept in their own file beside the config.
547        // Materialize it (home by default) on first launch.
548        let (bookmarks, bookmark_problem) = crate::config::load_bookmarks(&config.bookmarks);
549        if let Err(problem) = crate::config::ensure_bookmarks_file(&config.bookmarks, &bookmarks) {
550            eprintln!("tree-space: could not create bookmarks file: {problem:?}");
551        }
552
553        let session = SessionState::load();
554        // An interactive resize is sticky across launches; the configured
555        // `[panel] width` is only the initial default for each side.
556        let default_width = config.panel.width;
557        let widths: HashMap<PanelSide, u32> = [
558            (
559                PanelSide::Left,
560                session.left_width.unwrap_or(default_width).clamp(PANEL_MIN_WIDTH, PANEL_MAX_WIDTH),
561            ),
562            (
563                PanelSide::Right,
564                session.right_width.unwrap_or(default_width).clamp(PANEL_MIN_WIDTH, PANEL_MAX_WIDTH),
565            ),
566        ]
567        .into_iter()
568        .collect();
569
570        let primary_side = init.command.side.unwrap_or(config.panel.side);
571        // Keep `config.panel.width` meaningful for the primary dock.
572        config.panel.width = widths[&primary_side];
573        let primary_width = config.panel.width;
574        // Panes are scoped to the workspace they are opened on. Under Hyprland
575        // we follow the active workspace over IPC; without it every pane shares
576        // the empty workspace (i.e. all panes are visible, as before).
577        let hypr = Hyprland::connect();
578        let active_workspace =
579            hypr.as_ref().and_then(Hyprland::active_workspace).unwrap_or_default();
580        // Build the primary dock's initial panes from the invocation. With no
581        // roots, resolve the configured startup directory; a `bookmarks` startup
582        // (the default) resolves to none, so the pane opens the bookmarks view.
583        let roots = if !init.command.roots.is_empty() {
584            init.command.roots.clone()
585        } else if config.startup.is_bookmarks() {
586            Vec::new()
587        } else {
588            let last = session.last_root.clone().filter(|p| p.is_dir());
589            let fallback = config
590                .startup
591                .resolve(last)
592                .filter(|p| p.is_dir())
593                .or_else(home_dir);
594            if let Some(p) = fallback { vec![p] } else { Vec::new() }
595        };
596
597        let mut panes: Vec<Pane> = Vec::new();
598        let mut next_id = 0u64;
599        for root in roots.iter() {
600            let pane = make_pane(
601                &config,
602                parent.clone(),
603                next_id,
604                primary_side,
605                primary_width,
606                active_workspace.clone(),
607                sender.clone(),
608            );
609            pane.tree.emit(TreeMsg::OpenRoot(root.clone()));
610            panes.push(pane);
611            panes.last_mut().unwrap().set_root(root.clone());
612            next_id += 1;
613        }
614        // Guarantee at least one pane. With no roots it opens the bookmarks view
615        // (a "new panel" suggesting places to jump to).
616        if panes.is_empty() {
617            let mut pane = make_pane(
618                &config,
619                parent.clone(),
620                next_id,
621                primary_side,
622                primary_width,
623                active_workspace.clone(),
624                sender.clone(),
625            );
626            pane.show_bookmarks();
627            pane.history.record(ViewEntry::Bookmarks);
628            pane.refresh_nav();
629            panes.push(pane);
630            next_id += 1;
631        }
632
633        let pane_container = gtk::Box::new(gtk::Orientation::Vertical, 0);
634        pane_container.set_vexpand(true);
635        fill_pane_container(&pane_container, &panes, &active_workspace);
636
637        let mut status = String::new();
638        if let Some(problem) = loaded.problem {
639            status = format!("config: {problem:?}");
640        } else if let Some(problem) = bookmark_problem {
641            status = format!("bookmarks: {problem:?}");
642        }
643
644        // A hidden launch hides the panel only on the workspace it starts on.
645        let hidden_workspaces = if init.command.hidden {
646            HashSet::from([active_workspace.clone()])
647        } else {
648            HashSet::new()
649        };
650
651        let mut model = App {
652            config,
653            status,
654            window: root.clone(),
655            file_dialog: gtk::FileDialog::new(),
656            docks: vec![Dock {
657                side: primary_side,
658                monitor: None,
659                window: root.clone(),
660                container: pane_container.clone(),
661                panes,
662                active_pane: None,
663            }],
664            pane_container,
665            primary_side,
666            hypr: hypr.clone(),
667            active_workspace,
668            next_id,
669            hidden_workspaces,
670            widths,
671            last_root: session.last_root.clone(),
672            bookmarks,
673            pane_drag: None,
674            save_generation: Rc::new(Cell::new(0)),
675            sender: sender.clone(),
676        };
677
678        // Follow workspace changes so the panel hides when its workspace is not
679        // the active one and returns when it is.
680        if let Some(hypr) = &hypr {
681            let workspace_sender = sender.clone();
682            hypr.spawn_watcher(move |name| {
683                let sender = workspace_sender.clone();
684                glib::MainContext::default().invoke(move || {
685                    sender.input(AppMsg::ActiveWorkspace(name));
686                });
687            });
688        }
689
690        init_layer_window(&model.window, &model.config, primary_side, primary_width, None);
691        install_css(&model.config);
692        // Interactive resize only makes sense for a docked layer surface; a
693        // plain fallback window is resized like any other window.
694        if layer_shell_available() {
695            attach_resize_controls(&model.window, primary_side, &sender);
696        }
697
698        // Serve the instance socket: every new `tree-space` invocation delivers
699        // a Command here, hopped onto the UI thread by the main context.
700        if let Some(listener) = init.listener {
701            let ipc_sender = sender.input_sender().clone();
702            ipc::spawn_listener(listener, move |command| {
703                let sender = ipc_sender.clone();
704                glib::MainContext::default().invoke(move || {
705                    let _ = sender.send(AppMsg::LaunchRequest { command });
706                });
707            });
708        }
709
710        let widgets = view_output!();
711
712        // A mapped surface takes keyboard focus, so launching the panel leaves
713        // the keyboard on the first row without a click (the compositor grants
714        // the layer's keyboard on map; this focuses the row within it).
715        {
716            let sender = sender.clone();
717            model.window.connect_map(move |_| {
718                let sender = sender.clone();
719                // The surface now exists, so its monitor can be resolved.
720                sender.input(AppMsg::ResolveDockMonitor { di: 0 });
721                glib::idle_add_local_once(move || sender.input(AppMsg::FocusVisible));
722            });
723        }
724
725        // Populate every pane's bookmarks list now that the order is settled.
726        model.refresh_all_bookmarks(&sender);
727
728        // Quitting while a video thumbnail is playing is a shutdown race:
729        // `GtkMediaFile` renders through GStreamer's GL sink, and exiting with
730        // that context live lets NVIDIA's at-exit EGL teardown unmap GPU memory
731        // while the `gstglcontext` thread is still issuing GL calls (SIGSEGV in
732        // the driver). Intercept the first close, tear the media down on the
733        // main loop, and only let the window close once that has settled.
734        let shutting_down = Rc::new(Cell::new(false));
735        let flag = shutting_down.clone();
736        let shutdown_sender = sender.clone();
737        model.window.connect_close_request(move |_| {
738            if flag.get() {
739                return glib::Propagation::Proceed;
740            }
741            flag.set(true);
742            shutdown_sender.input(AppMsg::Shutdown);
743            glib::Propagation::Stop
744        });
745
746        ComponentParts { model, widgets }
747    }
748
749    fn update(&mut self, msg: Self::Input, sender: ComponentSender<Self>) {
750        match msg {
751            AppMsg::PaneToolbar { id, out } => match out {
752                ToolbarOutput::OpenFolder => {
753                    self.set_active(id);
754                    self.show_open_folder(id, &sender);
755                }
756                ToolbarOutput::NavigateTo(path) => {
757                    self.open_root_in_pane(id, path);
758                }
759                ToolbarOutput::FilterRequested => {
760                    self.set_active(id);
761                    self.open_filter(id);
762                }
763                ToolbarOutput::SplitView => {
764                    sender.input(AppMsg::SplitFromPane { id });
765                }
766                ToolbarOutput::Collapse => {
767                    self.collapse_dock(id);
768                }
769                ToolbarOutput::ClosePane => {
770                    sender.input(AppMsg::ClosePane { id });
771                }
772                ToolbarOutput::PaneItem(action) => self.run_pane_shortcut(id, action, &sender),
773            },
774
775            AppMsg::PaneTree { id, out } => match out {
776                TreeOutput::Status(message) => {
777                    self.set_active(id);
778                    self.status = message;
779                }
780                TreeOutput::OpenFolderRequested => {
781                    self.set_active(id);
782                    self.show_open_folder(id, &sender);
783                }
784                TreeOutput::OpenSplit(root) => {
785                    self.set_active(id);
786                    sender.input(AppMsg::OpenSplitFrom { id, root });
787                }
788                TreeOutput::OpenOpposite(root) => {
789                    self.set_active(id);
790                    sender.input(AppMsg::OpenOppositeFrom { id, root });
791                }
792                TreeOutput::PaneAction(action) => {
793                    self.dispatch_pane_builtin(id, action, &sender);
794                }
795                TreeOutput::AddBookmark(path) => {
796                    self.set_active(id);
797                    self.add_bookmark(path);
798                    self.refresh_all_bookmarks(&sender);
799                }
800                TreeOutput::RootChanged(root) => {
801                    self.set_active(id);
802                    if let Some((di, pi)) = self.dock_pane_of(id) {
803                        let pane = &mut self.docks[di].panes[pi];
804                        // Record the visit for back/forward, then clear the
805                        // in-flight flag a back/forward navigation sets (so its
806                        // own root change is not recorded as a fresh visit).
807                        pane.history.record(ViewEntry::Dir(root.clone()));
808                        pane.history.finish_navigation();
809                        pane.show_tree();
810                        pane.set_root(root.clone());
811                        pane.toolbar.emit(ToolbarMsg::SetRoot(root.clone()));
812                    }
813                    self.status = String::new();
814                    // Persist the most recently opened root for the next launch.
815                    self.last_root = Some(root);
816                    self.persist_session();
817                }
818            },
819
820            AppMsg::PaneShortcut { id, action } => {
821                self.run_pane_shortcut(id, action, &sender);
822            }
823
824            AppMsg::OpenFolderPicked { id, path: Some(path) } => {
825                self.open_root_in_pane(id, path);
826            }
827            AppMsg::OpenFolderPicked { path: None, .. } => {}
828
829            AppMsg::LaunchRequest { command } => {
830                self.handle_launch(command, &sender);
831            }
832
833            AppMsg::ActiveWorkspace(name) => self.on_active_workspace(name),
834
835            AppMsg::SplitFromPane { id } => {
836                // A new panel with no path opens the bookmarks view, so the
837                // split suggests places to jump to.
838                let di = self.dock_pane_of(id).map(|(di, _)| di).unwrap_or(0);
839                self.add_pane(di, None, &sender);
840            }
841
842            AppMsg::OpenSplitFrom { id, root } => {
843                if let Some((di, _pi)) = self.dock_pane_of(id) {
844                    self.add_pane(di, Some(root), &sender);
845                }
846            }
847
848            AppMsg::OpenOppositeFrom { id, root } => {
849                if let Some((di, _pi)) = self.dock_pane_of(id) {
850                    let side = self.docks[di].side.opposite();
851                    let monitor = self.docks[di].monitor.clone();
852                    let target = self.ensure_dock(side, monitor, false, &sender);
853                    self.add_pane(target, Some(root), &sender);
854                    // A freshly created dock starts hidden; reveal it (unless
855                    // the panel is currently toggled off on this workspace).
856                    let visible = self.visible();
857                    self.set_docks_visible(visible);
858                }
859            }
860
861            AppMsg::ClosePane { id } => {
862                let Some((di, pi)) = self.dock_pane_of(id) else {
863                    return;
864                };
865                // The pane's tree is about to be dropped; pause its previews
866                // first so a playing video can't be kept alive by a widget the
867                // removal leaves parented.
868                self.docks[di].panes[pi].tree.emit(TreeMsg::Suspend);
869                self.docks[di].panes.remove(pi);
870                if self.docks[di].active_pane == Some(id) {
871                    self.docks[di].active_pane = self.last_active_pane_id(di);
872                }
873                // The program only exits once the *last* pane anywhere closes.
874                let panes_left: usize = self.docks.iter().map(|d| d.panes.len()).sum();
875                if panes_left == 0 {
876                    self.window.close();
877                    return;
878                }
879                if self.docks[di].panes.is_empty() && di != 0 {
880                    // Nothing left in this non-primary dock on any workspace.
881                    let dock = self.docks.remove(di);
882                    dock.window.close();
883                } else {
884                    fill_pane_container(
885                        &self.docks[di].container.clone(),
886                        &self.docks[di].panes,
887                        &self.active_workspace,
888                    );
889                    // The dock may still hold panes, but none on the active
890                    // workspace; it must not linger as a blank strip. Closing a
891                    // pane is not a request to hide the panel, so the user's
892                    // show/hide intent is left alone.
893                    let shown = self.visible() && self.dock_has_active_pane(di);
894                    self.docks[di].window.set_visible(shown);
895                }
896            }
897
898            AppMsg::FilterChanged { id, filter } => {
899                if let Some((di, pi)) = self.dock_pane_of(id) {
900                    if self.docks[di].panes[pi].on_bookmarks() {
901                        self.docks[di].panes[pi].bookmark_filter = filter;
902                        self.refresh_all_bookmarks(&sender);
903                    } else {
904                        self.docks[di].panes[pi].tree.emit(TreeMsg::SetFilter(filter));
905                    }
906                }
907            }
908            AppMsg::FilterClosed { id } => {
909                if let Some((di, pi)) = self.dock_pane_of(id) {
910                    let pane = &mut self.docks[di].panes[pi];
911                    pane.filter_entry.set_text("");
912                    pane.tree.emit(TreeMsg::SetFilter(String::new()));
913                    pane.bookmark_filter.clear();
914                    pane.filter_bar.set_visible(false);
915                }
916                self.refresh_all_bookmarks(&sender);
917            }
918
919            AppMsg::FocusPane { id } => {
920                self.focus_pane(id);
921            }
922
923            AppMsg::FocusVisible => {
924                let ids: Vec<u64> = (0..self.docks.len())
925                    .filter(|&di| self.docks[di].window.is_visible())
926                    .filter_map(|di| self.last_active_pane_id(di))
927                    .collect();
928                for id in ids {
929                    self.focus_pane(id);
930                }
931            }
932
933            AppMsg::Shutdown => {
934                // Release every video stream synchronously, walking the widget
935                // trees rather than the model so that panes already detached
936                // from `self.docks` (a closed last pane keeps its widgets alive
937                // until the window is destroyed) are covered too.
938                for dock in &self.docks {
939                    stop_video_widgets(dock.window.upcast_ref());
940                }
941                // Drop the cached media (and thumbnails) on the main loop.
942                for dock in &self.docks {
943                    for pane in &dock.panes {
944                        pane.tree.emit(TreeMsg::Shutdown);
945                    }
946                }
947                // Let the message above run and GStreamer wind its GL context
948                // down before the window closes and the process exits.
949                let window = self.window.clone();
950                glib::timeout_add_local_once(std::time::Duration::from_millis(SHUTDOWN_GRACE_MS), move || {
951                    window.close();
952                });
953            }
954
955            AppMsg::ResizeBy { side, delta } => self.resize_by(side, delta),
956            AppMsg::ResizeCommit => self.persist_session(),
957
958            AppMsg::ResolveDockMonitor { di } => {
959                let Some(dock) = self.docks.get(di) else { return };
960                if dock.monitor.is_some() {
961                    return;
962                }
963                let (Some(surface), Some(display)) =
964                    (dock.window.surface(), gdk::Display::default())
965                else {
966                    return;
967                };
968                if let Some(monitor) = display.monitor_at_surface(&surface) {
969                    self.docks[di].monitor = Some(monitor);
970                }
971            }
972
973            AppMsg::PaneDragBegin { id, start } => self.begin_pane_drag(id, start),
974            AppMsg::PaneDragUpdate { id, offset } => self.update_pane_drag(id, offset),
975            AppMsg::PaneDragEnd { id, offset } => self.end_pane_drag(id, offset, &sender),
976
977            AppMsg::BookmarkEvent { id, event } => match event {
978                BookmarkEvent::Open(path) => self.open_bookmark(id, path, &sender),
979                BookmarkEvent::Toggle(index_path) => {
980                    if let Some(entry) = Bookmark::get_mut(&mut self.bookmarks, &index_path) {
981                        entry.expanded = !entry.expanded;
982                    }
983                    self.refresh_all_bookmarks(&sender);
984                }
985                BookmarkEvent::Edit(index_path) => self.edit_bookmark(id, index_path, &sender),
986                BookmarkEvent::NewBookmark => self.new_bookmark(id, &sender),
987                BookmarkEvent::NewBookmarkFolder => self.new_bookmark_folder(id, &sender),
988                BookmarkEvent::Action { path, target } => {
989                    self.run_bookmark_action(id, path, target);
990                }
991                BookmarkEvent::Move { from, to } => self.move_bookmark(from, to, &sender),
992                BookmarkEvent::Delete(index_path) => {
993                    if Bookmark::remove(&mut self.bookmarks, &index_path) {
994                        self.save_bookmarks();
995                        self.refresh_all_bookmarks(&sender);
996                    }
997                }
998            },
999            AppMsg::BookmarkEditSaved { index_path, name, path } => {
1000                if let Some(entry) = Bookmark::get_mut(&mut self.bookmarks, &index_path) {
1001                    entry.name = name;
1002                    // A folder has no path; a leaf always sets one.
1003                    if path.is_some() {
1004                        entry.path = path;
1005                    }
1006                    self.save_bookmarks();
1007                    self.refresh_all_bookmarks(&sender);
1008                }
1009            }
1010            AppMsg::BookmarkAdded { name, path } => {
1011                if Bookmark::contains_path(&self.bookmarks, &path) {
1012                    self.status = format!("{} is already bookmarked", path.display());
1013                    return;
1014                }
1015                self.bookmarks.push(Bookmark::leaf(name, path));
1016                self.save_bookmarks();
1017                self.refresh_all_bookmarks(&sender);
1018            }
1019            AppMsg::BookmarkFolderAdded { name } => {
1020                self.bookmarks.push(Bookmark {
1021                    name,
1022                    path: None,
1023                    items: Vec::new(),
1024                    expanded: false,
1025                });
1026                self.save_bookmarks();
1027                self.refresh_all_bookmarks(&sender);
1028            }
1029        }
1030    }
1031}
1032
1033impl App {
1034    /// Apply a forwarded launch request.
1035    fn handle_launch(&mut self, command: Command, sender: &ComponentSender<Self>) {
1036        // `--key`: run a configured shortcut against the active pane as if it
1037        // were pressed. It never changes visibility; a cold start (no panes at
1038        // all) falls through to the normal show.
1039        if let Some(accel) = command.key.clone() {
1040            match self.active_pane_id(command.side) {
1041                Some(id) => {
1042                    self.run_accelerator(id, &accel, sender);
1043                    return;
1044                }
1045                None if !self.docks.is_empty() => return,
1046                None => {}
1047            }
1048        }
1049
1050        let hidden = command.hidden;
1051
1052        // A width change is a side effect that never touches visibility. It
1053        // targets the named side, or the primary side when none is given.
1054        if let Some(width) = command.width {
1055            let side = command.side.unwrap_or(self.primary_side);
1056            match width {
1057                WidthArg::To(px) => self.set_width(side, px),
1058                WidthArg::By(delta) => self.resize_by(side, delta),
1059            }
1060            self.persist_session();
1061            if command.roots.is_empty() && command.reveal.is_empty() {
1062                return;
1063            }
1064        }
1065
1066        if command.roots.is_empty() && command.reveal.is_empty() {
1067            // `--hidden` always means "hide", never toggle. Otherwise the
1068            // command is a show/toggle scoped to a side or to every dock.
1069            let intent = match (command.side, hidden) {
1070                (Some(side), true) => VisibilityIntent::HideSide(side),
1071                (Some(side), false) => VisibilityIntent::ToggleSide(side),
1072                (None, true) => VisibilityIntent::HideAll,
1073                (None, false) => VisibilityIntent::ToggleAll,
1074            };
1075            self.apply_visibility(intent, sender);
1076            return;
1077        }
1078
1079        for root in &command.roots {
1080            if self.find_pane_with_dir(root).is_some() {
1081                // The directory is already open in some pane: never spawn a
1082                // duplicate — just keep it. The panel is shown below.
1083                continue;
1084            }
1085            let side = command.side.unwrap_or(self.primary_side);
1086            let monitor = self.default_monitor();
1087            let di = self.ensure_dock(side, monitor, false, sender);
1088            self.add_pane(di, Some(root.clone()), sender);
1089        }
1090
1091        // Reveal each `--select`/file argument: open a pane rooted at the
1092        // path's parent and select the path inside it. Reveals always open a
1093        // fresh pane on the default side (they carry a specific target, so
1094        // reusing an existing pane would lose it).
1095        for (root, select) in command.reveal_targets() {
1096            if !root.is_dir() {
1097                continue;
1098            }
1099            let side = command.side.unwrap_or(self.primary_side);
1100            let monitor = self.default_monitor();
1101            let di = self.ensure_dock(side, monitor, false, sender);
1102            let id = self.add_pane(di, Some(root), sender);
1103            if let Some(select) = select
1104                && let Some((di, pi)) = self.dock_pane_of(id)
1105            {
1106                self.docks[di].panes[pi].tree.emit(TreeMsg::SelectPath(select));
1107            }
1108        }
1109        // Show the panel, unless this launch asked to stay hidden.
1110        self.set_docks_visible(!hidden);
1111    }
1112
1113    /// Carry out a [`VisibilityIntent`] using the pure [`resolve_visibility`]
1114    /// plan. Creates a dock when the intent calls for it and seeds an empty one
1115    /// so "show" never reveals an empty shell.
1116    fn apply_visibility(&mut self, intent: VisibilityIntent, sender: &ComponentSender<Self>) {
1117        // Sides may host more than one dock (one per monitor); the visibility
1118        // rules only care which sides exist and which are shown.
1119        let mut existing: Vec<PanelSide> = Vec::new();
1120        let mut shown: Vec<PanelSide> = Vec::new();
1121        for dock in &self.docks {
1122            if !existing.contains(&dock.side) {
1123                existing.push(dock.side);
1124            }
1125            if dock.window.is_visible() && !shown.contains(&dock.side) {
1126                shown.push(dock.side);
1127            }
1128        }
1129        let plan = resolve_visibility(intent, &existing, &shown);
1130
1131        // A whole-panel show just reveals what is already open: an emptied dock
1132        // (e.g. the default side after its pane was moved to the other side)
1133        // must not be re-seeded with a fresh pane when a pane is still open
1134        // elsewhere. Only a side-specific show/creation seeds an empty dock.
1135        let whole_panel =
1136            matches!(intent, VisibilityIntent::ShowAll | VisibilityIntent::ToggleAll);
1137        // Only panes on the active workspace count as "open" for seeding: a
1138        // whole-panel show should reveal them rather than spawn a new pane.
1139        let panes_exist = self
1140            .docks
1141            .iter()
1142            .flat_map(|dock| &dock.panes)
1143            .any(|pane| pane.workspace == self.active_workspace);
1144        let seed_empty = should_seed_empty(panes_exist, plan.seed, whole_panel);
1145
1146        if let Some(side) = plan.create {
1147            let monitor = self.default_monitor();
1148            self.ensure_dock(side, monitor, plan.seed, sender);
1149        }
1150        for side in &plan.show {
1151            // Show every dock on this side (there may be more than one, one per
1152            // monitor), creating one on the default monitor if none exists.
1153            if self.dock_of_side(*side).is_none() {
1154                let monitor = self.default_monitor();
1155                self.ensure_dock(*side, monitor, seed_empty || !whole_panel, sender);
1156            }
1157            let indices: Vec<usize> = self
1158                .docks
1159                .iter()
1160                .enumerate()
1161                .filter(|(_, dock)| dock.side == *side)
1162                .map(|(di, _)| di)
1163                .collect();
1164            for di in indices {
1165                if seed_empty && !self.dock_has_active_pane(di) {
1166                    let startup = self.config.startup.clone();
1167                    match default_root(&startup) {
1168                        Some(root) => self.add_pane(di, Some(root), sender),
1169                        None => self.add_pane(di, None, sender),
1170                    };
1171                }
1172                // Never reveal a dock with no active-workspace pane.
1173                let show = self.dock_has_active_pane(di);
1174                self.docks[di].window.set_visible(show);
1175            }
1176        }
1177        for side in &plan.hide {
1178            let indices: Vec<usize> = self
1179                .docks
1180                .iter()
1181                .enumerate()
1182                .filter(|(_, dock)| dock.side == *side)
1183                .map(|(di, _)| di)
1184                .collect();
1185            for di in indices {
1186                self.docks[di].window.set_visible(false);
1187                self.suspend_dock_panes(di);
1188            }
1189        }
1190        // Any side not named by the plan keeps its current visibility.
1191        self.refresh_visible();
1192        // When a side was just shown, hand keyboard focus to its top pane so
1193        // the panel is immediately usable.
1194        let focus: Vec<u64> = plan
1195            .show
1196            .iter()
1197            .flat_map(|side| {
1198                self.docks
1199                    .iter()
1200                    .filter(move |dock| dock.side == *side)
1201                    .filter_map(|dock| dock.panes.last())
1202                    .map(|pane| pane.id)
1203            })
1204            .collect();
1205        for id in focus {
1206            self.focus_pane(id);
1207        }
1208    }
1209
1210    /// Set `id` as the active pane in whichever dock holds it.
1211    fn set_active(&mut self, id: u64) {
1212        if let Some((di, _pi)) = self.dock_pane_of(id) {
1213            self.docks[di].active_pane = Some(id);
1214        }
1215    }
1216
1217    /// Hand keyboard focus to pane `id`: the bookmark cursor when the bookmarks
1218    /// view is showing, otherwise the tree's current row. Used on launch, on
1219    /// show, and when a pane becomes active so the keyboard works without a
1220    /// click.
1221    fn focus_pane(&mut self, id: u64) {
1222        let Some((di, pi)) = self.dock_pane_of(id) else {
1223            return;
1224        };
1225        let pane = &self.docks[di].panes[pi];
1226        if pane.on_bookmarks() {
1227            pane.bookmark_nav.focus_start();
1228        } else {
1229            pane.tree.emit(TreeMsg::Focus);
1230        }
1231    }
1232
1233    /// Focus pane `id` on the next idle. Used after a body switch: the new page
1234    /// (and its freshly-loaded tree rows) is not laid out until then, so an
1235    /// immediate `grab_focus` would land on the now-hidden page.
1236    fn focus_pane_later(&self, id: u64) {
1237        let sender = self.sender.clone();
1238        glib::idle_add_local_once(move || sender.input(AppMsg::FocusPane { id }));
1239    }
1240
1241    /// Show the filter bar for pane `id` and focus its entry. The bar was just
1242    /// mapped, so an immediate `grab_focus()` no-ops (GTK applies visibility on
1243    /// the next layout pass) and keystrokes would land in the tree; defer it.
1244    fn open_filter(&mut self, id: u64) {
1245        if let Some((di, pi)) = self.dock_pane_of(id) {
1246            let pane = &mut self.docks[di].panes[pi];
1247            pane.filter_bar.set_visible(true);
1248            let entry = pane.filter_entry.clone();
1249            glib::idle_add_local_once(move || {
1250                entry.grab_focus();
1251            });
1252        }
1253    }
1254
1255    /// Append `path` to the bookmarks (skipping a duplicate path, at any depth)
1256    /// and persist.
1257    fn add_bookmark(&mut self, path: PathBuf) {
1258        if Bookmark::contains_path(&self.bookmarks, &path) {
1259            self.status = format!("{} is already bookmarked", path.display());
1260            return;
1261        }
1262        self.bookmarks.push(Bookmark::leaf(Bookmark::default_name(&path), path.clone()));
1263        self.save_bookmarks();
1264        self.status = format!("Bookmarked {}", path.display());
1265    }
1266
1267    /// Show `path` in pane `id`: switch its body to the tree and load the
1268    /// directory (leaving the bookmarks view, if it was showing).
1269    fn open_root_in_pane(&mut self, id: u64, path: PathBuf) {
1270        if let Some((di, pi)) = self.dock_pane_of(id) {
1271            self.docks[di].panes[pi].show_tree();
1272            self.docks[di].panes[pi].tree.emit(TreeMsg::OpenRoot(path));
1273            self.set_active(id);
1274        }
1275        self.focus_pane_later(id);
1276    }
1277
1278    /// Jump pane `id` to a bookmark's directory, replacing its bookmarks view
1279    /// (or its current directory) with that folder.
1280    fn open_bookmark(&mut self, id: u64, path: PathBuf, _sender: &ComponentSender<Self>) {
1281        let path = crate::config::expand_bookmark_path(&path);
1282        if !path.is_dir() {
1283            self.status = format!("{} is not a directory", path.display());
1284            return;
1285        }
1286        self.open_root_in_pane(id, path);
1287    }
1288
1289    /// Run a bookmark's inherited context action against its directory without
1290    /// opening it. `Open` navigates (a directory) or launches (a file); other
1291    /// path-safe builtins are sent to the pane's tree, which acts on the
1292    /// explicit path; custom commands run against the path.
1293    fn run_bookmark_action(&mut self, id: u64, path: PathBuf, target: ShortcutTarget) {
1294        let path = crate::config::expand_bookmark_path(&path);
1295        let Some((di, pi)) = self.dock_pane_of(id) else {
1296            return;
1297        };
1298        match target {
1299            ShortcutTarget::Builtin(BuiltinAction::Open) => {
1300                if path.is_dir() {
1301                    self.open_root_in_pane(id, path);
1302                } else {
1303                    self.docks[di].panes[pi].tree.emit(TreeMsg::OpenWithDefault(path));
1304                }
1305            }
1306            ShortcutTarget::Builtin(action) => {
1307                if let Some(msg) = crate::ui::tree::path_action_message(action, &path) {
1308                    self.docks[di].panes[pi].tree.emit(msg);
1309                }
1310            }
1311            ShortcutTarget::Command(cmd) => {
1312                self.docks[di].panes[pi]
1313                    .tree
1314                    .emit(TreeMsg::RunCommand { command: cmd.command, path });
1315            }
1316        }
1317    }
1318
1319    /// Switch pane `id` to the bookmarks view (the hamburger "Bookmarks" item).
1320    fn show_bookmarks_view(&mut self, id: u64) {
1321        if let Some((di, pi)) = self.dock_pane_of(id) {
1322            self.docks[di].panes[pi].show_bookmarks();
1323            self.docks[di].panes[pi].history.record(ViewEntry::Bookmarks);
1324            self.docks[di].panes[pi].refresh_nav();
1325            self.set_active(id);
1326        }
1327        self.focus_pane_later(id);
1328    }
1329
1330    /// Open the bookmark editor for the entry at `index_path`, parented to the
1331    /// window of pane `id`.
1332    fn edit_bookmark(&mut self, id: u64, index_path: Vec<usize>, sender: &ComponentSender<Self>) {
1333        let Some(bookmark) = Bookmark::get(&self.bookmarks, &index_path).cloned() else {
1334            return;
1335        };
1336        let Some((di, _pi)) = self.dock_pane_of(id) else { return };
1337        let parent = self.docks[di].window.clone();
1338        let sender = sender.clone();
1339        let path = bookmark.path.as_deref().map(crate::config::expand_bookmark_path);
1340        let title = if bookmark.is_folder() { "Edit Folder" } else { "Edit Bookmark" };
1341        bookmarks::show_bookmark_dialog(
1342            &parent,
1343            title,
1344            &bookmark.name,
1345            path.as_deref(),
1346            move |name, path| {
1347                sender.input(AppMsg::BookmarkEditSaved { index_path: index_path.clone(), name, path });
1348            },
1349        );
1350    }
1351
1352    /// Open the "new bookmark" dialog for pane `id`: a name and a path (with a
1353    /// folder picker), matching the edit dialog.
1354    fn new_bookmark(&mut self, id: u64, sender: &ComponentSender<Self>) {
1355        let Some((di, _pi)) = self.dock_pane_of(id) else { return };
1356        let parent = self.docks[di].window.clone();
1357        let sender = sender.clone();
1358        bookmarks::show_bookmark_dialog(
1359            &parent,
1360            "New Bookmark",
1361            "",
1362            Some(Path::new("")),
1363            move |name, path| {
1364                if let Some(path) = path {
1365                    sender.input(AppMsg::BookmarkAdded { name, path });
1366                }
1367            },
1368        );
1369    }
1370
1371    /// Open the "new folder" dialog for pane `id`: a name only.
1372    fn new_bookmark_folder(&mut self, id: u64, sender: &ComponentSender<Self>) {
1373        let Some((di, _pi)) = self.dock_pane_of(id) else { return };
1374        let parent = self.docks[di].window.clone();
1375        let sender = sender.clone();
1376        bookmarks::show_bookmark_dialog(&parent, "New Folder", "", None, move |name, _path| {
1377            sender.input(AppMsg::BookmarkFolderAdded { name });
1378        });
1379    }
1380
1381    /// Move the bookmark entry at `from` to `to` (drag and drop), then persist.
1382    /// Moving an entry into itself or one of its descendants is refused.
1383    fn move_bookmark(&mut self, from: Vec<usize>, to: MoveTarget, sender: &ComponentSender<Self>) {
1384        if from.is_empty() {
1385            return;
1386        }
1387        // Resolve the destination against the tree *after* the entry is removed,
1388        // so index shifts from the removal are accounted for.
1389        let (parent, index) = match &to {
1390            MoveTarget::Root => (Vec::new(), usize::MAX),
1391            MoveTarget::Into(folder) => {
1392                if folder.starts_with(&from) {
1393                    return;
1394                }
1395                (adjust_path_after_removal(&from, folder), usize::MAX)
1396            }
1397            MoveTarget::Before(leaf) => {
1398                if leaf.is_empty() || leaf.starts_with(&from) {
1399                    return;
1400                }
1401                let parent_orig = leaf[..leaf.len() - 1].to_vec();
1402                let parent = adjust_path_after_removal(&from, &parent_orig);
1403                let mut index = leaf[leaf.len() - 1];
1404                // A removal earlier in the same sibling list shifts the target.
1405                if from.len() == leaf.len()
1406                    && from[..from.len() - 1] == parent_orig[..]
1407                    && from[from.len() - 1] < index
1408                {
1409                    index -= 1;
1410                }
1411                (parent, index)
1412            }
1413        };
1414        let Some(entry) = Bookmark::take(&mut self.bookmarks, &from) else {
1415            return;
1416        };
1417        if !Bookmark::insert(&mut self.bookmarks, &parent, index, entry) {
1418            return;
1419        }
1420        self.save_bookmarks();
1421        self.refresh_all_bookmarks(sender);
1422    }
1423
1424    /// Save the bookmarks list to its file, reporting any failure.
1425    fn save_bookmarks(&mut self) {
1426        let path = bookmark_file_path(&self.config.bookmarks.file);
1427        if let Err(err) = save_bookmarks_to_path(&path, &self.bookmarks) {
1428            self.status = format!("Could not save bookmarks: {err:?}");
1429        }
1430    }
1431
1432    /// Refill every pane's bookmarks list after the list changes. Panes showing
1433    /// the view update in place; hidden ones are ready when next shown. Each
1434    /// pane's own filter and menu config are applied.
1435    fn refresh_all_bookmarks(&mut self, sender: &ComponentSender<Self>) {
1436        let context = self.config.context_menu.clone();
1437        let extras = self.config.bookmarks.context.clone();
1438        let bookmarks = self.bookmarks.clone();
1439        for di in 0..self.docks.len() {
1440            for pi in 0..self.docks[di].panes.len() {
1441                let pane = &self.docks[di].panes[pi];
1442                let menu = bookmarks::BookmarkMenuConfig {
1443                    context: &context,
1444                    extras: &extras,
1445                    side: self.docks[di].side,
1446                };
1447                let id = pane.id;
1448                let filter = pane.bookmark_filter.clone();
1449                let sender = sender.clone();
1450                let on_event: Rc<dyn Fn(BookmarkEvent)> =
1451                    Rc::new(move |event| sender.input(AppMsg::BookmarkEvent { id, event }));
1452                bookmarks::fill_bookmarks(
1453                    &pane.bookmarks_list,
1454                    &bookmarks,
1455                    &filter,
1456                    &menu,
1457                    &pane.bookmark_drag,
1458                    &pane.bookmark_nav,
1459                    on_event,
1460                );
1461            }
1462        }
1463    }
1464
1465    /// Run a pane-level builtin (`Split View`, `Open Folder...`, `Filter...`,
1466    /// `Collapse`, `Close Pane`) against pane `id`. Shared by the toolbar and by
1467    /// keyboard shortcuts forwarded up from the tree.
1468    fn dispatch_pane_builtin(
1469        &mut self,
1470        id: u64,
1471        action: BuiltinAction,
1472        sender: &ComponentSender<Self>,
1473    ) {
1474        self.set_active(id);
1475        match action {
1476            BuiltinAction::OpenFolder => self.show_open_folder(id, sender),
1477            BuiltinAction::Filter => self.open_filter(id),
1478            BuiltinAction::SplitView => sender.input(AppMsg::SplitFromPane { id }),
1479            BuiltinAction::Up => self.go_up(id),
1480            BuiltinAction::Back => self.go_back(id),
1481            BuiltinAction::Forward => self.go_forward(id),
1482            BuiltinAction::Collapse => self.collapse_dock(id),
1483            BuiltinAction::ClosePane => sender.input(AppMsg::ClosePane { id }),
1484            BuiltinAction::ToggleBookmarks => self.show_bookmarks_view(id),
1485            BuiltinAction::NewBookmark => self.new_bookmark(id, sender),
1486            BuiltinAction::NewBookmarkFolder => self.new_bookmark_folder(id, sender),
1487            _ => {}
1488        }
1489    }
1490
1491    /// Open the parent directory of pane `id`'s current root (`Up One Level`).
1492    /// A no-op at the filesystem root.
1493    fn go_up(&mut self, id: u64) {
1494        let Some((di, pi)) = self.dock_pane_of(id) else {
1495            return;
1496        };
1497        let Some(parent) = self.docks[di].panes[pi]
1498            .root
1499            .as_deref()
1500            .and_then(Path::parent)
1501            .map(Path::to_path_buf)
1502        else {
1503            return;
1504        };
1505        self.docks[di].panes[pi].tree.emit(TreeMsg::OpenRoot(parent));
1506        self.focus_pane_later(id);
1507    }
1508
1509    /// Step pane `id` back one entry in its history and show it.
1510    fn go_back(&mut self, id: u64) {
1511        let Some((di, pi)) = self.dock_pane_of(id) else {
1512            return;
1513        };
1514        if let Some(target) = self.docks[di].panes[pi].history.back() {
1515            self.show_view_entry(id, target);
1516        }
1517    }
1518
1519    /// Step pane `id` forward one entry in its history and show it.
1520    fn go_forward(&mut self, id: u64) {
1521        let Some((di, pi)) = self.dock_pane_of(id) else {
1522            return;
1523        };
1524        if let Some(target) = self.docks[di].panes[pi].history.forward() {
1525            self.show_view_entry(id, target);
1526        }
1527    }
1528
1529    /// Show a history entry in pane `id`: a directory (load it in the tree) or
1530    /// the bookmarks view. A back/forward to the bookmarks view produces no
1531    /// `RootChanged`, so its navigation flag is cleared here.
1532    fn show_view_entry(&mut self, id: u64, entry: ViewEntry) {
1533        let Some((di, pi)) = self.dock_pane_of(id) else {
1534            return;
1535        };
1536        match entry {
1537            ViewEntry::Dir(path) => {
1538                self.docks[di].panes[pi].show_tree();
1539                self.docks[di].panes[pi].tree.emit(TreeMsg::OpenRoot(path));
1540            }
1541            ViewEntry::Bookmarks => {
1542                self.docks[di].panes[pi].show_bookmarks();
1543                self.docks[di].panes[pi].history.finish_navigation();
1544                self.docks[di].panes[pi].refresh_nav();
1545            }
1546        }
1547        self.set_active(id);
1548        self.focus_pane_later(id);
1549    }
1550
1551    /// Run a pane-menu shortcut resolved anywhere in pane `id`. Pane builtins go
1552    /// to [`Self::dispatch_pane_builtin`]; view builtins and custom commands are
1553    /// sent to the pane's tree (which targets the open directory).
1554    fn run_pane_shortcut(
1555        &mut self,
1556        id: u64,
1557        action: ContextAction,
1558        sender: &ComponentSender<Self>,
1559    ) {
1560        self.set_active(id);
1561        // "Move to Workspace" carries a target that `ShortcutTarget` cannot
1562        // represent, so it is handled here from the full menu item.
1563        if let ContextAction::Entry(entry) = &action
1564            && entry.action == BuiltinAction::MoveToWorkspace
1565            && entry.workspace.is_none()
1566        {
1567            self.status = "Move to Workspace needs a workspace = \"...\" target".to_owned();
1568            return;
1569        }
1570        if let Some(target) = action.workspace_move() {
1571            self.move_active_pane_to_workspace(target);
1572            return;
1573        }
1574        let Some(target) = ShortcutTarget::from_action(&action) else {
1575            return;
1576        };
1577        if let ShortcutTarget::Builtin(builtin) = &target
1578            && builtin.is_pane_action()
1579        {
1580            self.dispatch_pane_builtin(id, *builtin, sender);
1581            return;
1582        }
1583        if let Some((di, pi)) = self.dock_pane_of(id)
1584            && let Some(msg) = pane_item_message(&target)
1585        {
1586            self.docks[di].panes[pi].tree.emit(msg);
1587        }
1588    }
1589
1590    /// The pane an action should target: the active pane of the asked-for side
1591    /// (or the primary side / first visible dock), falling back to its last
1592    /// pane.
1593    fn active_pane_id(&self, side: Option<PanelSide>) -> Option<u64> {
1594        let di = match side {
1595            Some(side) => self.dock_of_side(side)?,
1596            None => self
1597                .dock_of_side(self.primary_side)
1598                .or_else(|| self.docks.iter().position(|dock| dock.window.is_visible()))
1599                .or_else(|| (!self.docks.is_empty()).then_some(0))?,
1600        };
1601        let dock = &self.docks[di];
1602        let active = &self.active_workspace;
1603        dock.active_pane
1604            .filter(|id| {
1605                dock.panes.iter().any(|pane| pane.id == *id && &pane.workspace == active)
1606            })
1607            .or_else(|| {
1608                dock.panes
1609                    .iter()
1610                    .rev()
1611                    .find(|pane| &pane.workspace == active)
1612                    .map(|pane| pane.id)
1613            })
1614    }
1615
1616    /// Run an accelerator string (e.g. `Ctrl+c`, `Down`) against pane `id`, as
1617    /// if the key were pressed: pane-menu shortcuts first (they are resolved at
1618    /// the pane level in a real press), then the bookmarks or tree view's
1619    /// structural/context-menu shortcuts. Used by the IPC `--key` command.
1620    fn run_accelerator(&mut self, id: u64, accel: &str, sender: &ComponentSender<Self>) {
1621        let Some((key, mods)) = parse_accelerator(accel) else {
1622            return;
1623        };
1624        let Some((di, pi)) = self.dock_pane_of(id) else {
1625            return;
1626        };
1627        if let Some(action) = self.docks[di].panes[pi].shortcuts.action_for(key, mods) {
1628            self.run_pane_shortcut(id, action, sender);
1629            return;
1630        }
1631        // Navigation and activation belong to the bookmarks view, not the
1632        // (hidden) tree, which would have no rows to move over or open.
1633        if self.docks[di].panes[pi].on_bookmarks() {
1634            if bookmarks::BookmarkNav::is_nav_key(key) {
1635                if let Some(node) = self.docks[di].panes[pi].bookmark_nav.navigate(key) {
1636                    sender.input(AppMsg::BookmarkEvent {
1637                        id,
1638                        event: BookmarkEvent::Toggle(node.index_path),
1639                    });
1640                }
1641                return;
1642            }
1643            if matches!(key, gdk::Key::Return | gdk::Key::KP_Enter | gdk::Key::space) {
1644                if let Some(event) = self.docks[di].panes[pi].bookmark_nav.activate() {
1645                    sender.input(AppMsg::BookmarkEvent { id, event });
1646                }
1647                return;
1648            }
1649        }
1650        self.docks[di].panes[pi]
1651            .tree
1652            .emit(TreeMsg::RunAccelerator(accel.to_owned()));
1653    }
1654
1655    /// Locate `(dock_index, pane_index)` for a pane id.
1656    fn dock_pane_of(&self, id: u64) -> Option<(usize, usize)> {
1657        self.docks.iter().enumerate().find_map(|(di, dock)| {
1658            dock.panes
1659                .iter()
1660                .position(|pane| pane.id == id)
1661                .map(|pi| (di, pi))
1662        })
1663    }
1664
1665    /// Find a pane (across all docks) that already shows `path`, comparing
1666    /// canonical forms so that spellings like `./x` and `/a/x` dedupe. The
1667    /// canonical form of each pane root is cached on the pane, so this does no
1668    /// filesystem work for the common case.
1669    fn find_pane_with_dir(&self, path: &PathBuf) -> Option<(usize, usize)> {
1670        let canonical = std::fs::canonicalize(path).ok().unwrap_or_else(|| path.clone());
1671        self.docks.iter().enumerate().find_map(|(di, dock)| {
1672            dock.panes.iter().enumerate().find_map(|(pi, pane)| {
1673                if pane.canonical_root.as_deref() == Some(canonical.as_path()) {
1674                    Some((di, pi))
1675                } else {
1676                    None
1677                }
1678            })
1679        })
1680    }
1681
1682    /// Index of the first dock on `side`, if one exists.
1683    fn dock_of_side(&self, side: PanelSide) -> Option<usize> {
1684        self.docks.iter().position(|dock| dock.side == side)
1685    }
1686
1687    /// Index of the dock on `(side, monitor)`. A dock whose monitor has not
1688    /// been resolved yet matches only an unresolved (`None`) query.
1689    fn dock_index_of(&self, side: PanelSide, monitor: Option<&gdk::Monitor>) -> Option<usize> {
1690        self.docks.iter().position(|dock| {
1691            dock.side == side
1692                && match (&dock.monitor, monitor) {
1693                    (Some(a), Some(b)) => monitor_key(a) == monitor_key(b),
1694                    (None, None) => true,
1695                    _ => false,
1696                }
1697        })
1698    }
1699
1700    /// The monitor a newly-created (CLI/reveal) dock should use: the primary
1701    /// dock's, falling back to the first monitor the display reports.
1702    fn default_monitor(&self) -> Option<gdk::Monitor> {
1703        self.docks
1704            .first()
1705            .and_then(|dock| dock.monitor.clone())
1706            .or_else(|| {
1707                gdk::Display::default()?
1708                    .monitors()
1709                    .item(0)
1710                    .and_downcast::<gdk::Monitor>()
1711            })
1712    }
1713
1714    /// Hide the entire dock that holds pane `id` (the toolbar "Collapse"
1715    /// action). The dock keeps its panes, so it comes back on the next show.
1716    fn collapse_dock(&mut self, id: u64) {
1717        if let Some((di, _)) = self.dock_pane_of(id) {
1718            self.set_dock_visible(di, false);
1719        }
1720    }
1721
1722    /// Show/hide one dock. A dock only maps when it holds a pane on the active
1723    /// workspace.
1724    fn set_dock_visible(&mut self, di: usize, visible: bool) {
1725        let shown = visible && self.dock_has_active_pane(di);
1726        self.docks[di].window.set_visible(shown);
1727        if !shown {
1728            self.suspend_dock_panes(di);
1729        }
1730        self.refresh_visible();
1731    }
1732
1733    /// Pause media in every pane of dock `di`. Hiding a dock does not destroy
1734    /// its trees, so without this a preview would keep playing while invisible.
1735    fn suspend_dock_panes(&self, di: usize) {
1736        for pane in &self.docks[di].panes {
1737            pane.tree.emit(TreeMsg::Suspend);
1738        }
1739    }
1740
1741    /// Whether dock `di` holds at least one pane on the active workspace.
1742    fn dock_has_active_pane(&self, di: usize) -> bool {
1743        let active = &self.active_workspace;
1744        self.docks[di].panes.iter().any(|pane| &pane.workspace == active)
1745    }
1746
1747    /// The most recently added pane on the active workspace in dock `di`, if any.
1748    fn last_active_pane_id(&self, di: usize) -> Option<u64> {
1749        let active = &self.active_workspace;
1750        self.docks[di]
1751            .panes
1752            .iter()
1753            .rev()
1754            .find(|pane| &pane.workspace == active)
1755            .map(|pane| pane.id)
1756    }
1757
1758    /// Handle a workspace change: re-render every dock for the new workspace and
1759    /// update which docks are mapped.
1760    fn on_active_workspace(&mut self, name: String) {
1761        if self.active_workspace == name {
1762            return;
1763        }
1764        self.active_workspace = name;
1765        self.refill_docks();
1766        self.sync_dock_visibility();
1767        let active = (0..self.docks.len())
1768            .find(|&di| self.docks[di].window.is_visible())
1769            .and_then(|di| self.last_active_pane_id(di));
1770        if let Some(id) = active {
1771            self.focus_pane(id);
1772        }
1773    }
1774
1775    /// Rebuild every dock's pane stack for the active workspace.
1776    fn refill_docks(&mut self) {
1777        for dock in &self.docks {
1778            fill_pane_container(&dock.container, &dock.panes, &self.active_workspace);
1779        }
1780    }
1781
1782    /// Whether the panel is intended to be shown on the active workspace:
1783    /// shown unless the user explicitly hid it there. Each workspace keeps its
1784    /// own intent, so hiding on one never affects the others.
1785    fn visible(&self) -> bool {
1786        !self.hidden_workspaces.contains(&self.active_workspace)
1787    }
1788
1789    /// Record the show/hide intent for the active workspace.
1790    fn set_visible(&mut self, visible: bool) {
1791        if visible {
1792            self.hidden_workspaces.remove(&self.active_workspace);
1793        } else {
1794            self.hidden_workspaces.insert(self.active_workspace.clone());
1795        }
1796    }
1797
1798    /// Apply the active workspace's show/hide intent to every dock, showing only
1799    /// docks that hold a pane on the active workspace.
1800    fn sync_dock_visibility(&mut self) {
1801        let show = self.visible();
1802        for di in 0..self.docks.len() {
1803            let shown = show && self.dock_has_active_pane(di);
1804            self.docks[di].window.set_visible(shown);
1805            if !shown {
1806                self.suspend_dock_panes(di);
1807            }
1808        }
1809    }
1810
1811    /// Show/hide every dock window. Docks with no pane on the active workspace
1812    /// stay hidden.
1813    fn set_docks_visible(&mut self, visible: bool) {
1814        self.set_visible(visible);
1815        self.sync_dock_visibility();
1816        if visible {
1817            // Hand keyboard focus to each shown dock's active pane. The
1818            // window-map handler also does this once the surface is realized, so
1819            // a just-shown panel gets focus even though the grab here may
1820            // precede allocation.
1821            let focus: Vec<u64> = (0..self.docks.len())
1822                .filter(|&di| self.docks[di].window.is_visible())
1823                .filter_map(|di| self.last_active_pane_id(di))
1824                .collect();
1825            for id in focus {
1826                self.focus_pane(id);
1827            }
1828        }
1829    }
1830
1831    /// Move the active pane to another workspace (a configured action). It
1832    /// disappears from the current workspace and appears on the target. The
1833    /// panel itself never moves.
1834    fn move_active_pane_to_workspace(&mut self, target: WorkspaceMove) {
1835        if self.hypr.is_none() {
1836            self.status = "Workspace moves need a Hyprland session".to_owned();
1837            return;
1838        }
1839        let Some(id) = self.active_pane_id(None) else { return };
1840        let Some((di, pi)) = self.dock_pane_of(id) else { return };
1841        let from = self.docks[di].panes[pi].workspace.clone();
1842        let name = match target {
1843            WorkspaceMove::Named(name) => name,
1844            WorkspaceMove::Previous => self.neighbor_workspace(&from, -1),
1845            WorkspaceMove::Next => self.neighbor_workspace(&from, 1),
1846        };
1847        if name.is_empty() || name == from {
1848            return;
1849        }
1850        self.docks[di].panes[pi].workspace = name.clone();
1851        let container = self.docks[di].container.clone();
1852        fill_pane_container(&container, &self.docks[di].panes, &self.active_workspace);
1853        self.sync_dock_visibility();
1854        self.status = format!("Pane moved to workspace {name}");
1855    }
1856
1857    /// The workspace `delta` steps from `from`. A numeric workspace steps by
1858    /// number (`1`→`2`), creating it implicitly when the pane lands there; a
1859    /// named/special workspace steps through the workspaces Hyprland currently
1860    /// reports, wrapping around.
1861    fn neighbor_workspace(&self, from: &str, delta: i32) -> String {
1862        if let Ok(id) = from.parse::<i64>() {
1863            return (id + delta as i64).max(1).to_string();
1864        }
1865        let Some(hypr) = &self.hypr else { return String::new() };
1866        let names = hypr.workspace_names();
1867        if names.is_empty() {
1868            return from.to_owned();
1869        }
1870        match names.iter().position(|name| name == from) {
1871            Some(i) => {
1872                let j = (i as i32 + delta).rem_euclid(names.len() as i32) as usize;
1873                names[j].clone()
1874            }
1875            None => names[0].clone(),
1876        }
1877    }
1878
1879    /// Reconcile the active workspace's show/hide intent with what is actually
1880    /// mapped: shown while at least one dock is visible. Drives the no-argument
1881    /// toggle. Only the active workspace's intent is touched.
1882    fn refresh_visible(&mut self) {
1883        let any = self.docks.iter().any(|dock| dock.window.is_visible());
1884        self.set_visible(any);
1885    }
1886
1887    /// The current width of `side`'s dock.
1888    fn width_for(&self, side: PanelSide) -> u32 {
1889        self.widths.get(&side).copied().unwrap_or(self.config.panel.width)
1890    }
1891
1892    /// Resize `side`'s dock to `width` px, clamped, telling its trees so inline
1893    /// thumbnails re-measure. Saves are debounced.
1894    fn set_width(&mut self, side: PanelSide, width: u32) {
1895        let width = width.clamp(PANEL_MIN_WIDTH, PANEL_MAX_WIDTH);
1896        if width == self.width_for(side) {
1897            return;
1898        }
1899        self.widths.insert(side, width);
1900        let panel = PanelConfig { width, ..self.config.panel };
1901        for dock in self.docks.iter().filter(|dock| dock.side == side) {
1902            apply_window_width(&dock.window, width);
1903            for pane in &dock.panes {
1904                pane.tree.emit(TreeMsg::SetPanel(panel));
1905            }
1906        }
1907        // Keep the config's copy in step with the primary dock.
1908        if side == self.primary_side {
1909            self.config.panel.width = width;
1910        }
1911        self.schedule_save();
1912    }
1913
1914    /// Change `side`'s width by `delta` px (negative narrows).
1915    fn resize_by(&mut self, side: PanelSide, delta: i32) {
1916        let target = (self.width_for(side) as i64 + delta as i64)
1917            .clamp(PANEL_MIN_WIDTH as i64, PANEL_MAX_WIDTH as i64) as u32;
1918        self.set_width(side, target);
1919    }
1920
1921    /// The session state to persist: last root plus both dock widths.
1922    fn session_state(&self) -> SessionState {
1923        SessionState {
1924            last_root: self.last_root.clone(),
1925            left_width: Some(self.width_for(PanelSide::Left)),
1926            right_width: Some(self.width_for(PanelSide::Right)),
1927        }
1928    }
1929
1930    /// Persist session state now. Invalidates any pending debounced save.
1931    fn persist_session(&mut self) {
1932        self.save_generation.set(self.save_generation.get().wrapping_add(1));
1933        if let Err(err) = self.session_state().save() {
1934            self.status = format!("Could not save session: {err}");
1935        }
1936    }
1937
1938    /// Persist session state after a short quiet period, coalescing the many
1939    /// width changes a drag produces into a single write. A later change bumps
1940    /// the generation, so only the newest scheduled save actually writes.
1941    fn schedule_save(&mut self) {
1942        let generation = self.save_generation.get().wrapping_add(1);
1943        self.save_generation.set(generation);
1944        let current = self.save_generation.clone();
1945        let state = self.session_state();
1946        glib::timeout_add_local_once(std::time::Duration::from_millis(SAVE_DEBOUNCE_MS), move || {
1947            if current.get() == generation {
1948                let _ = state.save();
1949            }
1950        });
1951    }
1952
1953    /// Return the index of the dock on `(side, monitor)`, creating it (with a
1954    /// seeded pane from `[startup]`) when missing. `monitor` is the monitor the
1955    /// new dock should dock to; `None` lets the compositor pick.
1956    fn ensure_dock(
1957        &mut self,
1958        side: PanelSide,
1959        monitor: Option<gdk::Monitor>,
1960        seed: bool,
1961        sender: &ComponentSender<Self>,
1962    ) -> usize {
1963        if let Some(di) = self.dock_index_of(side, monitor.as_ref()) {
1964            if seed && self.docks[di].panes.is_empty() {
1965                // The dock exists but was emptied; give it a pane again.
1966                let startup = self.config.startup.clone();
1967                if let Some(root) = default_root(&startup) {
1968                    self.add_pane(di, Some(root), sender);
1969                }
1970            }
1971            return di;
1972        }
1973        let config = &self.config;
1974        let width = self.width_for(side);
1975        let window = gtk::Window::new();
1976        init_layer_window(&window, config, side, width, monitor.as_ref());
1977        window.set_default_size(width as i32, 520);
1978        if layer_shell_available() {
1979            attach_resize_controls(&window, side, sender);
1980        }
1981        // The window's child is the box holding the pane stack.
1982        let outer = gtk::Box::new(gtk::Orientation::Vertical, 0);
1983        outer.set_vexpand(true);
1984        let container = gtk::Box::new(gtk::Orientation::Vertical, 0);
1985        container.set_vexpand(true);
1986        outer.append(&container);
1987        window.set_child(Some(&outer));
1988
1989        let di = self.docks.len();
1990        self.docks.push(Dock {
1991            side,
1992            monitor,
1993            window,
1994            container,
1995            panes: Vec::new(),
1996            active_pane: None,
1997        });
1998        // As with the primary dock, resolve the monitor and hand focus to this
1999        // dock's pane once its surface is mapped (grab_focus before that no-ops).
2000        {
2001            let sender = sender.clone();
2002            let window = self.docks[di].window.clone();
2003            window.connect_map(move |_| {
2004                let sender = sender.clone();
2005                sender.input(AppMsg::ResolveDockMonitor { di });
2006                glib::idle_add_local_once(move || sender.input(AppMsg::FocusVisible));
2007            });
2008        }
2009        if seed {
2010            let startup = self.config.startup.clone();
2011            if let Some(root) = default_root(&startup) {
2012                self.add_pane(di, Some(root), sender);
2013            } else {
2014                // No directory: seed the dock with a bookmarks pane.
2015                self.add_pane(di, None, sender);
2016            }
2017        }
2018        di
2019    }
2020
2021    /// Append a pane showing `root` to dock `di`, returning its id. A `None`
2022    /// root opens the bookmarks view instead of a directory.
2023    fn add_pane(&mut self, di: usize, root: Option<PathBuf>, sender: &ComponentSender<Self>) -> u64 {
2024        let id = self.next_id;
2025        self.next_id += 1;
2026        let parent = self.docks[di].window.clone();
2027        let side = self.docks[di].side;
2028        let width = self.width_for(side);
2029        let workspace = self.active_workspace.clone();
2030        let pane = make_pane(&self.config, parent, id, side, width, workspace, sender.clone());
2031        let mut pane = pane;
2032        match &root {
2033            Some(root) => pane.tree.emit(TreeMsg::OpenRoot(root.clone())),
2034            None => {
2035                pane.show_bookmarks();
2036                pane.history.record(ViewEntry::Bookmarks);
2037                pane.refresh_nav();
2038            }
2039        }
2040        if let Some(root) = &root {
2041            pane.set_root(root.clone());
2042        }
2043        let dock = &mut self.docks[di];
2044        dock.panes.push(pane);
2045        dock.active_pane = Some(id);
2046        let container = dock.container.clone();
2047        fill_pane_container(&container, &dock.panes, &self.active_workspace);
2048        // Give the new pane keyboard focus once GTK has allocated it, so the
2049        // app (and each new split) is usable without a click.
2050        let focus_sender = sender.clone();
2051        glib::idle_add_local_once(move || focus_sender.input(AppMsg::FocusPane { id }));
2052        // Populate the new pane's bookmarks list.
2053        self.refresh_all_bookmarks(sender);
2054        id
2055    }
2056
2057    fn show_open_folder(&self, id: u64, sender: &ComponentSender<Self>) {
2058        let sender = sender.clone();
2059        self.file_dialog.select_folder(
2060            None::<&gtk::Window>,
2061            None::<&gio::Cancellable>,
2062            move |result| {
2063                let path = result.ok().and_then(|file| file.path());
2064                sender.input(AppMsg::OpenFolderPicked { id, path });
2065            },
2066        );
2067    }
2068
2069    /// The absolute top-left of dock `di`'s surface, derived from its monitor
2070    /// geometry, side, width and the configured margin. Turns window-relative
2071    /// drag coordinates into screen coordinates for target detection.
2072    fn dock_origin(&self, di: usize) -> (f64, f64) {
2073        let dock = &self.docks[di];
2074        let margin = self.config.panel.margin as f64;
2075        let width = self.width_for(dock.side) as f64;
2076        let (mx, my, mw) = match dock.monitor.as_ref().map(gdk::Monitor::geometry) {
2077            Some(g) => (g.x() as f64, g.y() as f64, g.width() as f64),
2078            None => (0.0, 0.0, 0.0),
2079        };
2080        let x = match dock.side {
2081            PanelSide::Left => mx + margin,
2082            PanelSide::Right => mx + mw - width - margin,
2083        };
2084        (x, my + margin)
2085    }
2086
2087    /// The `(side, monitor)` under an absolute screen point, or `None` when the
2088    /// point is outside every monitor. The side is the nearer edge of the
2089    /// monitor under the pointer.
2090    fn point_target(&self, point: (f64, f64)) -> Option<(PanelSide, gdk::Monitor)> {
2091        let (x, y) = point;
2092        let monitors = gdk::Display::default()?.monitors();
2093        for i in 0..monitors.n_items() {
2094            let Some(monitor) = monitors.item(i).and_downcast::<gdk::Monitor>() else {
2095                continue;
2096            };
2097            let g = monitor.geometry();
2098            let (x0, y0) = (g.x() as f64, g.y() as f64);
2099            let (w, h) = (g.width() as f64, g.height() as f64);
2100            if x >= x0 && x < x0 + w && y >= y0 && y < y0 + h {
2101                let side = if x < x0 + w / 2.0 { PanelSide::Left } else { PanelSide::Right };
2102                return Some((side, monitor));
2103            }
2104        }
2105        None
2106    }
2107
2108    /// Begin a pane-move drag: remember the pane and the pointer's absolute
2109    /// start position, and cue the source pane.
2110    fn begin_pane_drag(&mut self, id: u64, start: (f64, f64)) {
2111        let Some((di, pi)) = self.dock_pane_of(id) else { return };
2112        // Compute the grip's top-left within the pane's current window. Doing
2113        // this here (rather than in the gesture closure) keeps it correct after
2114        // the pane has been moved to a different dock/window.
2115        let grip_origin = {
2116            let grip = &self.docks[di].panes[pi].grip;
2117            let window = &self.docks[di].window;
2118            grip.compute_point(window, &relm4::gtk::graphene::Point::new(0.0, 0.0))
2119                .map(|p| (p.x() as f64, p.y() as f64))
2120                .unwrap_or((0.0, 0.0))
2121        };
2122        let origin = self.dock_origin(di);
2123        let base = (origin.0 + grip_origin.0 + start.0, origin.1 + grip_origin.1 + start.1);
2124        self.docks[di].panes[pi].widget.add_css_class("pane-moving");
2125        self.pane_drag = Some(PaneDrag { id, base, highlighted: None });
2126        self.status = "Drag the pane to the edge of a screen to move it".to_owned();
2127    }
2128
2129    /// Update the drop target for a drag in progress: highlight the target dock
2130    /// and describe it in the status line.
2131    fn update_pane_drag(&mut self, id: u64, offset: (f64, f64)) {
2132        let Some(drag) = self.pane_drag.as_ref() else { return };
2133        if drag.id != id {
2134            return;
2135        }
2136        let point = (drag.base.0 + offset.0, drag.base.1 + offset.1);
2137        let target = self.point_target(point);
2138        let highlight = target
2139            .as_ref()
2140            .and_then(|(side, monitor)| self.dock_index_of(*side, Some(monitor)));
2141        if let Some(drag) = self.pane_drag.as_mut()
2142            && drag.highlighted != highlight
2143        {
2144            if let Some(di) = drag.highlighted
2145                && let Some(dock) = self.docks.get(di)
2146            {
2147                dock.window.remove_css_class("pane-drop-target");
2148            }
2149            if let Some(di) = highlight
2150                && let Some(dock) = self.docks.get(di)
2151            {
2152                dock.window.add_css_class("pane-drop-target");
2153            }
2154            drag.highlighted = highlight;
2155        }
2156        self.status = match target {
2157            Some((side, monitor)) => format!(
2158                "Drop to move the pane to the {} panel on {}",
2159                side.name(),
2160                monitor_label(&monitor)
2161            ),
2162            None => "Drag the pane to the edge of a screen to move it".to_owned(),
2163        };
2164    }
2165
2166    /// Finish a pane drag: move the pane to the target under the pointer, or
2167    /// cancel when it was dropped outside every monitor.
2168    fn end_pane_drag(&mut self, id: u64, offset: (f64, f64), sender: &ComponentSender<Self>) {
2169        let Some(drag) = self.pane_drag.take() else { return };
2170        if drag.id != id {
2171            self.pane_drag = Some(drag);
2172            return;
2173        }
2174        if let Some((di, pi)) = self.dock_pane_of(id) {
2175            self.docks[di].panes[pi].widget.remove_css_class("pane-moving");
2176        }
2177        if let Some(di) = drag.highlighted
2178            && let Some(dock) = self.docks.get(di)
2179        {
2180            dock.window.remove_css_class("pane-drop-target");
2181        }
2182        let point = (drag.base.0 + offset.0, drag.base.1 + offset.1);
2183        match self.point_target(point) {
2184            Some((side, monitor)) => self.move_pane_to(id, side, monitor, sender),
2185            None => self.status.clear(),
2186        }
2187    }
2188
2189    /// Move pane `id` into the dock on `(side, monitor)`, creating that dock if
2190    /// it does not exist yet. A no-op when the pane is already there.
2191    fn move_pane_to(
2192        &mut self,
2193        id: u64,
2194        side: PanelSide,
2195        monitor: gdk::Monitor,
2196        sender: &ComponentSender<Self>,
2197    ) {
2198        let Some((sdi, spi)) = self.dock_pane_of(id) else { return };
2199        if self.dock_index_of(side, Some(&monitor)) == Some(sdi) {
2200            self.status.clear();
2201            return;
2202        }
2203        let pane = self.docks[sdi].panes.remove(spi);
2204        remove_from_parent(pane.widget.upcast_ref());
2205        if self.docks[sdi].active_pane == Some(id) {
2206            self.docks[sdi].active_pane = self.docks[sdi].panes.last().map(|p| p.id);
2207        }
2208        if self.docks[sdi].panes.is_empty() {
2209            if sdi == 0 {
2210                // The primary dock is the relm4 root window; hide it rather
2211                // than destroy the app (see `ClosePane`).
2212                self.docks[sdi].window.set_visible(false);
2213            } else {
2214                let dock = self.docks.remove(sdi);
2215                dock.window.close();
2216            }
2217        } else {
2218            let container = self.docks[sdi].container.clone();
2219            fill_pane_container(&container, &self.docks[sdi].panes, &self.active_workspace);
2220        }
2221        let tdi = self.ensure_dock(side, Some(monitor), false, sender);
2222        pane.tree.emit(TreeMsg::SetSide(side));
2223        self.docks[tdi].panes.push(pane);
2224        self.docks[tdi].active_pane = Some(id);
2225        let container = self.docks[tdi].container.clone();
2226        fill_pane_container(&container, &self.docks[tdi].panes, &self.active_workspace);
2227        self.sync_dock_visibility();
2228        self.refresh_all_bookmarks(sender);
2229        self.focus_pane_later(id);
2230        self.status = format!("Moved pane to the {} panel", side.name());
2231    }
2232}
2233
2234// ---------------------------------------------------------------------------
2235// helpers
2236// ---------------------------------------------------------------------------
2237
2238/// Map a configurable pane-menu item to the tree message that performs it.
2239/// Pane-level view actions have dedicated messages; custom commands are run
2240/// through the tree's shortcut path (which targets the open directory).
2241fn pane_item_message(target: &ShortcutTarget) -> Option<TreeMsg> {
2242    match target {
2243        ShortcutTarget::Builtin(action) => match action {
2244            BuiltinAction::ToggleHidden => Some(TreeMsg::ToggleHidden),
2245            BuiltinAction::SortByName => Some(TreeMsg::SetSortKey(SortKey::Name)),
2246            BuiltinAction::SortBySize => Some(TreeMsg::SetSortKey(SortKey::Size)),
2247            BuiltinAction::SortByModified => Some(TreeMsg::SetSortKey(SortKey::Modified)),
2248            BuiltinAction::SortByType => Some(TreeMsg::SetSortKey(SortKey::Type)),
2249            BuiltinAction::ToggleSortAscending => Some(TreeMsg::ToggleSortDirection),
2250            other => Some(TreeMsg::RunShortcut {
2251                target: ShortcutTarget::Builtin(*other),
2252            }),
2253        },
2254        ShortcutTarget::Command(_) => Some(TreeMsg::RunShortcut { target: target.clone() }),
2255    }
2256}
2257
2258/// Build the optional navigation toolbar: Up One Level, Back, Forward. Each
2259/// button dispatches its pane builtin through the same path as the hamburger,
2260/// so no new message type is needed.
2261fn build_nav_bar(id: u64, sender: &ComponentSender<App>) -> (gtk::Box, NavButtons) {
2262    let bar = gtk::Box::new(gtk::Orientation::Horizontal, 2);
2263    bar.add_css_class("nav-toolbar");
2264
2265    let up = nav_button("pan-up-symbolic", "Up One Level", BuiltinAction::Up, id, sender);
2266    let back = nav_button("pan-start-symbolic", "Back", BuiltinAction::Back, id, sender);
2267    let forward = nav_button("pan-end-symbolic", "Forward", BuiltinAction::Forward, id, sender);
2268
2269    bar.append(&up);
2270    bar.append(&back);
2271    bar.append(&forward);
2272    (bar, NavButtons { up, back, forward })
2273}
2274
2275/// One flat icon button in the navigation toolbar. Not focusable, so clicking it
2276/// never pulls the keyboard out of the tree or bookmarks list.
2277fn nav_button(
2278    icon: &str,
2279    tooltip: &str,
2280    action: BuiltinAction,
2281    id: u64,
2282    sender: &ComponentSender<App>,
2283) -> gtk::Button {
2284    let button = gtk::Button::from_icon_name(icon);
2285    button.add_css_class("nav-button");
2286    button.add_css_class("flat");
2287    button.set_tooltip_text(Some(tooltip));
2288    button.set_focusable(false);
2289    button.set_can_focus(false);
2290    button.set_valign(gtk::Align::Center);
2291    let sender = sender.clone();
2292    button.connect_clicked(move |_| {
2293        sender.input(AppMsg::PaneToolbar {
2294            id,
2295            out: ToolbarOutput::PaneItem(ContextAction::Builtin(action)),
2296        });
2297    });
2298    button
2299}
2300
2301fn make_pane(
2302    config: &Config,
2303    parent: gtk::Window,
2304    id: u64,
2305    side: PanelSide,
2306    width: u32,
2307    workspace: String,
2308    sender: ComponentSender<App>,
2309) -> Pane {
2310    // The pane overlay: the toolbar's completion dropdown is added to it so it
2311    // can draw over the tree while the entry keeps keyboard focus.
2312    let overlay = gtk::Overlay::new();
2313    let toolbar = Toolbar::builder()
2314        .launch(ToolbarInit {
2315            overlay: overlay.clone(),
2316            pane_menu: config.pane_menu.clone(),
2317            bookmarks_menu: config.bookmarks.menu.clone(),
2318        })
2319        .forward(sender.input_sender(), move |out| AppMsg::PaneToolbar { id, out });
2320    let tree = Tree::builder()
2321        .launch(TreeInit {
2322            config: config.tree.clone(),
2323            parent,
2324            menu: config.context_menu.clone(),
2325            side,
2326            panel: PanelConfig { width, ..config.panel },
2327        })
2328        .forward(sender.input_sender(), move |out| AppMsg::PaneTree { id, out });
2329
2330    // The toolbar grip starts a pane-move drag. The gesture reports the press
2331    // point and offsets relative to it; the app adds them to the pane's
2332    // absolute origin (computed against the pane's *current* window, which
2333    // changes when the pane is moved) to find the target edge/monitor.
2334    let grip = toolbar.model().grip().clone();
2335    {
2336        let drag = gtk::GestureDrag::new();
2337        drag.set_button(gdk::BUTTON_PRIMARY);
2338        drag.set_propagation_phase(gtk::PropagationPhase::Capture);
2339        let s = sender.clone();
2340        drag.connect_drag_begin(move |_, start_x, start_y| {
2341            s.input(AppMsg::PaneDragBegin { id, start: (start_x, start_y) });
2342        });
2343        let s = sender.clone();
2344        drag.connect_drag_update(move |_, offset_x, offset_y| {
2345            s.input(AppMsg::PaneDragUpdate { id, offset: (offset_x, offset_y) });
2346        });
2347        let s = sender.clone();
2348        drag.connect_drag_end(move |_, offset_x, offset_y| {
2349            s.input(AppMsg::PaneDragEnd { id, offset: (offset_x, offset_y) });
2350        });
2351        grip.add_controller(drag);
2352    }
2353
2354    let widget = gtk::Box::new(gtk::Orientation::Vertical, 0);
2355    widget.append(toolbar.widget());
2356
2357    // ── optional navigation toolbar (up one level / back / forward) ─────────
2358    let nav_buttons = if config.panel.nav_toolbar {
2359        let (bar, buttons) = build_nav_bar(id, &sender);
2360        widget.append(&bar);
2361        Some(buttons)
2362    } else {
2363        None
2364    };
2365
2366    // ── per-pane filter row (hidden until requested) ────────────────────────
2367    let filter_bar = gtk::Box::new(gtk::Orientation::Horizontal, 4);
2368    filter_bar.add_css_class("filter-bar");
2369    filter_bar.set_visible(false);
2370
2371    let filter_entry = gtk::SearchEntry::new();
2372    filter_entry.set_placeholder_text(Some("Filter..."));
2373    filter_entry.set_hexpand(true);
2374    filter_entry.set_valign(gtk::Align::Center);
2375    filter_entry.add_css_class("filter-entry");
2376    {
2377        let s = sender.clone();
2378        filter_entry.connect_search_changed(move |entry| {
2379            s.input(AppMsg::FilterChanged {
2380                id,
2381                filter: entry.text().to_string(),
2382            });
2383        });
2384    }
2385    filter_bar.append(&filter_entry);
2386
2387    let close_btn = gtk::Button::from_icon_name("window-close-symbolic");
2388    close_btn.set_tooltip_text(Some("Clear filter"));
2389    close_btn.set_valign(gtk::Align::Center);
2390    close_btn.add_css_class("flat");
2391    {
2392        let s = sender.clone();
2393        close_btn.connect_clicked(move |_| {
2394            s.input(AppMsg::FilterClosed { id });
2395        });
2396    }
2397    filter_bar.append(&close_btn);
2398
2399    widget.append(&filter_bar);
2400
2401    // ── pane body: tree or bookmarks, switched by `body` ────────────────────
2402    let tree_widget = tree.widget();
2403    tree_widget.set_vexpand(true);
2404
2405    let bookmarks_list = gtk::Box::new(gtk::Orientation::Vertical, 0);
2406    bookmarks_list.add_css_class("bookmarks-list");
2407    let bookmarks_scroll = gtk::ScrolledWindow::new();
2408    bookmarks_scroll.set_policy(gtk::PolicyType::Never, gtk::PolicyType::Automatic);
2409    bookmarks_scroll.set_vexpand(true);
2410    bookmarks_scroll.add_css_class("bookmarks-view");
2411    bookmarks_scroll.set_child(Some(&bookmarks_list));
2412    let bookmark_drag = bookmarks::BookmarkDrag::new();
2413    let bookmark_nav = bookmarks::BookmarkNav::new(&bookmarks_list);
2414    // The blank-area menu and the drag-and-drop wiring (reorder/move entries,
2415    // including in and out of folders).
2416    {
2417        let items = config.bookmarks.blank.clone();
2418        let sender = sender.clone();
2419        let on_event: Rc<dyn Fn(BookmarkEvent)> =
2420            Rc::new(move |event| sender.input(AppMsg::BookmarkEvent { id, event }));
2421        bookmarks::attach_bookmarks_scroller(&bookmarks_scroll, &items, side, &bookmark_drag, &bookmark_nav, on_event);
2422    }
2423
2424    let body = gtk::Stack::new();
2425    body.set_vexpand(true);
2426    body.add_named(tree_widget, Some("tree"));
2427    body.add_named(&bookmarks_scroll, Some("bookmarks"));
2428    body.set_visible_child_name("tree");
2429    widget.append(&body);
2430    widget.set_vexpand(true);
2431
2432    overlay.set_child(Some(&widget));
2433
2434    // Pane-menu shortcuts are resolved at the pane level (capture phase), so
2435    // they work whether focus is on the tree, the path entry, the filter bar, or
2436    // nothing at all. Row shortcuts stay on the tree. The compiled table is kept
2437    // on the pane so the IPC `--key` command can reuse it.
2438    let shortcuts = {
2439        // Both menus' shortcuts are live regardless of which body is showing,
2440        // so a binding in either works from the tree or the bookmarks view.
2441        let mut items = config.pane_menu.items.clone();
2442        items.extend(config.bookmarks.menu.iter().cloned());
2443        let shortcuts = Rc::new(PaneShortcuts::compile(&items));
2444        let s = sender.clone();
2445        let bound = shortcuts.clone();
2446        let keys = gtk::EventControllerKey::new();
2447        keys.set_propagation_phase(gtk::PropagationPhase::Capture);
2448        keys.connect_key_pressed(move |_, key, _, state| {
2449            if let Some(action) = bound.action_for(key, state) {
2450                s.input(AppMsg::PaneShortcut { id, action });
2451                glib::Propagation::Stop
2452            } else {
2453                glib::Propagation::Proceed
2454            }
2455        });
2456        widget.add_controller(keys);
2457        shortcuts
2458    };
2459
2460    Pane {
2461        id,
2462        toolbar,
2463        tree,
2464        workspace,
2465        root: None,
2466        canonical_root: None,
2467        history: NavHistory::default(),
2468        filter_bar,
2469        filter_entry,
2470        body,
2471        bookmarks_list,
2472        bookmark_filter: String::new(),
2473        bookmark_drag,
2474        bookmark_nav,
2475        nav_buttons,
2476        widget: overlay,
2477        grip,
2478        shortcuts,
2479    }
2480}
2481
2482/// Remove `w` from whichever parent it currently has, using the parent's own
2483/// removal API instead of a raw `unparent()`.
2484///
2485/// This matters because a `GtkPaned` keeps its start/end child slots alive
2486/// across a raw `unparent()`, and its *deferred* destroy (GTK can keep a
2487/// container alive beyond `container.remove`) then unparents the widget a
2488/// second time — kicking it out of whatever new parent we re-appended it to.
2489fn remove_from_parent(w: &gtk::Widget) {
2490    let Some(parent) = w.parent() else { return };
2491    if let Ok(paned) = parent.clone().downcast::<gtk::Paned>() {
2492        let none: Option<&gtk::Widget> = None;
2493        if paned.start_child().is_some_and(|c| c == *w) {
2494            paned.set_start_child(none);
2495        }
2496        let none: Option<&gtk::Widget> = None;
2497        if paned.end_child().is_some_and(|c| c == *w) {
2498            paned.set_end_child(none);
2499        }
2500    } else if let Ok(b) = parent.downcast::<gtk::Box>() {
2501        b.remove(w);
2502    } else {
2503        w.unparent();
2504    }
2505}
2506
2507fn pane_widget(pane: &Pane) -> gtk::Widget {
2508    pane.widget.clone().upcast()
2509}
2510
2511/// Rebuild a dock's container Box to show the panes of `active` (the active
2512/// workspace) in a nested Paned structure. Panes on other workspaces are
2513/// detached but kept alive.
2514///
2515/// Layout for N visible panes:
2516///   N=1 → container has the single pane widget.
2517///   N=2 → container has Paned { pane[0], pane[1] }
2518///   N=3 → container has Paned { pane[0], Paned { pane[1], pane[2] } }
2519///   etc.
2520///
2521/// Called on every split/close/workspace change so widget references are always
2522/// fresh.
2523fn fill_pane_container(container: &gtk::Box, panes: &[Pane], active: &str) {
2524    // Detach every pane box from its old parent *first*, using each parent's
2525    // removal API. A raw `unparent()` alone is not enough: a Paned keeps stale
2526    // child slots that re-unparent the widget when the old chain is finally
2527    // destroyed (possibly after we have re-appended it). Hidden panes are
2528    // detached too, so they are not left behind in the discarded structure.
2529    for pane in panes {
2530        remove_from_parent(pane.widget.upcast_ref());
2531    }
2532    // Now drop the old structure wholesale.
2533    while let Some(child) = container.first_child() {
2534        container.remove(&child);
2535    }
2536
2537    let visible: Vec<&Pane> = panes.iter().filter(|pane| pane.workspace == active).collect();
2538    match visible.as_slice() {
2539        [] => {}
2540        [single] => {
2541            let w = pane_widget(single);
2542            w.set_vexpand(true);
2543            container.append(&w);
2544        }
2545        visible => {
2546            // Build a right-nested Paned from the last two, then keep
2547            // wrapping from right to left.
2548            let n = visible.len();
2549            let last_w = pane_widget(visible[n - 1]);
2550            last_w.set_vexpand(true);
2551            let mut right: gtk::Widget = last_w.clone().upcast();
2552            for pane in visible[..n - 1].iter().rev() {
2553                let left_w = pane_widget(pane);
2554                left_w.set_vexpand(true);
2555                let split = gtk::Paned::new(gtk::Orientation::Vertical);
2556                split.set_vexpand(true);
2557                split.set_wide_handle(true);
2558                // Allow both children to shrink and resize freely.
2559                split.set_shrink_start_child(true);
2560                split.set_shrink_end_child(true);
2561                split.set_resize_start_child(true);
2562                split.set_resize_end_child(true);
2563                split.set_start_child(Some(&left_w));
2564                split.set_end_child(Some(&right));
2565                // Default to 50/50 split: set position after a short delay once
2566                // GTK has allocated space and computed max_position.
2567                {
2568                    let split2 = split.clone();
2569                    relm4::gtk::glib::timeout_add_local_once(
2570                        std::time::Duration::from_millis(PANED_CENTER_DELAY_MS),
2571                        move || {
2572                            // max_position is INT_MAX until the widget is allocated;
2573                            // once it has a real allocation, use half the actual height.
2574                            let alloc = split2.height();
2575                            if alloc > 0 {
2576                                split2.set_position(alloc / 2);
2577                            }
2578                        },
2579                    );
2580                }
2581                right = split.upcast();
2582            }
2583            container.append(&right);
2584        }
2585    }
2586}
2587
2588/// Clear the media stream of every `GtkVideo` under `widget`, releasing its
2589/// GStreamer GL sink. Walking the widget tree (rather than the model) means a
2590/// video also stops when the pane that built it has already been removed from
2591/// `App::docks` — a closed last pane keeps its widgets parented until the
2592/// window itself is destroyed.
2593fn stop_video_widgets(widget: &gtk::Widget) {
2594    if let Some(video) = widget.downcast_ref::<gtk::Video>() {
2595        crate::ui::stop_video_stream(video);
2596    }
2597    let mut child = widget.first_child();
2598    while let Some(current) = child {
2599        stop_video_widgets(&current);
2600        child = current.next_sibling();
2601    }
2602}
2603
2604/// Pixels added/removed per keyboard or CLI width increment.
2605const WIDTH_STEP: i32 = 24;
2606
2607/// Quiet period before a debounced session save is written after a resize.
2608const SAVE_DEBOUNCE_MS: u64 = 300;
2609
2610/// Delay before a freshly split `GtkPaned` is centred at 50/50, once GTK has
2611/// allocated it (before allocation `max_position` is `INT_MAX`).
2612const PANED_CENTER_DELAY_MS: u64 = 100;
2613
2614/// Grace period between tearing media down and closing the window on quit,
2615/// letting GStreamer release its GL context before the process exits.
2616const SHUTDOWN_GRACE_MS: u64 = 150;
2617
2618/// Resize a dock window's layer surface to `width`.
2619fn apply_window_width(window: &gtk::Window, width: u32) {
2620    let (_, height) = window.default_size();
2621    // `set_exclusive_zone` only means anything for a layer surface; on the
2622    // plain-window fallback it would warn, so resize the window directly.
2623    if window.is_layer_window() {
2624        window.set_exclusive_zone(width as i32);
2625    }
2626    window.set_size_request(width as i32, -1);
2627    window.set_default_size(width as i32, if height > 0 { height } else { -1 });
2628}
2629
2630/// Wire the interactive width controls into a dock window: Super+right-drag to
2631/// resize and Super+plus/minus to step the width.
2632fn attach_resize_controls(window: &gtk::Window, side: PanelSide, sender: &ComponentSender<App>) {
2633    attach_resize_drag(window, side, sender.input_sender().clone());
2634    attach_resize_keys(window, side, sender.input_sender().clone());
2635}
2636
2637fn attach_resize_drag(window: &gtk::Window, side: PanelSide, sender: relm4::Sender<AppMsg>) {
2638    let drag = gtk::GestureDrag::new();
2639    drag.set_button(gdk::BUTTON_SECONDARY);
2640    drag.set_propagation_phase(gtk::PropagationPhase::Capture);
2641    // (was Super held at drag start, cumulative offset of the last handled update)
2642    let state = Rc::new(RefCell::new((false, 0.0f64)));
2643
2644    let begin_state = state.clone();
2645    drag.connect_drag_begin(move |gesture, _, _| {
2646        let super_held = gesture
2647            .current_event_state()
2648            .contains(gdk::ModifierType::SUPER_MASK);
2649        *begin_state.borrow_mut() = (super_held, 0.0);
2650    });
2651
2652    let update_sender = sender.clone();
2653    let update_state = state.clone();
2654    drag.connect_drag_update(move |_, offset_x, _| {
2655        let mut state = update_state.borrow_mut();
2656        if !state.0 {
2657            return;
2658        }
2659        let step = offset_x - state.1;
2660        state.1 = offset_x;
2661        // A left dock grows as the pointer moves right; a right dock mirrors it.
2662        let widen = match side {
2663            PanelSide::Left => step,
2664            PanelSide::Right => -step,
2665        };
2666        let delta = widen.round() as i32;
2667        if delta != 0 {
2668            let _ = update_sender.send(AppMsg::ResizeBy { side, delta });
2669        }
2670    });
2671
2672    let end_sender = sender;
2673    let end_state = state.clone();
2674    drag.connect_drag_end(move |_, _, _| {
2675        if end_state.borrow().0 {
2676            let _ = end_sender.send(AppMsg::ResizeCommit);
2677        }
2678    });
2679
2680    window.add_controller(drag);
2681}
2682
2683fn attach_resize_keys(window: &gtk::Window, side: PanelSide, sender: relm4::Sender<AppMsg>) {
2684    let keys = gtk::EventControllerKey::new();
2685    keys.set_propagation_phase(gtk::PropagationPhase::Capture);
2686    keys.connect_key_pressed(move |_, key, _, state| {
2687        if !state.contains(gdk::ModifierType::SUPER_MASK) {
2688            return glib::Propagation::Proceed;
2689        }
2690        match key {
2691            gdk::Key::minus | gdk::Key::underscore | gdk::Key::KP_Subtract => {
2692                let _ = sender.send(AppMsg::ResizeBy { side, delta: -WIDTH_STEP });
2693                glib::Propagation::Stop
2694            }
2695            gdk::Key::equal | gdk::Key::plus | gdk::Key::KP_Add => {
2696                let _ = sender.send(AppMsg::ResizeBy { side, delta: WIDTH_STEP });
2697                glib::Propagation::Stop
2698            }
2699            _ => glib::Propagation::Proceed,
2700        }
2701    });
2702    window.add_controller(keys);
2703}
2704
2705/// Whether the running compositor implements the `wlr-layer-shell` protocol.
2706///
2707/// Hyprland, sway, river, niri and other wlroots-based compositors do; GNOME's
2708/// Mutter and KDE's KWin do not. Without it the panel cannot dock, reserve an
2709/// exclusive zone or use layer stacking, so it degrades to an ordinary window
2710/// (see [`init_layer_window`]). Cached after the first call, since it cannot
2711/// change while the app runs.
2712fn layer_shell_available() -> bool {
2713    static SUPPORTED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
2714    *SUPPORTED.get_or_init(gtk4_layer_shell::is_supported)
2715}
2716
2717/// Configure `window` as a layer-shell surface docked to `side`.
2718///
2719/// When the compositor has no `wlr-layer-shell` support the window is left as a
2720/// plain, decorated, freely-floating toplevel (a usable, undocked fallback
2721/// rather than a silently broken layer); check [`layer_shell_available`] when
2722/// docked-only behavior (like interactive resize) must be skipped.
2723fn init_layer_window(
2724    window: &gtk::Window,
2725    config: &Config,
2726    side: PanelSide,
2727    width: u32,
2728    monitor: Option<&gdk::Monitor>,
2729) {
2730    if !layer_shell_available() {
2731        configure_plain_window(window, width);
2732        return;
2733    }
2734    if window.is_layer_window() {
2735        return;
2736    }
2737    window.init_layer_shell();
2738    window.set_namespace(Some(crate::ui::LAYER_NAMESPACE));
2739    // Pin to the monitor before the surface is mapped; `None` leaves the
2740    // compositor to pick (the primary dock resolves its monitor after mapping).
2741    window.set_monitor(monitor);
2742    window.set_layer(layer_of(config.panel.layer));
2743    let edge = match side {
2744        PanelSide::Left => Edge::Left,
2745        PanelSide::Right => Edge::Right,
2746    };
2747    window.set_anchor(Edge::Top, true);
2748    window.set_anchor(Edge::Bottom, true);
2749    window.set_anchor(edge, true);
2750    window.set_exclusive_zone(width as i32);
2751    window.set_size_request(width as i32, -1);
2752    let margin = config.panel.margin as i32;
2753    window.set_margin(Edge::Top, margin);
2754    window.set_margin(Edge::Bottom, margin);
2755    window.set_margin(Edge::Left, margin);
2756    window.set_margin(Edge::Right, margin);
2757
2758    // Keyboard focus is `OnDemand`: the compositor grants it when the layer is
2759    // mapped (so launching or showing the panel focuses it) and while the
2760    // pointer moves over it, and the claim is released the moment the pointer
2761    // leaves.
2762    //
2763    // Releasing on pointer-leave matters because a Hyprland layer surface that
2764    // *keeps* keyboard focus does not update the compositor's notion of the
2765    // focused window (`hyprctl activewindow` still reports the previous one).
2766    // A click only triggers a refocus when the clicked window differs from that
2767    // focused window (`CInputManager::processMouseDownNormal`:
2768    // `focusState()->window() != w`), so a panel that holds the claim makes
2769    // clicking the previously-focused window a no-op — keyboard focus never
2770    // returns to it. Dropping the claim kicks the layer from the seat, so the
2771    // next click refocuses the window normally.
2772    window.set_keyboard_mode(KeyboardMode::OnDemand);
2773    let motion = gtk::EventControllerMotion::new();
2774    {
2775        let kb_window = window.clone();
2776        motion.connect_enter(move |_, _, _| kb_window.set_keyboard_mode(KeyboardMode::OnDemand));
2777    }
2778    {
2779        let kb_window = window.clone();
2780        motion.connect_leave(move |_| {
2781            // A row/menu popover is a child surface, so opening it fires a
2782            // pointer-leave for the panel even though the user has not left it.
2783            // Keep the keyboard claim in that case; the compositor returns
2784            // focus to the panel when the popover closes.
2785            if has_visible_popover(kb_window.upcast_ref()) {
2786                return;
2787            }
2788            kb_window.set_keyboard_mode(KeyboardMode::None);
2789        });
2790    }
2791    window.add_controller(motion);
2792}
2793
2794/// Fall back to a plain, decorated, freely-floating window when the compositor
2795/// has no layer-shell support. The panel cannot dock, so it opens at its
2796/// configured width with a normal title bar; the user places it like any other
2797/// window. Announced once so the reason for the un-docked panel is not a
2798/// mystery.
2799fn configure_plain_window(window: &gtk::Window, width: u32) {
2800    static ANNOUNCED: std::sync::OnceLock<()> = std::sync::OnceLock::new();
2801    ANNOUNCED.get_or_init(|| {
2802        eprintln!(
2803            "tree-space: this compositor does not support wlr-layer-shell, so the panel \
2804             cannot dock; opening it as a normal window instead."
2805        );
2806    });
2807    window.set_title(Some("tree-space"));
2808    window.set_default_size(width as i32, 720);
2809    window.set_size_request(width as i32, -1);
2810}
2811
2812/// Whether any visible [`gtk::Popover`] is open under `widget` (walking the
2813/// widget tree). Used to keep keyboard focus on the panel while its menu is up.
2814fn has_visible_popover(widget: &gtk::Widget) -> bool {
2815    if widget.is::<gtk::Popover>() && widget.is_visible() {
2816        return true;
2817    }
2818    let mut child = widget.first_child();
2819    while let Some(current) = child {
2820        if has_visible_popover(&current) {
2821            return true;
2822        }
2823        child = current.next_sibling();
2824    }
2825    false
2826}
2827
2828/// A stable key for a monitor, used to compare docks. Prefers the connector
2829/// name; falls back to model + geometry for backends (often Wayland) that do
2830/// not expose a connector.
2831fn monitor_key(monitor: &gdk::Monitor) -> String {
2832    if let Some(connector) = monitor.connector().filter(|c| !c.is_empty()) {
2833        return connector.to_string();
2834    }
2835    let g = monitor.geometry();
2836    let model = monitor.model().map(|m| m.to_string()).unwrap_or_default();
2837    format!("{model}:{}x{}+{}+{}", g.width(), g.height(), g.x(), g.y())
2838}
2839
2840/// Whether an existing, emptied dock should be given a fresh pane when showing.
2841/// A side-specific show does; a whole-panel show does not, as long as a pane is
2842/// still open somewhere — showing should reveal the existing pane, not spawn a
2843/// new one.
2844fn should_seed_empty(panes_exist: bool, seed: bool, whole_panel: bool) -> bool {
2845    seed && (!whole_panel || !panes_exist)
2846}
2847
2848/// A human label for a monitor, for the drag status line.
2849fn monitor_label(monitor: &gdk::Monitor) -> String {
2850    monitor
2851        .connector()
2852        .filter(|c| !c.is_empty())
2853        .or_else(|| monitor.model())
2854        .map(|name| name.to_string())
2855        .unwrap_or_else(|| "this monitor".to_owned())
2856}
2857
2858fn layer_of(layer: PanelLayer) -> Layer {
2859    use PanelLayer::*;
2860    match layer {
2861        Background => Layer::Background,
2862        Bottom => Layer::Bottom,
2863        Top => Layer::Top,
2864        Overlay => Layer::Overlay,
2865    }
2866}
2867
2868/// Load the user stylesheet (falling back to the shipped default) plus the
2869/// dynamically-sized font rule, which is appended last so `tree.font_size`
2870/// still wins over anything the stylesheet sets.
2871fn install_css(config: &Config) {
2872    let stylesheet = load_stylesheet(&config.theme);
2873    if let Some(problem) = &stylesheet.problem {
2874        eprintln!("tree-space: could not load stylesheet: {problem:?}");
2875    }
2876    crate::highlight::set_palette(crate::theme::syntax_palette(&config.theme));
2877    let css = format!(
2878        "{}\n.tree-row label, .tree-rename-entry, .tree-menu, .hamburger-menu {{ font-size: {}px; }}",
2879        stylesheet.css, config.tree.font_size
2880    );
2881    let provider = gtk::CssProvider::new();
2882    provider.load_from_string(&css);
2883    if let Some(display) = gdk::Display::default() {
2884        gtk::style_context_add_provider_for_display(
2885            &display,
2886            &provider,
2887            gtk::STYLE_PROVIDER_PRIORITY_APPLICATION,
2888        );
2889    }
2890}
2891
2892fn home_dir() -> Option<PathBuf> {
2893    std::env::var_os("HOME").map(PathBuf::from)
2894}
2895
2896/// The root a freshly-seeded dock should show, resolved from the `[startup]`
2897/// config (last-used directory, home, or a fixed path), falling back to home.
2898fn default_root(startup: &StartupRoot) -> Option<PathBuf> {
2899    // A bookmarks launch opens no directory pane; seeding one would defeat it.
2900    if startup.is_bookmarks() {
2901        return None;
2902    }
2903    let last = SessionState::load().last_root.filter(|p| p.is_dir());
2904    startup.resolve(last).filter(|p| p.is_dir()).or_else(home_dir)
2905}
2906
2907/// Translate an index `path` into the tree that remains after the entry at
2908/// `from` is removed. Index paths that diverge from `from` deeper in a different
2909/// subtree are unaffected; a sibling before the removed entry shifts down one.
2910fn adjust_path_after_removal(from: &[usize], path: &[usize]) -> Vec<usize> {
2911    let mut out = path.to_vec();
2912    for level in 0..path.len().min(from.len()) {
2913        if from[level] < path[level] {
2914            out[level] -= 1;
2915            return out;
2916        } else if from[level] > path[level] {
2917            return out;
2918        }
2919    }
2920    out
2921}
2922
2923#[cfg(test)]
2924mod adjust_path_tests {
2925    use super::adjust_path_after_removal;
2926
2927    #[test]
2928    fn adjusts_sibling_indices_after_a_removal() {
2929        // Removing index 0: later siblings shift down.
2930        assert_eq!(adjust_path_after_removal(&[0], &[2]), vec![1]);
2931        // Removing index 2: earlier siblings are unaffected.
2932        assert_eq!(adjust_path_after_removal(&[2], &[0]), vec![0]);
2933        // A deeper path in a sibling subtree shifts at the first level only.
2934        assert_eq!(adjust_path_after_removal(&[0], &[1, 3]), vec![0, 3]);
2935        // An ancestor of the removed node is unaffected.
2936        assert_eq!(adjust_path_after_removal(&[1, 2], &[1]), vec![1]);
2937        // Diverge at the second level: only that index shifts.
2938        assert_eq!(adjust_path_after_removal(&[1, 0], &[1, 2]), vec![1, 1]);
2939    }
2940}
2941
2942#[cfg(test)]
2943mod visibility_tests {
2944    use super::*;
2945
2946    const L: PanelSide = PanelSide::Left;
2947    const R: PanelSide = PanelSide::Right;
2948
2949    fn plan(intent: VisibilityIntent, existing: &[PanelSide], shown: &[PanelSide]) -> VisibilityPlan {
2950        resolve_visibility(intent, existing, shown)
2951    }
2952
2953    #[test]
2954    fn show_all_shows_both() {
2955        let p = plan(VisibilityIntent::ShowAll, &[L], &[]);
2956        // Only docks that already exist are shown; a second one is not created.
2957        assert_eq!(p.show, vec![L]);
2958        assert!(p.seed);
2959        let both = plan(VisibilityIntent::ShowAll, &[L, R], &[]);
2960        assert_eq!(both.show, vec![L, R]);
2961    }
2962
2963    #[test]
2964    fn hide_all_hides_both() {
2965        let p = plan(VisibilityIntent::HideAll, &[L, R], &[L, R]);
2966        assert_eq!(p.hide, vec![L, R]);
2967        assert!(p.show.is_empty());
2968    }
2969
2970    #[test]
2971    fn toggle_all_shows_when_nothing_visible_and_hides_otherwise() {
2972        let shown = plan(VisibilityIntent::ToggleAll, &[L], &[]);
2973        // Toggling all shows the docks that exist (not a newly created side).
2974        assert_eq!(shown.show, vec![L]);
2975        let hidden = plan(VisibilityIntent::ToggleAll, &[L, R], &[L]);
2976        assert_eq!(hidden.hide, vec![L, R]);
2977    }
2978
2979    #[test]
2980    fn show_side_creates_a_missing_dock() {
2981        let p = plan(VisibilityIntent::ShowSide(R), &[L], &[L]);
2982        assert_eq!(p.create, Some(R));
2983        assert_eq!(p.show, vec![R]);
2984    }
2985
2986    #[test]
2987    fn hide_side_never_creates() {
2988        let p = plan(VisibilityIntent::HideSide(R), &[L], &[L]);
2989        assert_eq!(p.create, None);
2990        assert_eq!(p.hide, vec![R]);
2991    }
2992
2993    #[test]
2994    fn toggle_side_is_granular() {
2995        // Missing -> create and show.
2996        let create = plan(VisibilityIntent::ToggleSide(R), &[L], &[L]);
2997        assert_eq!(create.create, Some(R));
2998        assert_eq!(create.show, vec![R]);
2999        // Shown -> hide.
3000        let hide = plan(VisibilityIntent::ToggleSide(L), &[L, R], &[L, R]);
3001        assert_eq!(hide.hide, vec![L]);
3002        // Hidden -> show.
3003        let show = plan(VisibilityIntent::ToggleSide(L), &[L, R], &[R]);
3004        assert_eq!(show.show, vec![L]);
3005    }
3006
3007    #[test]
3008    fn toggling_one_side_leaves_the_other_alone() {
3009        // Left visible, right hidden: toggling right must not name left at all.
3010        let p = plan(VisibilityIntent::ToggleSide(R), &[L, R], &[L]);
3011        assert_eq!(p.show, vec![R]);
3012        assert!(!p.hide.contains(&L));
3013    }
3014
3015    #[test]
3016    fn whole_panel_show_does_not_seed_while_a_pane_is_open() {
3017        // A whole-panel show with a pane still open elsewhere must reveal it,
3018        // not spawn a fresh pane in the emptied dock.
3019        assert!(!should_seed_empty(true, true, true));
3020        // With nothing open anywhere it does seed, or "show" would be empty.
3021        assert!(should_seed_empty(false, true, true));
3022        // A side-specific show always seeds an empty dock it was asked to show.
3023        assert!(should_seed_empty(true, true, false));
3024        // No seed requested is never overridden.
3025        assert!(!should_seed_empty(false, false, false));
3026    }
3027}
3028
3029#[cfg(test)]
3030mod nav_history_tests {
3031    use super::{NavHistory, ViewEntry};
3032    use std::path::PathBuf;
3033
3034    fn dir(s: &str) -> ViewEntry {
3035        ViewEntry::Dir(PathBuf::from(s))
3036    }
3037
3038    #[test]
3039    fn records_visits_in_order_and_navigates_both_ways() {
3040        let mut h = NavHistory::default();
3041        h.record(dir("/a"));
3042        h.record(dir("/b"));
3043        h.record(dir("/c"));
3044        assert!(h.can_back());
3045        assert!(!h.can_forward());
3046
3047        assert_eq!(h.back(), Some(dir("/b")));
3048        h.finish_navigation();
3049        assert_eq!(h.back(), Some(dir("/a")));
3050        h.finish_navigation();
3051        assert!(!h.can_back());
3052        assert!(h.can_forward());
3053
3054        assert_eq!(h.forward(), Some(dir("/b")));
3055        h.finish_navigation();
3056        assert_eq!(h.forward(), Some(dir("/c")));
3057        h.finish_navigation();
3058        assert!(h.can_back());
3059        assert!(!h.can_forward());
3060    }
3061
3062    #[test]
3063    fn a_new_visit_after_going_back_truncates_the_forward_tail() {
3064        let mut h = NavHistory::default();
3065        h.record(dir("/a"));
3066        h.record(dir("/b"));
3067        h.record(dir("/c"));
3068        assert_eq!(h.back(), Some(dir("/b")));
3069        h.finish_navigation();
3070        // Visiting /d from /b drops /c from the forward history.
3071        h.record(dir("/d"));
3072        assert!(!h.can_forward());
3073        assert_eq!(h.back(), Some(dir("/b")));
3074    }
3075
3076    #[test]
3077    fn navigating_does_not_record_and_repeats_are_ignored() {
3078        let mut h = NavHistory::default();
3079        h.record(dir("/a"));
3080        h.record(dir("/b"));
3081        // Back, then the resulting RootChanged; recording must be suppressed.
3082        let target = h.back().unwrap();
3083        assert_eq!(target, dir("/a"));
3084        h.record(target.clone());
3085        h.finish_navigation();
3086        // Still at /a with /b ahead, and no duplicate /a entry was appended.
3087        assert!(h.can_forward());
3088        assert_eq!(h.forward(), Some(dir("/b")));
3089        h.finish_navigation();
3090
3091        // Re-recording the current entry is a no-op.
3092        h.record(dir("/b"));
3093        assert_eq!(h.cursor, 1);
3094        assert_eq!(h.entries.len(), 2);
3095    }
3096
3097    #[test]
3098    fn bookmarks_view_is_a_history_entry() {
3099        let mut h = NavHistory::default();
3100        h.record(ViewEntry::Bookmarks);
3101        h.record(dir("/a"));
3102        // From /a, back goes to the bookmarks view.
3103        assert_eq!(h.back(), Some(ViewEntry::Bookmarks));
3104        h.finish_navigation();
3105        // Re-recording the view we are already on is a no-op.
3106        h.record(ViewEntry::Bookmarks);
3107        assert_eq!(h.entries.len(), 2);
3108        // And forward returns to the directory.
3109        assert_eq!(h.forward(), Some(dir("/a")));
3110        h.finish_navigation();
3111    }
3112
3113    #[test]
3114    fn cannot_navigate_an_empty_history() {
3115        let mut h = NavHistory::default();
3116        assert!(!h.can_back());
3117        assert!(!h.can_forward());
3118        assert_eq!(h.back(), None);
3119        assert_eq!(h.forward(), None);
3120    }
3121}