nvim_gtk/
file_browser.rs

1use std::cell::RefCell;
2use std::cmp::Ordering;
3use std::io;
4use std::fs;
5use std::fs::DirEntry;
6use std::path::{Component, Path, PathBuf};
7use std::rc::Rc;
8use std::ops::Deref;
9
10use gio;
11use gio::prelude::*;
12use gtk;
13use gtk::prelude::*;
14
15use neovim_lib::{NeovimApi, NeovimApiAsync};
16
17use crate::misc::escape_filename;
18use crate::nvim::{ErrorReport, NeovimClient, NeovimRef};
19use crate::shell;
20use crate::subscriptions::SubscriptionKey;
21
22const ICON_FOLDER_CLOSED: &str = "folder-symbolic";
23const ICON_FOLDER_OPEN: &str = "folder-open-symbolic";
24const ICON_FILE: &str = "text-x-generic-symbolic";
25
26struct Components {
27    dir_list_model: gtk::TreeStore,
28    dir_list: gtk::ComboBox,
29    context_menu: gtk::Menu,
30    show_hidden_checkbox: gtk::CheckMenuItem,
31    cd_action: gio::SimpleAction,
32}
33
34struct State {
35    current_dir: String,
36    show_hidden: bool,
37    selected_path: Option<String>,
38}
39
40pub struct FileBrowserWidget {
41    store: gtk::TreeStore,
42    tree: gtk::TreeView,
43    widget: gtk::Box,
44    nvim: Option<Rc<NeovimClient>>,
45    comps: Components,
46    state: Rc<RefCell<State>>,
47}
48
49impl Deref for FileBrowserWidget {
50    type Target = gtk::Box;
51
52    fn deref(&self) -> &gtk::Box {
53        &self.widget
54    }
55}
56
57#[derive(Copy, Clone, Debug)]
58enum FileType {
59    File,
60    Dir,
61}
62
63#[allow(dead_code)]
64enum Column {
65    Filename,
66    Path,
67    FileType,
68    IconName,
69}
70
71impl FileBrowserWidget {
72    pub fn new() -> Self {
73        let builder = gtk::Builder::new_from_string(include_str!("../resources/side-panel.ui"));
74        let widget: gtk::Box = builder.get_object("file_browser").unwrap();
75        let tree: gtk::TreeView = builder.get_object("file_browser_tree_view").unwrap();
76        let store: gtk::TreeStore = builder.get_object("file_browser_tree_store").unwrap();
77        let dir_list_model: gtk::TreeStore = builder.get_object("dir_list_model").unwrap();
78        let dir_list: gtk::ComboBox = builder.get_object("dir_list").unwrap();
79        let context_menu: gtk::Menu = builder.get_object("file_browser_context_menu").unwrap();
80        let show_hidden_checkbox: gtk::CheckMenuItem = builder
81            .get_object("file_browser_show_hidden_checkbox")
82            .unwrap();
83
84        let file_browser = FileBrowserWidget {
85            store,
86            tree,
87            widget,
88            nvim: None,
89            comps: Components {
90                dir_list_model,
91                dir_list,
92                context_menu,
93                show_hidden_checkbox,
94                cd_action: gio::SimpleAction::new("cd", None),
95            },
96            state: Rc::new(RefCell::new(State {
97                current_dir: "".to_owned(),
98                show_hidden: false,
99                selected_path: None,
100            })),
101        };
102        file_browser
103    }
104
105    fn nvim(&self) -> Option<NeovimRef> {
106        self.nvim.as_ref().unwrap().nvim()
107    }
108
109    pub fn init(&mut self, shell_state: &shell::State) {
110        // Initialize values.
111        let nvim = shell_state.nvim_clone();
112        self.nvim = Some(nvim);
113        if let Some(dir) = get_current_dir(&mut self.nvim().unwrap()) {
114            update_dir_list(&dir, &self.comps.dir_list_model, &self.comps.dir_list);
115            self.state.borrow_mut().current_dir = dir;
116        }
117
118        // Populate tree.
119        tree_reload(&self.store, &self.state.borrow());
120
121        let store = &self.store;
122        let state_ref = &self.state;
123        self.tree.connect_test_expand_row(clone!(store, state_ref => move |_, iter, _| {
124            store.set(&iter, &[Column::IconName as u32], &[&ICON_FOLDER_OPEN]);
125            // We cannot recursively populate all directories. Instead, we have prepared a single
126            // empty child entry for all non-empty directories, so the row will be expandable. Now,
127            // when a directory is expanded, populate its children.
128            let state = state_ref.borrow();
129            if let Some(child) = store.iter_children(Some(iter)) {
130                let filename = store.get_value(&child, Column::Filename as i32);
131                if filename.get::<&str>().is_none() {
132                    store.remove(&child);
133                    let dir_value = store.get_value(&iter, Column::Path as i32);
134                    if let Some(dir) = dir_value.get() {
135                        populate_tree_nodes(&store, &state, dir, Some(iter));
136                    }
137                } else {
138                    // This directory is already populated, i.e. it has been expanded and collapsed
139                    // again. Rows further down the tree might have been silently collapsed without
140                    // getting an event. Update their folder icon.
141                    let mut tree_path = store.get_path(&child).unwrap();
142                    while let Some(iter) = store.get_iter(&tree_path) {
143                        tree_path.next();
144                        let file_type = store
145                            .get_value(&iter, Column::FileType as i32)
146                            .get::<u8>();
147                        if file_type == Some(FileType::Dir as u8) {
148                            store.set(&iter, &[Column::IconName as u32], &[&ICON_FOLDER_CLOSED]);
149                        }
150                    }
151                }
152            }
153            Inhibit(false)
154        }));
155
156        self.tree.connect_row_collapsed(clone!(store => move |_, iter, _| {
157            store.set(&iter, &[Column::IconName as u32], &[&ICON_FOLDER_CLOSED]);
158        }));
159
160        // Further initialization.
161        self.init_actions();
162        self.init_subscriptions(shell_state);
163        self.connect_events();
164    }
165
166    fn init_actions(&self) {
167        let actions = gio::SimpleActionGroup::new();
168
169        let store = &self.store;
170        let state_ref = &self.state;
171        let nvim_ref = self.nvim.as_ref().unwrap();
172
173        let reload_action = gio::SimpleAction::new("reload", None);
174        reload_action.connect_activate(clone!(store, state_ref => move |_, _| {
175            tree_reload(&store, &state_ref.borrow());
176        }));
177        actions.add_action(&reload_action);
178
179        let cd_action = &self.comps.cd_action;
180        cd_action.connect_activate(clone!(state_ref, nvim_ref => move |_, _| {
181            let mut nvim = nvim_ref.nvim().unwrap();
182            if let Some(ref path) = state_ref.borrow().selected_path {
183                nvim.set_current_dir_async(&path)
184                    .cb(|r| r.report_err())
185                    .call();
186            }
187        }));
188        actions.add_action(cd_action);
189
190        self.comps
191            .context_menu
192            .insert_action_group("filebrowser", Some(&actions));
193    }
194
195    fn init_subscriptions(&self, shell_state: &shell::State) {
196        // Always set the current working directory as the root of the file browser.
197        let store = &self.store;
198        let state_ref = &self.state;
199        let dir_list_model = &self.comps.dir_list_model;
200        let dir_list = &self.comps.dir_list;
201        shell_state.subscribe(
202            SubscriptionKey::from("DirChanged"),
203            &["getcwd()"],
204            clone!(store, state_ref, dir_list_model, dir_list => move |args| {
205                let dir = args.into_iter().next().unwrap();
206                if dir != state_ref.borrow().current_dir {
207                    state_ref.borrow_mut().current_dir = dir.to_owned();
208                    update_dir_list(&dir, &dir_list_model, &dir_list);
209                    tree_reload(&store, &state_ref.borrow());
210                }
211            }),
212        );
213
214        // Reveal the file of an entered buffer in the file browser and select the entry.
215        let tree = &self.tree;
216        let subscription = shell_state.subscribe(
217            SubscriptionKey::from("BufEnter"),
218            &["getcwd()", "expand('%:p')"],
219            clone!(tree, store => move |args| {
220                let mut args_iter = args.into_iter();
221                let dir = args_iter.next().unwrap();
222                let file_path = args_iter.next().unwrap();
223                let could_reveal =
224                    if let Ok(rel_path) = Path::new(&file_path).strip_prefix(&Path::new(&dir)) {
225                        reveal_path_in_tree(&store, &tree, &rel_path)
226                    } else {
227                        false
228                    };
229                if !could_reveal {
230                    tree.get_selection().unselect_all();
231                }
232            }),
233        );
234        shell_state.run_now(&subscription);
235    }
236
237    fn connect_events(&self) {
238        // Open file / go to dir, when user clicks on an entry.
239        let store = &self.store;
240        let state_ref = &self.state;
241        let nvim_ref = self.nvim.as_ref().unwrap();
242        self.tree.connect_row_activated(clone!(store, state_ref, nvim_ref => move |tree, path, _| {
243            let iter = store.get_iter(path).unwrap();
244            let file_type = store
245                .get_value(&iter, Column::FileType as i32)
246                .get::<u8>()
247                .unwrap();
248            let file_path = store
249                .get_value(&iter, Column::Path as i32)
250                .get::<String>()
251                .unwrap();
252            if file_type == FileType::Dir as u8 {
253                let expanded = tree.row_expanded(path);
254                if expanded {
255                    tree.collapse_row(path);
256                } else {
257                    tree.expand_row(path, false);
258                }
259            } else {
260                // FileType::File
261                let cwd = &state_ref.borrow().current_dir;
262                let cwd = Path::new(cwd);
263                let file_path = if let Some(rel_path) = Path::new(&file_path)
264                    .strip_prefix(&cwd)
265                    .ok()
266                    .and_then(|p| p.to_str())
267                {
268                    rel_path
269                } else {
270                    &file_path
271                };
272                let file_path = escape_filename(file_path);
273                nvim_ref.nvim().unwrap().command_async(&format!(":e {}", file_path))
274                    .cb(|r| r.report_err())
275                    .call();
276            }
277        }));
278
279        // Connect directory list.
280        let nvim_ref = self.nvim.as_ref().unwrap();
281        self.comps.dir_list.connect_changed(clone!(nvim_ref, state_ref => move |dir_list| {
282            if let Some(iter) = dir_list.get_active_iter() {
283                let model = dir_list.get_model().unwrap();
284                if let Some(dir) = model.get_value(&iter, 2).get::<&str>() {
285                    if dir != state_ref.borrow().current_dir {
286                        let mut nvim = nvim_ref.nvim().unwrap();
287                        nvim.set_current_dir_async(dir)
288                            .cb(|r| r.report_err())
289                            .call();
290                    }
291                }
292            }
293        }));
294
295        let context_menu = &self.comps.context_menu;
296        let cd_action = &self.comps.cd_action;
297        self.tree.connect_button_press_event(
298            clone!(store, state_ref, context_menu, cd_action => move |tree, ev_btn| {
299                // Open context menu on right click.
300                if ev_btn.get_button() == 3 {
301                    context_menu.popup_at_pointer(Some(&**ev_btn));
302                    let (pos_x, pos_y) = ev_btn.get_position();
303                    let iter = tree
304                        .get_path_at_pos(pos_x as i32, pos_y as i32)
305                        .and_then(|(path, _, _, _)| path)
306                        .and_then(|path| store.get_iter(&path));
307                    let file_type = iter
308                        .as_ref()
309                        .and_then(|iter| {
310                            store
311                                .get_value(&iter, Column::FileType as i32)
312                                .get::<u8>()
313                        });
314                    // Enable the "Go To Directory" action only if the user clicked on a folder.
315                    cd_action.set_enabled(file_type == Some(FileType::Dir as u8));
316                    let path = iter
317                        .and_then(|iter| {
318                            store
319                                .get_value(&iter, Column::Path as i32)
320                                .get::<String>()
321                        });
322                    state_ref.borrow_mut().selected_path = path;
323                }
324                Inhibit(false)
325            }),
326        );
327
328        // Show / hide hidden files when corresponding menu item is toggled.
329        self.comps.show_hidden_checkbox.connect_toggled(clone!(state_ref, store => move |ev| {
330            let mut state = state_ref.borrow_mut();
331            state.show_hidden = ev.get_active();
332            tree_reload(&store, &state);
333        }));
334    }
335}
336
337/// Compare function for dir entries.
338///
339/// Sorts directories above files.
340fn cmp_dirs_first(lhs: &DirEntry, rhs: &DirEntry) -> io::Result<Ordering> {
341    let lhs_metadata = fs::metadata(lhs.path())?;
342    let rhs_metadata = fs::metadata(rhs.path())?;
343    if lhs_metadata.is_dir() == rhs_metadata.is_dir() {
344        Ok(lhs.path()
345            .to_string_lossy()
346            .to_lowercase()
347            .cmp(&rhs.path().to_string_lossy().to_lowercase()))
348    } else {
349        if lhs_metadata.is_dir() {
350            Ok(Ordering::Less)
351        } else {
352            Ok(Ordering::Greater)
353        }
354    }
355}
356
357/// Clears an repopulate the entire tree.
358fn tree_reload(store: &gtk::TreeStore, state: &State) {
359    let dir = &state.current_dir;
360    store.clear();
361    populate_tree_nodes(store, state, dir, None);
362}
363
364/// Updates the dirctory list on top of the file browser.
365///
366/// The list represents the path the the current working directory.  If the new cwd is a parent of
367/// the old one, the list is kept and only the active entry is updated. Otherwise, the list is
368/// replaced with the new path and the last entry is marked active.
369fn update_dir_list(dir: &str, dir_list_model: &gtk::TreeStore, dir_list: &gtk::ComboBox) {
370    // The current working directory path.
371    let complete_path = Path::new(dir);
372    let mut path = PathBuf::new();
373    let mut components = complete_path.components();
374    let mut next = components.next();
375
376    // Iterator over existing dir_list model.
377    let mut dir_list_iter = dir_list_model.get_iter_first();
378
379    // Whether existing entries up to the current position of dir_list_iter are a prefix of the
380    // new current working directory path.
381    let mut is_prefix = true;
382
383    // Iterate over components of the cwd. Simultaneously move dir_list_iter forward.
384    while let Some(dir) = next {
385        next = components.next();
386        let dir_name = &*dir.as_os_str().to_string_lossy();
387        // Assemble path up to current component.
388        path.push(Path::new(&dir));
389        let path_str = path.to_str().unwrap_or_else(|| {
390            error!(
391                "Could not convert path to string: {}\n
392                    Directory chooser will not work for that entry.",
393                path.to_string_lossy()
394            );
395            ""
396        });
397        // Use the current entry of dir_list, if any, otherwise append a new one.
398        let current_iter = dir_list_iter.unwrap_or_else(|| dir_list_model.append(None));
399        // Check if the current entry is still part of the new cwd.
400        if is_prefix && dir_list_model.get_value(&current_iter, 0).get::<&str>() != Some(&dir_name)
401        {
402            is_prefix = false;
403        }
404        if next.is_some() {
405            // Update dir_list entry.
406            dir_list_model.set(
407                &current_iter,
408                &[0, 1, 2],
409                &[&dir_name, &ICON_FOLDER_CLOSED, &path_str],
410            );
411        } else {
412            // We reached the last component of the new cwd path. Set the active entry of dir_list
413            // to this one.
414            dir_list_model.set(
415                &current_iter,
416                &[0, 1, 2],
417                &[&dir_name, &ICON_FOLDER_OPEN, &path_str],
418            );
419            dir_list.set_active_iter(Some(&current_iter));
420        };
421        // Advance dir_list_iter.
422        dir_list_iter = if dir_list_model.iter_next(&current_iter) {
423            Some(current_iter)
424        } else {
425            None
426        }
427    }
428    // We updated the dir list to the point of the current working directory.
429    if let Some(iter) = dir_list_iter {
430        if is_prefix {
431            // If we didn't change any entries to this point and the list contains further entries,
432            // the remaining ones are subdirectories of the cwd and we keep them.
433            loop {
434                dir_list_model.set(&iter, &[1], &[&ICON_FOLDER_CLOSED]);
435                if !dir_list_model.iter_next(&iter) {
436                    break;
437                }
438            }
439        } else {
440            // If we needed to change entries, the following ones are not directories under the
441            // cwd and we clear them.
442            while dir_list_model.remove(&iter) {}
443        }
444    }
445}
446
447/// Populates one level, i.e. one directory of the file browser tree.
448fn populate_tree_nodes(
449    store: &gtk::TreeStore,
450    state: &State,
451    dir: &str,
452    parent: Option<&gtk::TreeIter>,
453) {
454    let path = Path::new(dir);
455    let read_dir = match path.read_dir() {
456        Ok(read_dir) => read_dir,
457        Err(err) => {
458            error!("Couldn't populate tree: {}", err);
459            return;
460        }
461    };
462    let iter = read_dir.filter_map(Result::ok);
463    let mut entries: Vec<DirEntry> = if state.show_hidden {
464        iter.collect()
465    } else {
466        iter.filter(|entry| !entry.file_name().to_string_lossy().starts_with('.'))
467            .filter(|entry| !entry.file_name().to_string_lossy().ends_with('~'))
468            .collect()
469    };
470    entries.sort_unstable_by(|lhs, rhs| cmp_dirs_first(lhs, rhs).unwrap_or(Ordering::Equal));
471    for entry in entries {
472        let path = if let Some(path) = entry.path().to_str() {
473            path.to_owned()
474        } else {
475            // Skip paths that contain invalid unicode.
476            continue;
477        };
478        let filename = entry.file_name().to_str().unwrap().to_owned();
479        let file_type = if let Ok(metadata) = fs::metadata(entry.path()) {
480            let file_type = metadata.file_type();
481            if file_type.is_dir() {
482                FileType::Dir
483            } else if file_type.is_file() {
484                FileType::File
485            } else {
486                continue;
487            }
488        } else {
489            // In case of invalid symlinks, we cannot obtain metadata.
490            continue;
491        };
492        let icon = match file_type {
493            FileType::Dir => ICON_FOLDER_CLOSED,
494            FileType::File => ICON_FILE,
495        };
496        // When we get until here, we want to show the entry. Append it to the tree.
497        let iter = store.append(parent);
498        store.set(
499            &iter,
500            &[0, 1, 2, 3],
501            &[&filename, &path, &(file_type as u8), &icon],
502        );
503        // For directories, check whether the directory is empty. If not, append a single empty
504        // entry, so the expand arrow is shown. Its contents are dynamically populated when
505        // expanded (see `init`).
506        if let FileType::Dir = file_type {
507            let not_empty = if let Ok(mut dir) = entry.path().read_dir() {
508                dir.next().is_some()
509            } else {
510                false
511            };
512            if not_empty {
513                let iter = store.append(Some(&iter));
514                store.set(&iter, &[], &[]);
515            }
516        }
517    }
518}
519
520fn get_current_dir(nvim: &mut NeovimRef) -> Option<String> {
521    match nvim.eval("getcwd()") {
522        Ok(cwd) => cwd.as_str().map(|s| s.to_owned()),
523        Err(err) => {
524            error!("Couldn't get cwd: {}", err);
525            None
526        }
527    }
528}
529
530/// Reveals and selects the given file in the file browser.
531///
532/// Returns `true` if the file could be successfully revealed.
533fn reveal_path_in_tree(store: &gtk::TreeStore, tree: &gtk::TreeView, rel_file_path: &Path) -> bool {
534    let mut tree_path = gtk::TreePath::new();
535    'components: for component in rel_file_path.components() {
536        if let Component::Normal(component) = component {
537            tree_path.down();
538            while let Some(iter) = store.get_iter(&tree_path) {
539                let entry_value = store.get_value(&iter, Column::Filename as i32);
540                let entry = entry_value.get::<&str>().unwrap();
541                if component == entry {
542                    tree.expand_row(&tree_path, false);
543                    continue 'components;
544                }
545                tree_path.next();
546            }
547            return false;
548        } else {
549            return false;
550        }
551    }
552    if tree_path.get_depth() == 0 {
553        return false;
554    }
555    tree.set_cursor(&tree_path, Option::<&gtk::TreeViewColumn>::None, false);
556    true
557}