Skip to main content

dear_file_browser/ui/
mod.rs

1use std::path::PathBuf;
2
3use crate::core::{DialogMode, FileDialogError, LayoutStyle, Selection};
4use crate::custom_pane::{CustomPane, CustomPaneCtx};
5use crate::dialog_core::{ConfirmGate, CoreEvent};
6use crate::dialog_state::CustomPaneDock;
7use crate::dialog_state::{FileDialogState, FileListViewMode};
8use crate::fs::{FileSystem, StdFileSystem};
9use crate::thumbnails::ThumbnailBackend;
10use dear_imgui_rs::Ui;
11use dear_imgui_rs::input::MouseCursor;
12use dear_imgui_rs::sys;
13
14mod file_table;
15mod footer;
16mod header;
17mod igfd_path_popup;
18mod ops;
19mod path_bar;
20mod places;
21mod popups;
22
23/// Configuration for hosting the file browser in an ImGui window.
24#[derive(Clone, Debug)]
25pub struct WindowHostConfig {
26    /// Window title
27    pub title: String,
28    /// Initial window size (used with `size_condition`)
29    pub initial_size: [f32; 2],
30    /// Condition used when setting the window size
31    pub size_condition: dear_imgui_rs::Condition,
32    /// Optional minimum size constraint.
33    pub min_size: Option<[f32; 2]>,
34    /// Optional maximum size constraint.
35    pub max_size: Option<[f32; 2]>,
36}
37
38impl WindowHostConfig {
39    /// Default window host configuration for the given dialog mode.
40    pub fn for_mode(mode: DialogMode) -> Self {
41        let title = match mode {
42            DialogMode::OpenFile | DialogMode::OpenFiles => "Open",
43            DialogMode::PickFolder => "Select Folder",
44            DialogMode::SaveFile => "Save",
45        };
46        Self {
47            title: title.to_string(),
48            initial_size: [760.0, 520.0],
49            size_condition: dear_imgui_rs::Condition::FirstUseEver,
50            min_size: None,
51            max_size: None,
52        }
53    }
54}
55
56/// Configuration for hosting the file browser in an ImGui modal popup.
57///
58/// `popup_label` must be stable across frames. For multiple concurrent dialogs,
59/// ensure the label includes a unique ID suffix (ImGui `###` syntax is fine).
60#[derive(Clone, Debug)]
61pub struct ModalHostConfig {
62    /// Modal popup label/title (supports `###` id suffix).
63    pub popup_label: String,
64    /// Initial modal size (used with `size_condition`).
65    pub initial_size: [f32; 2],
66    /// Condition used when setting the popup size.
67    pub size_condition: dear_imgui_rs::Condition,
68    /// Optional minimum size constraint.
69    pub min_size: Option<[f32; 2]>,
70    /// Optional maximum size constraint.
71    pub max_size: Option<[f32; 2]>,
72}
73
74impl ModalHostConfig {
75    /// Default modal host configuration for the given dialog mode.
76    pub fn for_mode(mode: DialogMode) -> Self {
77        let title = match mode {
78            DialogMode::OpenFile | DialogMode::OpenFiles => "Open",
79            DialogMode::PickFolder => "Select Folder",
80            DialogMode::SaveFile => "Save",
81        };
82        Self {
83            popup_label: format!("{title}###FileBrowserModal"),
84            initial_size: [760.0, 520.0],
85            size_condition: dear_imgui_rs::Condition::FirstUseEver,
86            min_size: None,
87            max_size: None,
88        }
89    }
90}
91
92/// UI handle for file browser
93pub struct FileBrowser<'ui> {
94    pub ui: &'ui Ui,
95}
96
97/// Extend Ui with a file browser entry point
98pub trait FileDialogExt {
99    /// Entry point for showing the file browser widget
100    fn file_browser(&self) -> FileBrowser<'_>;
101}
102
103impl FileDialogExt for Ui {
104    fn file_browser(&self) -> FileBrowser<'_> {
105        FileBrowser { ui: self }
106    }
107}
108
109impl<'ui> FileBrowser<'ui> {
110    /// Draw only the contents of the file browser (no window/modal host).
111    ///
112    /// This is useful for embedding the browser into an existing window, popup,
113    /// tab, or child region managed by the caller.
114    ///
115    /// Returns Some(result) once the user confirms/cancels; None otherwise.
116    pub fn draw_contents(
117        &self,
118        state: &mut FileDialogState,
119    ) -> Option<Result<Selection, FileDialogError>> {
120        self.draw_contents_with(state, &StdFileSystem, None, None)
121    }
122
123    /// Draw only the contents of the file browser (no window/modal host) with explicit hooks.
124    ///
125    /// - `fs`: filesystem backend used by core operations.
126    /// - `custom_pane`: optional custom pane that can render extra UI and block confirm.
127    /// - `thumbnails_backend`: optional backend for thumbnail decode/upload lifecycle.
128    pub fn draw_contents_with(
129        &self,
130        state: &mut FileDialogState,
131        fs: &dyn FileSystem,
132        mut custom_pane: Option<&mut dyn CustomPane>,
133        mut thumbnails_backend: Option<&mut ThumbnailBackend<'_>>,
134    ) -> Option<Result<Selection, FileDialogError>> {
135        draw_contents_with_fs_and_hooks(
136            self.ui,
137            state,
138            fs,
139            custom_pane.take(),
140            thumbnails_backend.take(),
141        )
142    }
143
144    /// Draw the file browser in a standard ImGui window with default host config.
145    /// Returns Some(result) once the user confirms/cancels; None otherwise.
146    pub fn show(&self, state: &mut FileDialogState) -> Option<Result<Selection, FileDialogError>> {
147        let cfg = WindowHostConfig::for_mode(state.core.mode);
148        self.show_windowed(state, &cfg)
149    }
150
151    /// Draw the file browser in a standard ImGui window using the given host configuration.
152    /// Returns Some(result) once the user confirms/cancels; None otherwise.
153    pub fn show_windowed(
154        &self,
155        state: &mut FileDialogState,
156        cfg: &WindowHostConfig,
157    ) -> Option<Result<Selection, FileDialogError>> {
158        self.show_windowed_with(state, cfg, &StdFileSystem, None, None)
159    }
160
161    /// Draw the file browser in a standard ImGui window with explicit hooks.
162    pub fn show_windowed_with(
163        &self,
164        state: &mut FileDialogState,
165        cfg: &WindowHostConfig,
166        fs: &dyn FileSystem,
167        mut custom_pane: Option<&mut dyn CustomPane>,
168        mut thumbnails_backend: Option<&mut ThumbnailBackend<'_>>,
169    ) -> Option<Result<Selection, FileDialogError>> {
170        if !state.ui.visible {
171            return None;
172        }
173
174        let mut out: Option<Result<Selection, FileDialogError>> = None;
175        let mut window = self
176            .ui
177            .window(&cfg.title)
178            .size(cfg.initial_size, cfg.size_condition);
179        if let Some((min_size, max_size)) =
180            resolve_host_size_constraints(cfg.min_size, cfg.max_size)
181        {
182            window = window.size_constraints(min_size, max_size);
183        }
184        window.build(|| {
185            out = draw_contents_with_fs_and_hooks(
186                self.ui,
187                state,
188                fs,
189                custom_pane.take(),
190                thumbnails_backend.take(),
191            );
192        });
193        out
194    }
195
196    /// Draw the file browser in an ImGui modal popup with default host config.
197    /// Returns Some(result) once the user confirms/cancels; None otherwise.
198    pub fn show_modal(
199        &self,
200        state: &mut FileDialogState,
201    ) -> Option<Result<Selection, FileDialogError>> {
202        let cfg = ModalHostConfig::for_mode(state.core.mode);
203        self.show_modal_with(state, &cfg, &StdFileSystem, None, None)
204    }
205
206    /// Draw the file browser in an ImGui modal popup with explicit hooks.
207    pub fn show_modal_with(
208        &self,
209        state: &mut FileDialogState,
210        cfg: &ModalHostConfig,
211        fs: &dyn FileSystem,
212        mut custom_pane: Option<&mut dyn CustomPane>,
213        mut thumbnails_backend: Option<&mut ThumbnailBackend<'_>>,
214    ) -> Option<Result<Selection, FileDialogError>> {
215        if !state.ui.visible {
216            return None;
217        }
218
219        if !self.ui.is_popup_open(&cfg.popup_label) {
220            self.ui.open_popup(&cfg.popup_label);
221        }
222
223        if let Some((min_size, max_size)) =
224            resolve_host_size_constraints(cfg.min_size, cfg.max_size)
225        {
226            unsafe {
227                let min_vec = sys::ImVec2_c {
228                    x: min_size[0],
229                    y: min_size[1],
230                };
231                let max_vec = sys::ImVec2_c {
232                    x: max_size[0],
233                    y: max_size[1],
234                };
235                sys::igSetNextWindowSizeConstraints(min_vec, max_vec, None, std::ptr::null_mut());
236            }
237        }
238
239        unsafe {
240            let size_vec = sys::ImVec2 {
241                x: cfg.initial_size[0],
242                y: cfg.initial_size[1],
243            };
244            sys::igSetNextWindowSize(size_vec, cfg.size_condition as i32);
245        }
246
247        let Some(_popup) = self.ui.begin_modal_popup(&cfg.popup_label) else {
248            return None;
249        };
250
251        let out = draw_contents_with_fs_and_hooks(
252            self.ui,
253            state,
254            fs,
255            custom_pane.take(),
256            thumbnails_backend.take(),
257        );
258        if out.is_some() {
259            self.ui.close_current_popup();
260        }
261        out
262    }
263}
264
265fn resolve_host_size_constraints(
266    min_size: Option<[f32; 2]>,
267    max_size: Option<[f32; 2]>,
268) -> Option<([f32; 2], [f32; 2])> {
269    if min_size.is_none() && max_size.is_none() {
270        return None;
271    }
272
273    let sanitize = |value: f32, fallback: f32| -> f32 {
274        if value.is_finite() {
275            value.max(0.0)
276        } else {
277            fallback
278        }
279    };
280
281    let mut min = min_size.unwrap_or([0.0, 0.0]);
282    min[0] = sanitize(min[0], 0.0);
283    min[1] = sanitize(min[1], 0.0);
284
285    let mut max = max_size.unwrap_or([f32::MAX, f32::MAX]);
286    max[0] = sanitize(max[0], f32::MAX);
287    max[1] = sanitize(max[1], f32::MAX);
288
289    max[0] = max[0].max(min[0]);
290    max[1] = max[1].max(min[1]);
291
292    Some((min, max))
293}
294
295fn draw_contents_with_fs_and_hooks(
296    ui: &Ui,
297    state: &mut FileDialogState,
298    fs: &dyn FileSystem,
299    mut custom_pane: Option<&mut dyn CustomPane>,
300    mut thumbnails_backend: Option<&mut ThumbnailBackend<'_>>,
301) -> Option<Result<Selection, FileDialogError>> {
302    if !state.ui.visible {
303        return None;
304    }
305
306    // Make all widget IDs inside this browser instance unique, even when embedding
307    // multiple dialogs in the same host window. This avoids ImGui "conflicting ID"
308    // warnings for internal child windows/popups/tooltips.
309    let _dialog_id_scope = ui.push_id(state as *mut FileDialogState);
310
311    let has_thumbnail_backend = thumbnails_backend.is_some();
312    let mut request_confirm = false;
313    let mut confirm_gate = ConfirmGate::default();
314
315    header::draw_chrome(ui, state, fs, has_thumbnail_backend);
316
317    // Content region
318    let avail = ui.content_region_avail();
319    let footer_h = state
320        .ui
321        .runtime
322        .footer
323        .height_last
324        .max(footer::estimate_footer_height(ui, state));
325    let content_h = (avail[1] - footer_h).max(0.0);
326    match state.ui.config.layout {
327        LayoutStyle::Standard => {
328            if state.ui.config.places_pane_shown {
329                const MIN_PLACES_W: f32 = 120.0;
330                const MIN_FILE_LIST_W: f32 = 180.0;
331
332                let splitter_w = splitter_width(ui);
333                let spacing_x = ui.clone_style().item_spacing()[0];
334                let max_places_w =
335                    (avail[0] - MIN_FILE_LIST_W - splitter_w - spacing_x * 2.0).max(0.0);
336                let mut places_w = state.ui.config.places_pane_width.clamp(0.0, max_places_w);
337                if max_places_w >= MIN_PLACES_W {
338                    places_w = places_w.clamp(MIN_PLACES_W, max_places_w);
339                }
340                let file_w = (avail[0] - places_w - splitter_w - spacing_x * 2.0).max(0.0);
341
342                let mut new_cwd: Option<PathBuf> = None;
343                ui.child_window("places_pane")
344                    .size([places_w, content_h])
345                    .border(true)
346                    .build(ui, || {
347                        new_cwd = places::draw_places_pane(ui, state);
348                    });
349                if let Some(p) = new_cwd {
350                    let _ = state.core.handle_event(CoreEvent::NavigateTo(p));
351                }
352
353                ui.same_line();
354                ui.invisible_button("places_pane_splitter", [splitter_w, content_h]);
355                if ui.is_item_hovered() || ui.is_item_active() {
356                    ui.set_mouse_cursor(Some(MouseCursor::ResizeEW));
357                }
358                if ui.is_item_active() {
359                    let dx = ui.io().mouse_delta()[0];
360                    let new_w = (places_w + dx).clamp(0.0, max_places_w);
361                    state.ui.config.places_pane_width = if max_places_w >= MIN_PLACES_W {
362                        new_w.clamp(MIN_PLACES_W, max_places_w)
363                    } else {
364                        new_w
365                    };
366                }
367
368                ui.same_line();
369                ui.child_window("file_list")
370                    .size([file_w, content_h])
371                    .build(ui, || {
372                        let inner = ui.content_region_avail();
373                        let show_pane = state.ui.config.custom_pane_enabled
374                            && custom_pane.as_deref_mut().is_some();
375                        if !show_pane {
376                            file_table::draw_file_table(
377                                ui,
378                                state,
379                                [inner[0], inner[1]],
380                                fs,
381                                &mut request_confirm,
382                                thumbnails_backend.as_deref_mut(),
383                            );
384                            return;
385                        }
386
387                        match state.ui.config.custom_pane_dock {
388                            CustomPaneDock::Bottom => {
389                                let style = ui.clone_style();
390                                let sep_h = style.item_spacing()[1] * 2.0 + 1.0;
391                                let pane_h = state
392                                    .ui
393                                    .config
394                                    .custom_pane_height
395                                    .clamp(0.0, inner[1].max(0.0));
396                                let mut table_h = inner[1];
397                                if pane_h > 0.0 {
398                                    table_h = (table_h - pane_h - sep_h).max(0.0);
399                                }
400
401                                file_table::draw_file_table(
402                                    ui,
403                                    state,
404                                    [inner[0], table_h],
405                                    fs,
406                                    &mut request_confirm,
407                                    thumbnails_backend.as_deref_mut(),
408                                );
409
410                                if let Some(pane) = custom_pane.as_deref_mut() {
411                                    if state.ui.config.custom_pane_enabled && pane_h > 0.0 {
412                                        ui.separator();
413                                        ui.child_window("custom_pane")
414                                            .size([inner[0], pane_h])
415                                            .border(true)
416                                            .build(ui, || {
417                                                let selected_entry_ids =
418                                                    state.core.selected_entry_ids();
419                                                let selected_paths =
420                                                    ops::selected_entry_paths_from_ids(state);
421                                                let (selected_files_count, selected_dirs_count) =
422                                                    ops::selected_entry_counts_from_ids(state);
423                                                let ctx = CustomPaneCtx {
424                                                    mode: state.core.mode,
425                                                    cwd: &state.core.cwd,
426                                                    selected_entry_ids: &selected_entry_ids,
427                                                    selected_paths: &selected_paths,
428                                                    selected_files_count,
429                                                    selected_dirs_count,
430                                                    save_name: &state.core.save_name,
431                                                    active_filter: state.core.active_filter(),
432                                                };
433                                                confirm_gate = pane.draw(ui, ctx);
434                                            });
435                                    }
436                                }
437                            }
438                            CustomPaneDock::Right => {
439                                const MIN_TABLE_W: f32 = 120.0;
440                                const MIN_PANE_W: f32 = 120.0;
441
442                                let splitter_w = splitter_width(ui);
443                                let max_pane_w = (inner[0] - MIN_TABLE_W - splitter_w).max(0.0);
444                                let mut pane_w =
445                                    state.ui.config.custom_pane_width.clamp(0.0, max_pane_w);
446                                if max_pane_w >= MIN_PANE_W {
447                                    pane_w = pane_w.clamp(MIN_PANE_W, max_pane_w);
448                                }
449
450                                let table_w = (inner[0] - pane_w - splitter_w).max(0.0);
451
452                                ui.child_window("file_table_rightdock")
453                                    .size([table_w, inner[1]])
454                                    .build(ui, || {
455                                        file_table::draw_file_table(
456                                            ui,
457                                            state,
458                                            [table_w, inner[1]],
459                                            fs,
460                                            &mut request_confirm,
461                                            thumbnails_backend.as_deref_mut(),
462                                        );
463                                    });
464
465                                ui.same_line();
466                                ui.invisible_button("custom_pane_splitter", [splitter_w, inner[1]]);
467                                if ui.is_item_hovered() || ui.is_item_active() {
468                                    ui.set_mouse_cursor(Some(MouseCursor::ResizeEW));
469                                }
470                                if ui.is_item_active() {
471                                    let dx = ui.io().mouse_delta()[0];
472                                    let new_w = (pane_w - dx).clamp(0.0, max_pane_w);
473                                    state.ui.config.custom_pane_width = if max_pane_w >= MIN_PANE_W
474                                    {
475                                        new_w.clamp(MIN_PANE_W, max_pane_w)
476                                    } else {
477                                        new_w
478                                    };
479                                }
480
481                                ui.same_line();
482                                ui.child_window("custom_pane_rightdock")
483                                    .size([pane_w, inner[1]])
484                                    .border(true)
485                                    .build(ui, || {
486                                        if let Some(pane) = custom_pane.as_deref_mut() {
487                                            let selected_entry_ids =
488                                                state.core.selected_entry_ids();
489                                            let selected_paths =
490                                                ops::selected_entry_paths_from_ids(state);
491                                            let (selected_files_count, selected_dirs_count) =
492                                                ops::selected_entry_counts_from_ids(state);
493                                            let ctx = CustomPaneCtx {
494                                                mode: state.core.mode,
495                                                cwd: &state.core.cwd,
496                                                selected_entry_ids: &selected_entry_ids,
497                                                selected_paths: &selected_paths,
498                                                selected_files_count,
499                                                selected_dirs_count,
500                                                save_name: &state.core.save_name,
501                                                active_filter: state.core.active_filter(),
502                                            };
503                                            confirm_gate = pane.draw(ui, ctx);
504                                        }
505                                    });
506                            }
507                        }
508                    });
509            } else {
510                ui.child_window("file_list")
511                    .size([avail[0], content_h])
512                    .build(ui, || {
513                        let inner = ui.content_region_avail();
514                        let show_pane = state.ui.config.custom_pane_enabled
515                            && custom_pane.as_deref_mut().is_some();
516                        if !show_pane {
517                            file_table::draw_file_table(
518                                ui,
519                                state,
520                                [inner[0], inner[1]],
521                                fs,
522                                &mut request_confirm,
523                                thumbnails_backend.as_deref_mut(),
524                            );
525                            return;
526                        }
527
528                        match state.ui.config.custom_pane_dock {
529                            CustomPaneDock::Bottom => {
530                                let style = ui.clone_style();
531                                let sep_h = style.item_spacing()[1] * 2.0 + 1.0;
532                                let pane_h = state
533                                    .ui
534                                    .config
535                                    .custom_pane_height
536                                    .clamp(0.0, inner[1].max(0.0));
537                                let mut table_h = inner[1];
538                                if pane_h > 0.0 {
539                                    table_h = (table_h - pane_h - sep_h).max(0.0);
540                                }
541
542                                file_table::draw_file_table(
543                                    ui,
544                                    state,
545                                    [inner[0], table_h],
546                                    fs,
547                                    &mut request_confirm,
548                                    thumbnails_backend.as_deref_mut(),
549                                );
550
551                                if let Some(pane) = custom_pane.as_deref_mut() {
552                                    if state.ui.config.custom_pane_enabled && pane_h > 0.0 {
553                                        ui.separator();
554                                        ui.child_window("custom_pane")
555                                            .size([inner[0], pane_h])
556                                            .border(true)
557                                            .build(ui, || {
558                                                let selected_entry_ids =
559                                                    state.core.selected_entry_ids();
560                                                let selected_paths =
561                                                    ops::selected_entry_paths_from_ids(state);
562                                                let (selected_files_count, selected_dirs_count) =
563                                                    ops::selected_entry_counts_from_ids(state);
564                                                let ctx = CustomPaneCtx {
565                                                    mode: state.core.mode,
566                                                    cwd: &state.core.cwd,
567                                                    selected_entry_ids: &selected_entry_ids,
568                                                    selected_paths: &selected_paths,
569                                                    selected_files_count,
570                                                    selected_dirs_count,
571                                                    save_name: &state.core.save_name,
572                                                    active_filter: state.core.active_filter(),
573                                                };
574                                                confirm_gate = pane.draw(ui, ctx);
575                                            });
576                                    }
577                                }
578                            }
579                            CustomPaneDock::Right => {
580                                const MIN_TABLE_W: f32 = 120.0;
581                                const MIN_PANE_W: f32 = 120.0;
582
583                                let splitter_w = splitter_width(ui);
584                                let max_pane_w = (inner[0] - MIN_TABLE_W - splitter_w).max(0.0);
585                                let mut pane_w =
586                                    state.ui.config.custom_pane_width.clamp(0.0, max_pane_w);
587                                if max_pane_w >= MIN_PANE_W {
588                                    pane_w = pane_w.clamp(MIN_PANE_W, max_pane_w);
589                                }
590
591                                let table_w = (inner[0] - pane_w - splitter_w).max(0.0);
592
593                                ui.child_window("file_table_rightdock")
594                                    .size([table_w, inner[1]])
595                                    .build(ui, || {
596                                        file_table::draw_file_table(
597                                            ui,
598                                            state,
599                                            [table_w, inner[1]],
600                                            fs,
601                                            &mut request_confirm,
602                                            thumbnails_backend.as_deref_mut(),
603                                        );
604                                    });
605
606                                ui.same_line();
607                                ui.invisible_button("custom_pane_splitter", [splitter_w, inner[1]]);
608                                if ui.is_item_hovered() || ui.is_item_active() {
609                                    ui.set_mouse_cursor(Some(MouseCursor::ResizeEW));
610                                }
611                                if ui.is_item_active() {
612                                    let dx = ui.io().mouse_delta()[0];
613                                    let new_w = (pane_w - dx).clamp(0.0, max_pane_w);
614                                    state.ui.config.custom_pane_width = if max_pane_w >= MIN_PANE_W
615                                    {
616                                        new_w.clamp(MIN_PANE_W, max_pane_w)
617                                    } else {
618                                        new_w
619                                    };
620                                }
621
622                                ui.same_line();
623                                ui.child_window("custom_pane_rightdock")
624                                    .size([pane_w, inner[1]])
625                                    .border(true)
626                                    .build(ui, || {
627                                        if let Some(pane) = custom_pane.as_deref_mut() {
628                                            let selected_entry_ids =
629                                                state.core.selected_entry_ids();
630                                            let selected_paths =
631                                                ops::selected_entry_paths_from_ids(state);
632                                            let (selected_files_count, selected_dirs_count) =
633                                                ops::selected_entry_counts_from_ids(state);
634                                            let ctx = CustomPaneCtx {
635                                                mode: state.core.mode,
636                                                cwd: &state.core.cwd,
637                                                selected_entry_ids: &selected_entry_ids,
638                                                selected_paths: &selected_paths,
639                                                selected_files_count,
640                                                selected_dirs_count,
641                                                save_name: &state.core.save_name,
642                                                active_filter: state.core.active_filter(),
643                                            };
644                                            confirm_gate = pane.draw(ui, ctx);
645                                        }
646                                    });
647                            }
648                        }
649                    });
650            }
651        }
652        LayoutStyle::Minimal => {
653            ui.child_window("file_list_min")
654                .size([avail[0], content_h])
655                .build(ui, || {
656                    let inner = ui.content_region_avail();
657                    let show_pane =
658                        state.ui.config.custom_pane_enabled && custom_pane.as_deref_mut().is_some();
659                    if !show_pane {
660                        file_table::draw_file_table(
661                            ui,
662                            state,
663                            [inner[0], inner[1]],
664                            fs,
665                            &mut request_confirm,
666                            thumbnails_backend.as_deref_mut(),
667                        );
668                        return;
669                    }
670
671                    match state.ui.config.custom_pane_dock {
672                        CustomPaneDock::Bottom => {
673                            let style = ui.clone_style();
674                            let sep_h = style.item_spacing()[1] * 2.0 + 1.0;
675                            let pane_h = state
676                                .ui
677                                .config
678                                .custom_pane_height
679                                .clamp(0.0, inner[1].max(0.0));
680                            let mut table_h = inner[1];
681                            if pane_h > 0.0 {
682                                table_h = (table_h - pane_h - sep_h).max(0.0);
683                            }
684
685                            file_table::draw_file_table(
686                                ui,
687                                state,
688                                [inner[0], table_h],
689                                fs,
690                                &mut request_confirm,
691                                thumbnails_backend.as_deref_mut(),
692                            );
693
694                            if let Some(pane) = custom_pane.as_deref_mut() {
695                                if state.ui.config.custom_pane_enabled && pane_h > 0.0 {
696                                    ui.separator();
697                                    ui.child_window("custom_pane")
698                                        .size([inner[0], pane_h])
699                                        .border(true)
700                                        .build(ui, || {
701                                            let selected_entry_ids =
702                                                state.core.selected_entry_ids();
703                                            let selected_paths =
704                                                ops::selected_entry_paths_from_ids(state);
705                                            let (selected_files_count, selected_dirs_count) =
706                                                ops::selected_entry_counts_from_ids(state);
707                                            let ctx = CustomPaneCtx {
708                                                mode: state.core.mode,
709                                                cwd: &state.core.cwd,
710                                                selected_entry_ids: &selected_entry_ids,
711                                                selected_paths: &selected_paths,
712                                                selected_files_count,
713                                                selected_dirs_count,
714                                                save_name: &state.core.save_name,
715                                                active_filter: state.core.active_filter(),
716                                            };
717                                            confirm_gate = pane.draw(ui, ctx);
718                                        });
719                                }
720                            }
721                        }
722                        CustomPaneDock::Right => {
723                            const MIN_TABLE_W: f32 = 120.0;
724                            const MIN_PANE_W: f32 = 120.0;
725
726                            let splitter_w = splitter_width(ui);
727                            let max_pane_w = (inner[0] - MIN_TABLE_W - splitter_w).max(0.0);
728                            let mut pane_w =
729                                state.ui.config.custom_pane_width.clamp(0.0, max_pane_w);
730                            if max_pane_w >= MIN_PANE_W {
731                                pane_w = pane_w.clamp(MIN_PANE_W, max_pane_w);
732                            }
733
734                            let table_w = (inner[0] - pane_w - splitter_w).max(0.0);
735
736                            ui.child_window("file_table_rightdock")
737                                .size([table_w, inner[1]])
738                                .build(ui, || {
739                                    file_table::draw_file_table(
740                                        ui,
741                                        state,
742                                        [table_w, inner[1]],
743                                        fs,
744                                        &mut request_confirm,
745                                        thumbnails_backend.as_deref_mut(),
746                                    );
747                                });
748
749                            ui.same_line();
750                            ui.invisible_button("custom_pane_splitter", [splitter_w, inner[1]]);
751                            if ui.is_item_hovered() || ui.is_item_active() {
752                                ui.set_mouse_cursor(Some(MouseCursor::ResizeEW));
753                            }
754                            if ui.is_item_active() {
755                                let dx = ui.io().mouse_delta()[0];
756                                let new_w = (pane_w - dx).clamp(0.0, max_pane_w);
757                                state.ui.config.custom_pane_width = if max_pane_w >= MIN_PANE_W {
758                                    new_w.clamp(MIN_PANE_W, max_pane_w)
759                                } else {
760                                    new_w
761                                };
762                            }
763
764                            ui.same_line();
765                            ui.child_window("custom_pane_rightdock")
766                                .size([pane_w, inner[1]])
767                                .border(true)
768                                .build(ui, || {
769                                    if let Some(pane) = custom_pane.as_deref_mut() {
770                                        let selected_entry_ids = state.core.selected_entry_ids();
771                                        let selected_paths =
772                                            ops::selected_entry_paths_from_ids(state);
773                                        let (selected_files_count, selected_dirs_count) =
774                                            ops::selected_entry_counts_from_ids(state);
775                                        let ctx = CustomPaneCtx {
776                                            mode: state.core.mode,
777                                            cwd: &state.core.cwd,
778                                            selected_entry_ids: &selected_entry_ids,
779                                            selected_paths: &selected_paths,
780                                            selected_files_count,
781                                            selected_dirs_count,
782                                            save_name: &state.core.save_name,
783                                            active_filter: state.core.active_filter(),
784                                        };
785                                        confirm_gate = pane.draw(ui, ctx);
786                                    }
787                                });
788                        }
789                    }
790                });
791        }
792    }
793
794    // IGFD-style quick path selection popup (opened from breadcrumb separators).
795    if let Some(p) = igfd_path_popup::draw_igfd_path_popup(ui, state, fs, [avail[0], content_h]) {
796        let _ = state.core.handle_event(CoreEvent::NavigateTo(p));
797    }
798
799    places::draw_minimal_places_popup(ui, state);
800    popups::draw_columns_popup(ui, state, has_thumbnail_backend);
801    popups::draw_options_popup(ui, state, has_thumbnail_backend);
802
803    places::draw_places_io_modal(ui, state);
804    places::draw_places_edit_modal(ui, state, fs);
805    popups::draw_new_folder_modal(ui, state, fs);
806    popups::draw_rename_modal(ui, state, fs);
807    popups::draw_delete_confirm_modal(ui, state, fs);
808    popups::draw_paste_conflict_modal(ui, state, fs);
809
810    footer::draw_footer(ui, state, fs, &confirm_gate, &mut request_confirm);
811
812    let out = state.core.take_result();
813    if out.is_some() {
814        state.close();
815    }
816    out
817}
818
819fn splitter_width(ui: &Ui) -> f32 {
820    // Match IGFD's typical splitter thickness (~4px) but keep it relative to current style.
821    let w = ui.frame_height() * 0.25;
822    w.clamp(4.0, 10.0)
823}
824
825pub(in crate::ui) fn apply_file_list_view_from_ui(
826    state: &mut FileDialogState,
827    view: FileListViewMode,
828    has_thumbnail_backend: bool,
829) -> bool {
830    match view {
831        FileListViewMode::List => {
832            state.ui.config.file_list_view = FileListViewMode::List;
833            true
834        }
835        FileListViewMode::ThumbnailsList => {
836            if !has_thumbnail_backend {
837                return false;
838            }
839            state.ui.config.file_list_view = FileListViewMode::ThumbnailsList;
840            state.ui.config.thumbnails_enabled = true;
841            state.ui.config.file_list_columns.show_preview = true;
842            true
843        }
844        FileListViewMode::Grid => {
845            if !has_thumbnail_backend {
846                return false;
847            }
848            state.ui.config.file_list_view = FileListViewMode::Grid;
849            state.ui.config.thumbnails_enabled = true;
850            true
851        }
852    }
853}
854
855#[cfg(test)]
856mod tests {
857    use super::file_table::{ListColumnLayout, list_column_layout, merged_order_with_current};
858    use super::ops::{open_delete_modal_from_selection, open_rename_modal_from_selection};
859    use super::{apply_file_list_view_from_ui, resolve_host_size_constraints};
860    use crate::core::DialogMode;
861    use crate::dialog_core::EntryId;
862    use crate::dialog_state::{
863        FileDialogState, FileListColumnWeightOverrides, FileListColumnsConfig, FileListDataColumn,
864        FileListViewMode,
865    };
866    use crate::fs::{FileSystem, FsEntry, FsMetadata};
867    use dear_imgui_rs::TableColumnIndex;
868    use std::path::{Path, PathBuf};
869
870    fn columns_config(
871        show_size: bool,
872        show_modified: bool,
873        order: [FileListDataColumn; 4],
874    ) -> FileListColumnsConfig {
875        FileListColumnsConfig {
876            show_size,
877            show_modified,
878            order,
879            ..FileListColumnsConfig::default()
880        }
881    }
882
883    #[test]
884    fn resolve_host_size_constraints_returns_none_when_unset() {
885        assert!(resolve_host_size_constraints(None, None).is_none());
886    }
887
888    #[test]
889    fn resolve_host_size_constraints_supports_one_sided_values() {
890        let (min, max) = resolve_host_size_constraints(Some([200.0, 150.0]), None).unwrap();
891        assert_eq!(min, [200.0, 150.0]);
892        assert_eq!(max, [f32::MAX, f32::MAX]);
893
894        let (min, max) = resolve_host_size_constraints(None, Some([900.0, 700.0])).unwrap();
895        assert_eq!(min, [0.0, 0.0]);
896        assert_eq!(max, [900.0, 700.0]);
897    }
898
899    #[test]
900    fn resolve_host_size_constraints_normalizes_invalid_values() {
901        let (min, max) =
902            resolve_host_size_constraints(Some([300.0, f32::NAN]), Some([100.0, f32::INFINITY]))
903                .unwrap();
904        assert_eq!(min, [300.0, 0.0]);
905        assert_eq!(max, [300.0, f32::MAX]);
906    }
907
908    #[derive(Clone, Default)]
909    struct UiTestFs {
910        entries: Vec<FsEntry>,
911    }
912
913    impl FileSystem for UiTestFs {
914        fn read_dir(&self, _dir: &Path) -> std::io::Result<Vec<FsEntry>> {
915            Ok(self.entries.clone())
916        }
917
918        fn canonicalize(&self, path: &Path) -> std::io::Result<PathBuf> {
919            Ok(path.to_path_buf())
920        }
921
922        fn metadata(&self, path: &Path) -> std::io::Result<FsMetadata> {
923            self.entries
924                .iter()
925                .find(|entry| entry.path == path)
926                .map(|entry| FsMetadata {
927                    is_dir: entry.is_dir,
928                    is_symlink: entry.is_symlink,
929                })
930                .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "not found"))
931        }
932
933        fn create_dir(&self, _path: &Path) -> std::io::Result<()> {
934            Err(std::io::Error::new(
935                std::io::ErrorKind::Unsupported,
936                "create_dir not supported in UiTestFs",
937            ))
938        }
939
940        fn rename(&self, _from: &Path, _to: &Path) -> std::io::Result<()> {
941            Err(std::io::Error::new(
942                std::io::ErrorKind::Unsupported,
943                "rename not supported in UiTestFs",
944            ))
945        }
946
947        fn remove_file(&self, _path: &Path) -> std::io::Result<()> {
948            Err(std::io::Error::new(
949                std::io::ErrorKind::Unsupported,
950                "remove_file not supported in UiTestFs",
951            ))
952        }
953
954        fn remove_dir(&self, _path: &Path) -> std::io::Result<()> {
955            Err(std::io::Error::new(
956                std::io::ErrorKind::Unsupported,
957                "remove_dir not supported in UiTestFs",
958            ))
959        }
960
961        fn remove_dir_all(&self, _path: &Path) -> std::io::Result<()> {
962            Err(std::io::Error::new(
963                std::io::ErrorKind::Unsupported,
964                "remove_dir_all not supported in UiTestFs",
965            ))
966        }
967
968        fn copy_file(&self, _from: &Path, _to: &Path) -> std::io::Result<u64> {
969            Err(std::io::Error::new(
970                std::io::ErrorKind::Unsupported,
971                "copy_file not supported in UiTestFs",
972            ))
973        }
974    }
975
976    fn file_entry(path: &str) -> FsEntry {
977        let path = PathBuf::from(path);
978        let name = path
979            .file_name()
980            .and_then(|name| name.to_str())
981            .unwrap_or(path.as_os_str().to_string_lossy().as_ref())
982            .to_string();
983        FsEntry {
984            name,
985            path,
986            is_dir: false,
987            is_symlink: false,
988            size: None,
989            modified: None,
990        }
991    }
992    #[test]
993    fn list_column_layout_all_columns_visible_without_preview() {
994        let cfg = columns_config(
995            true,
996            true,
997            [
998                FileListDataColumn::Name,
999                FileListDataColumn::Extension,
1000                FileListDataColumn::Size,
1001                FileListDataColumn::Modified,
1002            ],
1003        );
1004        assert_eq!(
1005            list_column_layout(false, &cfg),
1006            ListColumnLayout {
1007                data_columns: vec![
1008                    FileListDataColumn::Name,
1009                    FileListDataColumn::Extension,
1010                    FileListDataColumn::Size,
1011                    FileListDataColumn::Modified,
1012                ],
1013                name: TableColumnIndex::new(0),
1014                extension: Some(TableColumnIndex::new(1)),
1015                size: Some(TableColumnIndex::new(2)),
1016                modified: Some(TableColumnIndex::new(3)),
1017            }
1018        );
1019    }
1020
1021    #[test]
1022    fn list_column_layout_hides_extension_column() {
1023        let mut cfg = columns_config(
1024            true,
1025            true,
1026            [
1027                FileListDataColumn::Name,
1028                FileListDataColumn::Extension,
1029                FileListDataColumn::Size,
1030                FileListDataColumn::Modified,
1031            ],
1032        );
1033        cfg.show_extension = false;
1034
1035        assert_eq!(
1036            list_column_layout(false, &cfg),
1037            ListColumnLayout {
1038                data_columns: vec![
1039                    FileListDataColumn::Name,
1040                    FileListDataColumn::Size,
1041                    FileListDataColumn::Modified,
1042                ],
1043                name: TableColumnIndex::new(0),
1044                extension: None,
1045                size: Some(TableColumnIndex::new(1)),
1046                modified: Some(TableColumnIndex::new(2)),
1047            }
1048        );
1049    }
1050
1051    #[test]
1052    fn list_column_layout_all_columns_visible_with_preview() {
1053        let cfg = columns_config(
1054            true,
1055            true,
1056            [
1057                FileListDataColumn::Name,
1058                FileListDataColumn::Extension,
1059                FileListDataColumn::Size,
1060                FileListDataColumn::Modified,
1061            ],
1062        );
1063        assert_eq!(
1064            list_column_layout(true, &cfg),
1065            ListColumnLayout {
1066                data_columns: vec![
1067                    FileListDataColumn::Name,
1068                    FileListDataColumn::Extension,
1069                    FileListDataColumn::Size,
1070                    FileListDataColumn::Modified,
1071                ],
1072                name: TableColumnIndex::new(1),
1073                extension: Some(TableColumnIndex::new(2)),
1074                size: Some(TableColumnIndex::new(3)),
1075                modified: Some(TableColumnIndex::new(4)),
1076            }
1077        );
1078    }
1079
1080    #[test]
1081    fn list_column_layout_hides_size_column() {
1082        let cfg = columns_config(
1083            false,
1084            true,
1085            [
1086                FileListDataColumn::Name,
1087                FileListDataColumn::Extension,
1088                FileListDataColumn::Size,
1089                FileListDataColumn::Modified,
1090            ],
1091        );
1092        assert_eq!(
1093            list_column_layout(false, &cfg),
1094            ListColumnLayout {
1095                data_columns: vec![
1096                    FileListDataColumn::Name,
1097                    FileListDataColumn::Extension,
1098                    FileListDataColumn::Modified,
1099                ],
1100                name: TableColumnIndex::new(0),
1101                extension: Some(TableColumnIndex::new(1)),
1102                size: None,
1103                modified: Some(TableColumnIndex::new(2)),
1104            }
1105        );
1106    }
1107
1108    #[test]
1109    fn apply_file_list_view_from_ui_rejects_thumbnail_views_without_backend() {
1110        let mut state = FileDialogState::new(DialogMode::OpenFile);
1111        state.ui.config.file_list_view = FileListViewMode::List;
1112        state.ui.config.thumbnails_enabled = false;
1113        state.ui.config.file_list_columns.show_preview = false;
1114
1115        assert!(!apply_file_list_view_from_ui(
1116            &mut state,
1117            FileListViewMode::ThumbnailsList,
1118            false
1119        ));
1120        assert_eq!(state.ui.config.file_list_view, FileListViewMode::List);
1121        assert!(!state.ui.config.thumbnails_enabled);
1122        assert!(!state.ui.config.file_list_columns.show_preview);
1123
1124        assert!(!apply_file_list_view_from_ui(
1125            &mut state,
1126            FileListViewMode::Grid,
1127            false
1128        ));
1129        assert_eq!(state.ui.config.file_list_view, FileListViewMode::List);
1130        assert!(!state.ui.config.thumbnails_enabled);
1131    }
1132
1133    #[test]
1134    fn apply_file_list_view_from_ui_enables_thumbnail_state_with_backend() {
1135        let mut state = FileDialogState::new(DialogMode::OpenFile);
1136        state.ui.config.file_list_columns.show_preview = false;
1137
1138        assert!(apply_file_list_view_from_ui(
1139            &mut state,
1140            FileListViewMode::ThumbnailsList,
1141            true
1142        ));
1143        assert_eq!(
1144            state.ui.config.file_list_view,
1145            FileListViewMode::ThumbnailsList
1146        );
1147        assert!(state.ui.config.thumbnails_enabled);
1148        assert!(state.ui.config.file_list_columns.show_preview);
1149
1150        state.ui.config.file_list_columns.show_preview = false;
1151        assert!(apply_file_list_view_from_ui(
1152            &mut state,
1153            FileListViewMode::Grid,
1154            true
1155        ));
1156        assert_eq!(state.ui.config.file_list_view, FileListViewMode::Grid);
1157        assert!(state.ui.config.thumbnails_enabled);
1158        assert!(!state.ui.config.file_list_columns.show_preview);
1159    }
1160
1161    #[test]
1162    fn apply_file_list_view_from_ui_keeps_list_view_available() {
1163        let mut state = FileDialogState::new(DialogMode::OpenFile);
1164        state.ui.config.file_list_view = FileListViewMode::Grid;
1165        state.ui.config.thumbnails_enabled = true;
1166
1167        assert!(apply_file_list_view_from_ui(
1168            &mut state,
1169            FileListViewMode::List,
1170            false
1171        ));
1172        assert_eq!(state.ui.config.file_list_view, FileListViewMode::List);
1173        assert!(state.ui.config.thumbnails_enabled);
1174    }
1175
1176    #[test]
1177    fn list_column_layout_hides_modified_column() {
1178        let cfg = columns_config(
1179            true,
1180            false,
1181            [
1182                FileListDataColumn::Name,
1183                FileListDataColumn::Extension,
1184                FileListDataColumn::Size,
1185                FileListDataColumn::Modified,
1186            ],
1187        );
1188        assert_eq!(
1189            list_column_layout(false, &cfg),
1190            ListColumnLayout {
1191                data_columns: vec![
1192                    FileListDataColumn::Name,
1193                    FileListDataColumn::Extension,
1194                    FileListDataColumn::Size,
1195                ],
1196                name: TableColumnIndex::new(0),
1197                extension: Some(TableColumnIndex::new(1)),
1198                size: Some(TableColumnIndex::new(2)),
1199                modified: None,
1200            }
1201        );
1202    }
1203
1204    #[test]
1205    fn list_column_layout_hides_size_and_modified_columns() {
1206        let cfg = columns_config(
1207            false,
1208            false,
1209            [
1210                FileListDataColumn::Name,
1211                FileListDataColumn::Extension,
1212                FileListDataColumn::Size,
1213                FileListDataColumn::Modified,
1214            ],
1215        );
1216        assert_eq!(
1217            list_column_layout(false, &cfg),
1218            ListColumnLayout {
1219                data_columns: vec![FileListDataColumn::Name, FileListDataColumn::Extension],
1220                name: TableColumnIndex::new(0),
1221                extension: Some(TableColumnIndex::new(1)),
1222                size: None,
1223                modified: None,
1224            }
1225        );
1226    }
1227
1228    #[test]
1229    fn list_column_layout_respects_custom_order() {
1230        let cfg = columns_config(
1231            true,
1232            true,
1233            [
1234                FileListDataColumn::Name,
1235                FileListDataColumn::Size,
1236                FileListDataColumn::Modified,
1237                FileListDataColumn::Extension,
1238            ],
1239        );
1240        assert_eq!(
1241            list_column_layout(false, &cfg),
1242            ListColumnLayout {
1243                data_columns: vec![
1244                    FileListDataColumn::Name,
1245                    FileListDataColumn::Size,
1246                    FileListDataColumn::Modified,
1247                    FileListDataColumn::Extension,
1248                ],
1249                name: TableColumnIndex::new(0),
1250                extension: Some(TableColumnIndex::new(3)),
1251                size: Some(TableColumnIndex::new(1)),
1252                modified: Some(TableColumnIndex::new(2)),
1253            }
1254        );
1255    }
1256
1257    #[test]
1258    fn merged_order_with_current_keeps_hidden_columns() {
1259        let merged = merged_order_with_current(
1260            &[FileListDataColumn::Name, FileListDataColumn::Modified],
1261            [
1262                FileListDataColumn::Name,
1263                FileListDataColumn::Size,
1264                FileListDataColumn::Modified,
1265                FileListDataColumn::Extension,
1266            ],
1267        );
1268        assert_eq!(
1269            merged,
1270            [
1271                FileListDataColumn::Name,
1272                FileListDataColumn::Modified,
1273                FileListDataColumn::Size,
1274                FileListDataColumn::Extension,
1275            ]
1276        );
1277    }
1278
1279    #[test]
1280    fn move_column_order_up_swaps_adjacent_items() {
1281        let mut order = [
1282            FileListDataColumn::Name,
1283            FileListDataColumn::Extension,
1284            FileListDataColumn::Size,
1285            FileListDataColumn::Modified,
1286        ];
1287        assert!(super::file_table::move_column_order_up(&mut order, 2));
1288        assert_eq!(
1289            order,
1290            [
1291                FileListDataColumn::Name,
1292                FileListDataColumn::Size,
1293                FileListDataColumn::Extension,
1294                FileListDataColumn::Modified,
1295            ]
1296        );
1297    }
1298
1299    #[test]
1300    fn move_column_order_down_swaps_adjacent_items() {
1301        let mut order = [
1302            FileListDataColumn::Name,
1303            FileListDataColumn::Extension,
1304            FileListDataColumn::Size,
1305            FileListDataColumn::Modified,
1306        ];
1307        assert!(super::file_table::move_column_order_down(&mut order, 1));
1308        assert_eq!(
1309            order,
1310            [
1311                FileListDataColumn::Name,
1312                FileListDataColumn::Size,
1313                FileListDataColumn::Extension,
1314                FileListDataColumn::Modified,
1315            ]
1316        );
1317    }
1318
1319    #[test]
1320    fn move_column_order_up_rejects_first_item() {
1321        let mut order = [
1322            FileListDataColumn::Name,
1323            FileListDataColumn::Extension,
1324            FileListDataColumn::Size,
1325            FileListDataColumn::Modified,
1326        ];
1327        assert!(!super::file_table::move_column_order_up(&mut order, 0));
1328        assert_eq!(
1329            order,
1330            [
1331                FileListDataColumn::Name,
1332                FileListDataColumn::Extension,
1333                FileListDataColumn::Size,
1334                FileListDataColumn::Modified,
1335            ]
1336        );
1337    }
1338
1339    #[test]
1340    fn apply_compact_column_layout_updates_visibility_and_order_only() {
1341        let expected_weights = FileListColumnWeightOverrides {
1342            preview: Some(0.11),
1343            name: Some(0.57),
1344            extension: Some(0.14),
1345            size: Some(0.18),
1346            modified: Some(0.22),
1347        };
1348
1349        let mut cfg = FileListColumnsConfig {
1350            show_preview: true,
1351            show_extension: true,
1352            show_size: false,
1353            show_modified: true,
1354            order: [
1355                FileListDataColumn::Modified,
1356                FileListDataColumn::Size,
1357                FileListDataColumn::Extension,
1358                FileListDataColumn::Name,
1359            ],
1360            weight_overrides: expected_weights.clone(),
1361        };
1362
1363        super::file_table::apply_compact_column_layout(&mut cfg);
1364
1365        assert!(!cfg.show_preview);
1366        assert!(cfg.show_size);
1367        assert!(!cfg.show_modified);
1368        assert_eq!(
1369            cfg.order,
1370            [
1371                FileListDataColumn::Name,
1372                FileListDataColumn::Extension,
1373                FileListDataColumn::Size,
1374                FileListDataColumn::Modified,
1375            ]
1376        );
1377        assert_eq!(cfg.weight_overrides, expected_weights);
1378    }
1379
1380    #[test]
1381    fn apply_balanced_column_layout_updates_visibility_and_order_only() {
1382        let expected_weights = FileListColumnWeightOverrides {
1383            preview: Some(0.13),
1384            name: Some(0.54),
1385            extension: Some(0.16),
1386            size: Some(0.17),
1387            modified: Some(0.21),
1388        };
1389
1390        let mut cfg = FileListColumnsConfig {
1391            show_preview: false,
1392            show_extension: true,
1393            show_size: false,
1394            show_modified: false,
1395            order: [
1396                FileListDataColumn::Size,
1397                FileListDataColumn::Name,
1398                FileListDataColumn::Modified,
1399                FileListDataColumn::Extension,
1400            ],
1401            weight_overrides: expected_weights.clone(),
1402        };
1403
1404        super::file_table::apply_balanced_column_layout(&mut cfg);
1405
1406        assert!(cfg.show_preview);
1407        assert!(cfg.show_size);
1408        assert!(cfg.show_modified);
1409        assert_eq!(
1410            cfg.order,
1411            [
1412                FileListDataColumn::Name,
1413                FileListDataColumn::Extension,
1414                FileListDataColumn::Size,
1415                FileListDataColumn::Modified,
1416            ]
1417        );
1418        assert_eq!(cfg.weight_overrides, expected_weights);
1419    }
1420
1421    #[test]
1422    fn open_rename_modal_from_selection_prefills_name_from_id() {
1423        let mut state = FileDialogState::new(DialogMode::OpenFiles);
1424        state.core.set_cwd(PathBuf::from("/tmp"));
1425
1426        let fs = UiTestFs {
1427            entries: vec![file_entry("/tmp/a.txt")],
1428        };
1429        state.core.rescan_if_needed(&fs);
1430
1431        let id = state
1432            .core
1433            .entries()
1434            .iter()
1435            .find(|entry| entry.path == Path::new("/tmp/a.txt"))
1436            .map(|entry| entry.id)
1437            .expect("missing /tmp/a.txt entry id");
1438        state.core.focus_and_select_by_id(id);
1439
1440        open_rename_modal_from_selection(&mut state);
1441
1442        assert_eq!(state.ui.operations.rename.target_id, Some(id));
1443        assert_eq!(state.ui.operations.rename.to, "a.txt");
1444        assert!(state.ui.operations.rename.open_next);
1445        assert!(state.ui.operations.rename.focus_next);
1446    }
1447
1448    #[test]
1449    fn open_rename_modal_from_selection_ignores_unresolved_id() {
1450        let mut state = FileDialogState::new(DialogMode::OpenFiles);
1451        let id = EntryId::from_path(Path::new("/tmp/missing.txt"));
1452        state.core.focus_and_select_by_id(id);
1453
1454        open_rename_modal_from_selection(&mut state);
1455
1456        assert_eq!(state.ui.operations.rename.target_id, None);
1457        assert!(state.ui.operations.rename.to.is_empty());
1458        assert!(!state.ui.operations.rename.open_next);
1459    }
1460
1461    #[test]
1462    fn open_delete_modal_from_selection_stores_selected_ids() {
1463        let mut state = FileDialogState::new(DialogMode::OpenFiles);
1464        state.core.set_cwd(PathBuf::from("/tmp"));
1465
1466        let fs = UiTestFs {
1467            entries: vec![file_entry("/tmp/a.txt"), file_entry("/tmp/b.txt")],
1468        };
1469        state.core.rescan_if_needed(&fs);
1470
1471        let a = state
1472            .core
1473            .entries()
1474            .iter()
1475            .find(|entry| entry.path == Path::new("/tmp/a.txt"))
1476            .map(|entry| entry.id)
1477            .expect("missing /tmp/a.txt entry id");
1478        let b = state
1479            .core
1480            .entries()
1481            .iter()
1482            .find(|entry| entry.path == Path::new("/tmp/b.txt"))
1483            .map(|entry| entry.id)
1484            .expect("missing /tmp/b.txt entry id");
1485        state.core.replace_selection_by_ids([b, a]);
1486
1487        open_delete_modal_from_selection(&mut state);
1488
1489        assert_eq!(state.ui.operations.delete.target_ids, vec![b, a]);
1490        assert!(state.ui.operations.delete.open_next);
1491    }
1492
1493    #[test]
1494    fn operation_state_defaults_keep_modal_jobs_internal() {
1495        let state = FileDialogState::new(DialogMode::OpenFiles);
1496
1497        assert!(state.ui.operations.rename.target_id.is_none());
1498        assert!(!state.ui.operations.rename.open_next);
1499        assert!(state.ui.operations.rename.to.is_empty());
1500        assert!(state.ui.operations.delete.target_ids.is_empty());
1501        assert!(!state.ui.operations.delete.open_next);
1502        assert!(state.ui.operations.paste.clipboard.is_none());
1503        assert!(state.ui.operations.paste.job.is_none());
1504        assert!(!state.ui.operations.paste.conflict_open_next);
1505        assert!(state.ui.operations.places.io.buffer.is_empty());
1506        assert!(state.ui.operations.places.edit.group.is_empty());
1507        assert!(state.ui.operations.places.inline_edit.target.is_none());
1508    }
1509}