diff --git a/resources/side-panel.ui b/resources/side-panel.ui new file mode 100644 index 0000000..2e595cb --- /dev/null +++ b/resources/side-panel.ui @@ -0,0 +1,141 @@ + + + + + + + + + + + + + + + + True + False + + + True + False + filebrowser.cd + Go To Directory + + + + + True + False + + + + + True + False + filebrowser.reload + Reload + + + + + True + False + Show Hidden Files + + + + + + + + + + + + + + + + + 150 + False + vertical + + + False + False + 6 + dir_list_model + 1 + + + 6 + + + 1 + + + + + 6 + end + + + 0 + + + + + False + True + 0 + + + + + False + + + False + file_browser_tree_store + False + False + 20 + True + + + + + + autosize + + + 6 + + + 3 + + + + + + 0 + + + + + + + + + True + True + 1 + + + + + diff --git a/src/file_browser.rs b/src/file_browser.rs new file mode 100644 index 0000000..04f0f8b --- /dev/null +++ b/src/file_browser.rs @@ -0,0 +1,540 @@ +use std::cell::RefCell; +use std::cmp::Ordering; +use std::io; +use std::fs; +use std::fs::DirEntry; +use std::path::{Component, Path, PathBuf}; +use std::rc::Rc; +use std::ops::Deref; + +use gio; +use gio::prelude::*; +use gtk; +use gtk::MenuExt; +use gtk::prelude::*; + +use neovim_lib::NeovimApi; + +use nvim::{ErrorReport, NeovimClient, NeovimRef}; +use shell; + +const ICON_FOLDER_CLOSED: &str = "folder-symbolic"; +const ICON_FOLDER_OPEN: &str = "folder-open-symbolic"; +const ICON_FILE: &str = "text-x-generic-symbolic"; + +struct Components { + dir_list_model: gtk::TreeStore, + dir_list: gtk::ComboBox, + context_menu: gtk::Menu, + show_hidden_checkbox: gtk::CheckMenuItem, + cd_action: gio::SimpleAction, +} + +struct State { + current_dir: String, + show_hidden: bool, + selected_path: Option, +} + +pub struct FileBrowserWidget { + store: gtk::TreeStore, + tree: gtk::TreeView, + widget: gtk::Box, + nvim: Option>, + comps: Components, + state: Rc>, +} + +impl Deref for FileBrowserWidget { + type Target = gtk::Box; + + fn deref(&self) -> >k::Box { + &self.widget + } +} + +#[derive(Copy, Clone, Debug)] +enum FileType { + File, + Dir, +} + +#[allow(dead_code)] +enum Column { + Filename, + Path, + FileType, + IconName, +} + +impl FileBrowserWidget { + pub fn new() -> Self { + let builder = gtk::Builder::new_from_string(include_str!("../resources/side-panel.ui")); + let widget: gtk::Box = builder.get_object("file_browser").unwrap(); + let tree: gtk::TreeView = builder.get_object("file_browser_tree_view").unwrap(); + let store: gtk::TreeStore = builder.get_object("file_browser_tree_store").unwrap(); + let dir_list_model: gtk::TreeStore = builder.get_object("dir_list_model").unwrap(); + let dir_list: gtk::ComboBox = builder.get_object("dir_list").unwrap(); + let context_menu: gtk::Menu = builder.get_object("file_browser_context_menu").unwrap(); + let show_hidden_checkbox: gtk::CheckMenuItem = builder + .get_object("file_browser_show_hidden_checkbox") + .unwrap(); + + let file_browser = FileBrowserWidget { + store, + tree, + widget, + nvim: None, + comps: Components { + dir_list_model, + dir_list, + context_menu, + show_hidden_checkbox, + cd_action: gio::SimpleAction::new("cd", None), + }, + state: Rc::new(RefCell::new(State { + current_dir: "".to_owned(), + show_hidden: false, + selected_path: None, + })), + }; + file_browser + } + + fn nvim(&self) -> Option { + self.nvim.as_ref().unwrap().nvim() + } + + pub fn init(&mut self, shell_state: &shell::State) { + // Initialize values. + let nvim = shell_state.nvim_clone(); + self.nvim = Some(nvim); + let dir = get_current_dir(&mut self.nvim().unwrap()); + update_dir_list(&dir, &self.comps.dir_list_model, &self.comps.dir_list); + self.state.borrow_mut().current_dir = dir; + + // Populate tree. + tree_reload(&self.store, &self.state.borrow()); + + let store = &self.store; + let state_ref = &self.state; + self.tree.connect_test_expand_row(clone!(store, state_ref => move |_, iter, _| { + store.set(&iter, &[Column::IconName as u32], &[&ICON_FOLDER_OPEN]); + // We cannot recursively populate all directories. Instead, we have prepared a single + // empty child entry for all non-empty directories, so the row will be expandable. Now, + // when a directory is expanded, populate its children. + let state = state_ref.borrow(); + if let Some(child) = store.iter_children(iter) { + let filename = store.get_value(&child, Column::Filename as i32); + if filename.get::<&str>().is_none() { + store.remove(&child); + let dir_value = store.get_value(&iter, Column::Path as i32); + if let Some(dir) = dir_value.get() { + populate_tree_nodes(&store, &state, dir, Some(iter)); + } + } else { + // This directory is already populated, i.e. it has been expanded and collapsed + // again. Rows further down the tree might have been silently collapsed without + // getting an event. Update their folder icon. + let mut tree_path = store.get_path(&child).unwrap(); + while let Some(iter) = store.get_iter(&tree_path) { + tree_path.next(); + let file_type = store + .get_value(&iter, Column::FileType as i32) + .get::(); + if file_type == Some(FileType::Dir as u8) { + store.set(&iter, &[Column::IconName as u32], &[&ICON_FOLDER_CLOSED]); + } + } + } + } + Inhibit(false) + })); + + self.tree.connect_row_collapsed(clone!(store => move |_, iter, _| { + store.set(&iter, &[Column::IconName as u32], &[&ICON_FOLDER_CLOSED]); + })); + + // Further initialization. + self.init_actions(); + self.init_subscriptions(shell_state); + self.connect_events(); + } + + fn init_actions(&self) { + let actions = gio::SimpleActionGroup::new(); + + let store = &self.store; + let state_ref = &self.state; + let nvim_ref = self.nvim.as_ref().unwrap(); + + let reload_action = gio::SimpleAction::new("reload", None); + reload_action.connect_activate(clone!(store, state_ref => move |_, _| { + tree_reload(&store, &state_ref.borrow()); + })); + actions.add_action(&reload_action); + + let cd_action = &self.comps.cd_action; + cd_action.connect_activate(clone!(state_ref, nvim_ref => move |_, _| { + let mut nvim = nvim_ref.nvim().unwrap(); + if let Some(ref path) = state_ref.borrow().selected_path { + nvim.set_current_dir(&path).report_err(); + } + })); + actions.add_action(cd_action); + + self.comps + .context_menu + .insert_action_group("filebrowser", &actions); + } + + fn init_subscriptions(&self, shell_state: &shell::State) { + // Always set the current working directory as the root of the file browser. + let store = &self.store; + let state_ref = &self.state; + let dir_list_model = &self.comps.dir_list_model; + let dir_list = &self.comps.dir_list; + shell_state.subscribe( + "DirChanged", + &["getcwd()"], + clone!(store, state_ref, dir_list_model, dir_list => move |args| { + let dir = args.into_iter().next().unwrap(); + let mut state = state_ref.borrow_mut(); + if dir != *state.current_dir { + update_dir_list(&dir, &dir_list_model, &dir_list); + state.current_dir = dir; + tree_reload(&store, &state); + } + }), + ); + + // Reveal the file of an entered buffer in the file browser and select the entry. + let tree = &self.tree; + let subscription = shell_state.subscribe( + "BufEnter", + &["getcwd()", "expand('%:p')"], + clone!(tree, store => move |args| { + let mut args_iter = args.into_iter(); + let dir = args_iter.next().unwrap(); + let file_path = args_iter.next().unwrap(); + let could_reveal = + if let Ok(rel_path) = Path::new(&file_path).strip_prefix(&Path::new(&dir)) { + reveal_path_in_tree(&store, &tree, &rel_path) + } else { + false + }; + if !could_reveal { + tree.get_selection().unselect_all(); + } + }), + ); + shell_state.run_now(&subscription); + } + + fn connect_events(&self) { + // Open file / go to dir, when user clicks on an entry. + let store = &self.store; + let nvim_ref = self.nvim.as_ref().unwrap(); + self.tree.connect_row_activated(clone!(store, nvim_ref => move |tree, path, _| { + let mut nvim = nvim_ref.nvim().unwrap(); + let iter = store.get_iter(path).unwrap(); + let file_type = store + .get_value(&iter, Column::FileType as i32) + .get::() + .unwrap(); + let file_path = store + .get_value(&iter, Column::Path as i32) + .get::() + .unwrap(); + if file_type == FileType::Dir as u8 { + let expanded = tree.row_expanded(path); + if expanded { + tree.collapse_row(path); + } else { + tree.expand_row(path, false); + } + } else { + // FileType::File + let dir = get_current_dir(&mut nvim); + let dir = Path::new(&dir); + let file_path = if let Some(rel_path) = Path::new(&file_path) + .strip_prefix(&dir) + .ok() + .and_then(|p| p.to_str()) + { + rel_path + } else { + &file_path + }; + nvim.command(&format!(":e {}", file_path)).report_err(); + } + })); + + // Connect directory list. + let nvim_ref = self.nvim.as_ref().unwrap(); + self.comps.dir_list.connect_changed(clone!(nvim_ref => move |dir_list| { + if let Some(iter) = dir_list.get_active_iter() { + let model = dir_list.get_model().unwrap(); + if let Some(dir) = model.get_value(&iter, 2).get::<&str>() { + let mut nvim = nvim_ref.nvim().unwrap(); + nvim.set_current_dir(dir).report_err(); + } + } + })); + + let store = &self.store; + let state_ref = &self.state; + let context_menu = &self.comps.context_menu; + let cd_action = &self.comps.cd_action; + self.tree.connect_button_press_event( + clone!(store, state_ref, context_menu, cd_action => move |tree, ev_btn| { + // Open context menu on right click. + if ev_btn.get_button() == 3 { + context_menu.popup_at_pointer(&**ev_btn); + let (pos_x, pos_y) = ev_btn.get_position(); + let iter = tree + .get_path_at_pos(pos_x as i32, pos_y as i32) + .and_then(|(path, _, _, _)| path) + .and_then(|path| store.get_iter(&path)); + let file_type = iter + .as_ref() + .and_then(|iter| { + store + .get_value(&iter, Column::FileType as i32) + .get::() + }); + // Enable the "Go To Directory" action only if the user clicked on a folder. + cd_action.set_enabled(file_type == Some(FileType::Dir as u8)); + let path = iter + .and_then(|iter| { + store + .get_value(&iter, Column::Path as i32) + .get::() + }); + state_ref.borrow_mut().selected_path = path; + } + Inhibit(false) + }), + ); + + // Show / hide hidden files when corresponding menu item is toggled. + self.comps.show_hidden_checkbox.connect_toggled(clone!(state_ref, store => move |ev| { + let mut state = state_ref.borrow_mut(); + state.show_hidden = ev.get_active(); + tree_reload(&store, &state); + })); + } +} + +/// Compare function for dir entries. +/// +/// Sorts directories above files. +fn cmp_dirs_first(lhs: &DirEntry, rhs: &DirEntry) -> io::Result { + let lhs_metadata = fs::metadata(lhs.path())?; + let rhs_metadata = fs::metadata(rhs.path())?; + if lhs_metadata.file_type() == rhs_metadata.file_type() { + Ok(lhs.path().cmp(&rhs.path())) + } else { + if lhs_metadata.is_dir() { + Ok(Ordering::Less) + } else { + Ok(Ordering::Greater) + } + } +} + +/// Clears an repopulate the entire tree. +fn tree_reload(store: >k::TreeStore, state: &State) { + let dir = &state.current_dir; + store.clear(); + populate_tree_nodes(store, state, dir, None); +} + +/// Updates the dirctory list on top of the file browser. +/// +/// The list represents the path the the current working directory. If the new cwd is a parent of +/// the old one, the list is kept and only the active entry is updated. Otherwise, the list is +/// replaced with the new path and the last entry is marked active. +fn update_dir_list(dir: &str, dir_list_model: >k::TreeStore, dir_list: >k::ComboBox) { + // The current working directory path. + let complete_path = Path::new(dir).canonicalize().unwrap(); + let mut path = PathBuf::new(); + let mut components = complete_path.components(); + let mut next = components.next(); + + // Iterator over existing dir_list model. + let mut dir_list_iter = dir_list_model.get_iter_first(); + + // Whether existing entries up to the current position of dir_list_iter are a prefix of the + // new current working directory path. + let mut is_prefix = true; + + // Iterate over components of the cwd. Simultaneously move dir_list_iter forward. + while let Some(dir) = next { + next = components.next(); + let dir_name = &*dir.as_os_str().to_string_lossy(); + // Assemble path up to current component. + path.push(Path::new(&dir)); + let path_str = path.to_str().unwrap_or_else(|| { + error!( + "Could not convert path to string: {}\n + Directory chooser will not work for that entry.", + path.to_string_lossy() + ); + "" + }); + // Use the current entry of dir_list, if any, otherwise append a new one. + let current_iter = dir_list_iter.unwrap_or_else(|| dir_list_model.append(None)); + // Check if the current entry is still part of the new cwd. + if is_prefix && dir_list_model.get_value(¤t_iter, 0).get::<&str>() != Some(&dir_name) + { + is_prefix = false; + } + if next.is_some() { + // Update dir_list entry. + dir_list_model.set( + ¤t_iter, + &[0, 1, 2], + &[&dir_name, &ICON_FOLDER_CLOSED, &path_str], + ); + } else { + // We reached the last component of the new cwd path. Set the active entry of dir_list + // to this one. + dir_list_model.set( + ¤t_iter, + &[0, 1, 2], + &[&dir_name, &ICON_FOLDER_OPEN, &path_str], + ); + dir_list.set_active_iter(¤t_iter); + }; + // Advance dir_list_iter. + dir_list_iter = if dir_list_model.iter_next(¤t_iter) { + Some(current_iter) + } else { + None + } + } + // We updated the dir list to the point of the current working directory. + if let Some(iter) = dir_list_iter { + if is_prefix { + // If we didn't change any entries to this point and the list contains further entries, + // the remaining ones are subdirectories of the cwd and we keep them. + loop { + dir_list_model.set(&iter, &[1], &[&ICON_FOLDER_CLOSED]); + if !dir_list_model.iter_next(&iter) { + break; + } + } + } else { + // If we needed to change entries, the following ones are not directories under the + // cwd and we clear them. + while dir_list_model.remove(&iter) {} + } + } +} + +/// Populates one level, i.e. one directory of the file browser tree. +fn populate_tree_nodes( + store: >k::TreeStore, + state: &State, + dir: &str, + parent: Option<>k::TreeIter>, +) { + let path = Path::new(dir); + let iter = path.read_dir() + .expect("read dir failed") + .filter_map(Result::ok); + let mut entries: Vec = if state.show_hidden { + iter.collect() + } else { + iter.filter(|entry| !entry.file_name().to_string_lossy().starts_with(".")) + .filter(|entry| !entry.file_name().to_string_lossy().ends_with("~")) + .collect() + }; + entries.sort_unstable_by(|lhs, rhs| cmp_dirs_first(lhs, rhs).unwrap_or(Ordering::Equal)); + for entry in entries { + let path = if let Some(path) = entry.path().to_str() { + path.to_owned() + } else { + // Skip paths that contain invalid unicode. + continue; + }; + let filename = entry.file_name().to_str().unwrap().to_owned(); + let file_type = if let Ok(metadata) = fs::metadata(entry.path()) { + let file_type = metadata.file_type(); + if file_type.is_dir() { + FileType::Dir + } else if file_type.is_file() { + FileType::File + } else { + continue; + } + } else { + // In case of invalid symlinks, we cannot obtain metadata. + continue; + }; + let icon = match file_type { + FileType::Dir => ICON_FOLDER_CLOSED, + FileType::File => ICON_FILE, + }; + // When we get until here, we want to show the entry. Append it to the tree. + let iter = store.append(parent); + store.set( + &iter, + &[0, 1, 2, 3], + &[&filename, &path, &(file_type as u8), &icon], + ); + // For directories, check whether the directory is empty. If not, append a single empty + // entry, so the expand arrow is shown. Its contents are dynamically populated when + // expanded (see `init`). + if let FileType::Dir = file_type { + let not_empty = if let Ok(mut dir) = entry.path().read_dir() { + dir.next().is_some() + } else { + false + }; + if not_empty { + let iter = store.append(&iter); + store.set(&iter, &[], &[]); + } + } + } +} + +fn get_current_dir(nvim: &mut NeovimRef) -> String { + nvim.eval("getcwd()") + .as_ref() + .ok() + .and_then(|s| s.as_str()) + .expect("Couldn't get working directory") + .to_owned() +} + +/// Reveals and selects the given file in the file browser. +/// +/// Returns `true` if the file could be successfully revealed. +fn reveal_path_in_tree(store: >k::TreeStore, tree: >k::TreeView, rel_file_path: &Path) -> bool { + let mut tree_path = gtk::TreePath::new(); + 'components: for component in rel_file_path.components() { + if let Component::Normal(component) = component { + tree_path.down(); + while let Some(iter) = store.get_iter(&tree_path) { + let entry_value = store.get_value(&iter, Column::Filename as i32); + let entry = entry_value.get::<&str>().unwrap(); + if component == entry { + tree.expand_row(&tree_path, false); + continue 'components; + } + tree_path.next(); + } + return false; + } else { + return false; + } + } + if tree_path.get_depth() == 0 { + return false; + } + tree.set_cursor(&tree_path, None, false); + true +} diff --git a/src/main.rs b/src/main.rs index 92f0cb9..75f9600 100644 --- a/src/main.rs +++ b/src/main.rs @@ -48,6 +48,7 @@ mod popup_menu; mod project; mod tabline; mod error; +mod file_browser; mod subscriptions; use std::env; diff --git a/src/ui.rs b/src/ui.rs index 5b4bc37..a01706f 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -7,7 +7,7 @@ use std::sync::Arc; use gdk; use gtk; use gtk::prelude::*; -use gtk::{AboutDialog, ApplicationWindow, Button, HeaderBar, SettingsExt}; +use gtk::{AboutDialog, ApplicationWindow, Button, HeaderBar, Orientation, Paned, SettingsExt}; use gio::prelude::*; use gio::{Menu, MenuExt, MenuItem, SimpleAction}; use toml; @@ -17,6 +17,7 @@ use shell::{self, Shell, ShellOptions}; use shell_dlg; use project::Projects; use plug_manager; +use file_browser::FileBrowserWidget; use subscriptions::SubscriptionHandle; macro_rules! clone { @@ -38,6 +39,7 @@ macro_rules! clone { const DEFAULT_WIDTH: i32 = 800; const DEFAULT_HEIGHT: i32 = 600; +const DEFAULT_SIDEBAR_WIDTH: i32 = 200; pub struct Ui { initialized: bool, @@ -46,6 +48,7 @@ pub struct Ui { shell: Rc>, projects: Rc>, plug_manager: Arc>, + file_browser: Arc>, } pub struct Components { @@ -88,6 +91,7 @@ impl Ui { let plug_manager = plug_manager::Manager::new(); let plug_manager = Arc::new(UiMutex::new(plug_manager)); + let file_browser = Arc::new(UiMutex::new(FileBrowserWidget::new())); let comps = Arc::new(UiMutex::new(Components::new())); let settings = Rc::new(RefCell::new(Settings::new())); let shell = Rc::new(RefCell::new(Shell::new(settings.clone(), options))); @@ -102,6 +106,7 @@ impl Ui { settings, projects, plug_manager, + file_browser, } } @@ -115,6 +120,7 @@ impl Ui { settings.init(); let window = ApplicationWindow::new(app); + let main = Paned::new(Orientation::Horizontal); { // initialize window from comps @@ -144,8 +150,11 @@ impl Ui { comps.window_state.current_width, comps.window_state.current_height, ); + + main.set_position(comps.window_state.sidebar_width); } else { window.set_default_size(DEFAULT_WIDTH, DEFAULT_HEIGHT); + main.set_position(DEFAULT_SIDEBAR_WIDTH); } } @@ -164,10 +173,28 @@ impl Ui { None }; + let show_sidebar_action = + SimpleAction::new_stateful("show-sidebar", None, &false.to_variant()); + let file_browser_ref = self.file_browser.clone(); let comps_ref = self.comps.clone(); - window.connect_size_allocate(move |window, _| { - gtk_window_size_allocate(window, &mut *comps_ref.borrow_mut()) + show_sidebar_action.connect_change_state(move |action, value| { + if let Some(ref value) = *value { + action.set_state(value); + let is_active = value.get::().unwrap(); + file_browser_ref.borrow().set_visible(is_active); + comps_ref.borrow_mut().window_state.show_sidebar = is_active; + } }); + app.add_action(&show_sidebar_action); + + let comps_ref = self.comps.clone(); + window.connect_size_allocate(clone!(main => move |window, _| { + gtk_window_size_allocate( + window, + &mut *comps_ref.borrow_mut(), + &main, + ); + })); let comps_ref = self.comps.clone(); window.connect_window_state_event(move |_, event| { @@ -181,10 +208,21 @@ impl Ui { }); let shell = self.shell.borrow(); - window.add(&**shell); + let file_browser = self.file_browser.borrow(); + main.pack1(&**file_browser, false, false); + main.pack2(&**shell, true, false); + + window.add(&main); window.show_all(); + if restore_win_state { + // Hide sidebar, if it wasn't shown last time. + // Has to be done after show_all(), so it won't be shown again. + let show_sidebar = self.comps.borrow().window_state.show_sidebar; + show_sidebar_action.change_state(&show_sidebar.to_variant()); + } + let comps_ref = self.comps.clone(); let update_title = shell.state.borrow().subscribe( "BufEnter,DirChanged", @@ -225,12 +263,14 @@ impl Ui { })); let state_ref = self.shell.borrow().state.clone(); + let file_browser_ref = self.file_browser.clone(); let plug_manager_ref = self.plug_manager.clone(); shell.set_nvim_started_cb(Some(move || { let state = state_ref.borrow(); plug_manager_ref .borrow_mut() .init_nvim_client(state_ref.borrow().nvim_clone()); + file_browser_ref.borrow_mut().init(&state); state.set_autocmds(); state.run_now(&update_title); if let Some(ref update_subtitle) = update_subtitle { @@ -297,6 +337,10 @@ impl Ui { section.append_item(&MenuItem::new("New Window", "app.new-window")); menu.append_section(None, §ion); + let section = Menu::new(); + section.append_item(&MenuItem::new("Sidebar", "app.show-sidebar")); + menu.append_section(None, §ion); + let section = Menu::new(); section.append_item(&MenuItem::new("Plugins", "app.Plugins")); section.append_item(&MenuItem::new("About", "app.HelpAbout")); @@ -353,12 +397,19 @@ fn gtk_delete(comps: &UiMutex, shell: &RefCell) -> Inhibit { }) } -fn gtk_window_size_allocate(app_window: >k::ApplicationWindow, comps: &mut Components) { +fn gtk_window_size_allocate( + app_window: >k::ApplicationWindow, + comps: &mut Components, + main: &Paned, +) { if !app_window.is_maximized() { let (current_width, current_height) = app_window.get_size(); comps.window_state.current_width = current_width; comps.window_state.current_height = current_height; } + if comps.window_state.show_sidebar { + comps.window_state.sidebar_width = main.get_position(); + } } fn gtk_window_state_event(event: &gdk::EventWindowState, comps: &mut Components) { @@ -372,6 +423,8 @@ struct WindowState { current_width: i32, current_height: i32, is_maximized: bool, + show_sidebar: bool, + sidebar_width: i32, } impl WindowState { @@ -380,6 +433,8 @@ impl WindowState { current_width: DEFAULT_WIDTH, current_height: DEFAULT_HEIGHT, is_maximized: false, + show_sidebar: false, + sidebar_width: DEFAULT_SIDEBAR_WIDTH, } } }