diff --git a/Cargo.lock b/Cargo.lock index ce727a9c6c..be8a8a74bf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8676,6 +8676,7 @@ dependencies = [ "gpui", "indoc", "install_cli", + "itertools", "language", "lazy_static", "log", diff --git a/crates/file_finder/src/file_finder.rs b/crates/file_finder/src/file_finder.rs index b318f1d167..8ba58819a3 100644 --- a/crates/file_finder/src/file_finder.rs +++ b/crates/file_finder/src/file_finder.rs @@ -25,10 +25,11 @@ pub struct FileFinderDelegate { latest_search_id: usize, latest_search_did_cancel: bool, latest_search_query: Option>, - relative_to: Option>, + currently_opened_path: Option, matches: Vec, selected: Option<(usize, Arc)>, cancel_flag: Arc, + history_items: Vec, } actions!(file_finder, [Toggle]); @@ -38,17 +39,26 @@ pub fn init(cx: &mut AppContext) { FileFinder::init(cx); } +const MAX_RECENT_SELECTIONS: usize = 20; + fn toggle_file_finder(workspace: &mut Workspace, _: &Toggle, cx: &mut ViewContext) { workspace.toggle_modal(cx, |workspace, cx| { - let relative_to = workspace + let history_items = workspace.recent_navigation_history(Some(MAX_RECENT_SELECTIONS), cx); + let currently_opened_path = workspace .active_item(cx) - .and_then(|item| item.project_path(cx)) - .map(|project_path| project_path.path.clone()); + .and_then(|item| item.project_path(cx)); + let project = workspace.project().clone(); let workspace = cx.handle().downgrade(); let finder = cx.add_view(|cx| { Picker::new( - FileFinderDelegate::new(workspace, project, relative_to, cx), + FileFinderDelegate::new( + workspace, + project, + currently_opened_path, + history_items, + cx, + ), cx, ) }); @@ -106,7 +116,8 @@ impl FileFinderDelegate { pub fn new( workspace: WeakViewHandle, project: ModelHandle, - relative_to: Option>, + currently_opened_path: Option, + history_items: Vec, cx: &mut ViewContext, ) -> Self { cx.observe(&project, |picker, _, cx| { @@ -120,10 +131,11 @@ impl FileFinderDelegate { latest_search_id: 0, latest_search_did_cancel: false, latest_search_query: None, - relative_to, + currently_opened_path, matches: Vec::new(), selected: None, cancel_flag: Arc::new(AtomicBool::new(false)), + history_items, } } @@ -132,7 +144,10 @@ impl FileFinderDelegate { query: PathLikeWithPosition, cx: &mut ViewContext, ) -> Task<()> { - let relative_to = self.relative_to.clone(); + let relative_to = self + .currently_opened_path + .as_ref() + .map(|project_path| Arc::clone(&project_path.path)); let worktrees = self .project .read(cx) @@ -239,6 +254,23 @@ impl PickerDelegate for FileFinderDelegate { if raw_query.is_empty() { self.latest_search_id = post_inc(&mut self.search_count); self.matches.clear(); + + self.matches = self + .currently_opened_path + .iter() // if exists, bubble the currently opened path to the top + .chain(self.history_items.iter().filter(|history_item| { + Some(*history_item) != self.currently_opened_path.as_ref() + })) + .enumerate() + .map(|(i, history_item)| PathMatch { + score: i as f64, + positions: Vec::new(), + worktree_id: history_item.worktree_id.0, + path: Arc::clone(&history_item.path), + path_prefix: "".into(), + distance_to_relative_ancestor: usize::MAX, + }) + .collect(); cx.notify(); Task::ready(()) } else { @@ -265,7 +297,6 @@ impl PickerDelegate for FileFinderDelegate { worktree_id: WorktreeId::from_usize(m.worktree_id), path: m.path.clone(), }; - let open_task = workspace.update(cx, |workspace, cx| { workspace.open_path(project_path.clone(), None, true, cx) }); @@ -301,7 +332,6 @@ impl PickerDelegate for FileFinderDelegate { .log_err(); } } - workspace .update(&mut cx, |workspace, cx| workspace.dismiss_modal(cx)) .log_err(); @@ -343,14 +373,14 @@ impl PickerDelegate for FileFinderDelegate { #[cfg(test)] mod tests { - use std::time::Duration; + use std::{assert_eq, collections::HashMap, time::Duration}; use super::*; use editor::Editor; - use gpui::TestAppContext; + use gpui::{TestAppContext, ViewHandle}; use menu::{Confirm, SelectNext}; use serde_json::json; - use workspace::{AppState, Workspace}; + use workspace::{AppState, Pane, Workspace}; #[ctor::ctor] fn init_logger() { @@ -604,6 +634,7 @@ mod tests { workspace.downgrade(), workspace.read(cx).project().clone(), None, + Vec::new(), cx, ), cx, @@ -688,6 +719,7 @@ mod tests { workspace.downgrade(), workspace.read(cx).project().clone(), None, + Vec::new(), cx, ), cx, @@ -723,6 +755,7 @@ mod tests { workspace.downgrade(), workspace.read(cx).project().clone(), None, + Vec::new(), cx, ), cx, @@ -788,6 +821,7 @@ mod tests { workspace.downgrade(), workspace.read(cx).project().clone(), None, + Vec::new(), cx, ), cx, @@ -833,17 +867,26 @@ mod tests { let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await; let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project, cx)); + let worktree_id = cx.read(|cx| { + let worktrees = workspace.read(cx).worktrees(cx).collect::>(); + assert_eq!(worktrees.len(), 1); + WorktreeId(worktrees[0].id()) + }); // When workspace has an active item, sort items which are closer to that item // first when they have the same name. In this case, b.txt is closer to dir2's a.txt // so that one should be sorted earlier - let b_path = Some(Arc::from(Path::new("/root/dir2/b.txt"))); + let b_path = Some(ProjectPath { + worktree_id, + path: Arc::from(Path::new("/root/dir2/b.txt")), + }); let (_, finder) = cx.add_window(|cx| { Picker::new( FileFinderDelegate::new( workspace.downgrade(), workspace.read(cx).project().clone(), b_path, + Vec::new(), cx, ), cx, @@ -888,6 +931,7 @@ mod tests { workspace.downgrade(), workspace.read(cx).project().clone(), None, + Vec::new(), cx, ), cx, @@ -904,6 +948,243 @@ mod tests { }); } + #[gpui::test] + async fn test_query_history( + deterministic: Arc, + cx: &mut gpui::TestAppContext, + ) { + let app_state = init_test(cx); + + app_state + .fs + .as_fake() + .insert_tree( + "/src", + json!({ + "test": { + "first.rs": "// First Rust file", + "second.rs": "// Second Rust file", + "third.rs": "// Third Rust file", + } + }), + ) + .await; + + let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await; + let (window_id, workspace) = cx.add_window(|cx| Workspace::test_new(project, cx)); + let worktree_id = cx.read(|cx| { + let worktrees = workspace.read(cx).worktrees(cx).collect::>(); + assert_eq!(worktrees.len(), 1); + WorktreeId(worktrees[0].id()) + }); + + // Open and close panels, getting their history items afterwards. + // Ensure history items get populated with opened items, and items are kept in a certain order. + // The history lags one opened buffer behind, since it's updated in the search panel only on its reopen. + // + // TODO: without closing, the opened items do not propagate their history changes for some reason + // it does work in real app though, only tests do not propagate. + + let initial_history = open_close_queried_buffer( + "fir", + 1, + "first.rs", + window_id, + &workspace, + &deterministic, + cx, + ) + .await; + assert!( + initial_history.is_empty(), + "Should have no history before opening any files" + ); + + let history_after_first = open_close_queried_buffer( + "sec", + 1, + "second.rs", + window_id, + &workspace, + &deterministic, + cx, + ) + .await; + assert_eq!( + history_after_first, + vec![ProjectPath { + worktree_id, + path: Arc::from(Path::new("test/first.rs")), + }], + "Should show 1st opened item in the history when opening the 2nd item" + ); + + let history_after_second = open_close_queried_buffer( + "thi", + 1, + "third.rs", + window_id, + &workspace, + &deterministic, + cx, + ) + .await; + assert_eq!( + history_after_second, + vec![ + ProjectPath { + worktree_id, + path: Arc::from(Path::new("test/second.rs")), + }, + ProjectPath { + worktree_id, + path: Arc::from(Path::new("test/first.rs")), + }, + ], + "Should show 1st and 2nd opened items in the history when opening the 3rd item. \ +2nd item should be the first in the history, as the last opened." + ); + + let history_after_third = open_close_queried_buffer( + "sec", + 1, + "second.rs", + window_id, + &workspace, + &deterministic, + cx, + ) + .await; + assert_eq!( + history_after_third, + vec![ + ProjectPath { + worktree_id, + path: Arc::from(Path::new("test/third.rs")), + }, + ProjectPath { + worktree_id, + path: Arc::from(Path::new("test/second.rs")), + }, + ProjectPath { + worktree_id, + path: Arc::from(Path::new("test/first.rs")), + }, + ], + "Should show 1st, 2nd and 3rd opened items in the history when opening the 2nd item again. \ +3rd item should be the first in the history, as the last opened." + ); + + let history_after_second_again = open_close_queried_buffer( + "thi", + 1, + "third.rs", + window_id, + &workspace, + &deterministic, + cx, + ) + .await; + assert_eq!( + history_after_second_again, + vec![ + ProjectPath { + worktree_id, + path: Arc::from(Path::new("test/second.rs")), + }, + ProjectPath { + worktree_id, + path: Arc::from(Path::new("test/third.rs")), + }, + ProjectPath { + worktree_id, + path: Arc::from(Path::new("test/first.rs")), + }, + ], + "Should show 1st, 2nd and 3rd opened items in the history when opening the 3rd item again. \ +2nd item, as the last opened, 3rd item should go next as it was opened right before." + ); + } + + async fn open_close_queried_buffer( + input: &str, + expected_matches: usize, + expected_editor_title: &str, + window_id: usize, + workspace: &ViewHandle, + deterministic: &gpui::executor::Deterministic, + cx: &mut gpui::TestAppContext, + ) -> Vec { + cx.dispatch_action(window_id, Toggle); + let finder = cx.read(|cx| workspace.read(cx).modal::().unwrap()); + finder + .update(cx, |finder, cx| { + finder.delegate_mut().update_matches(input.to_string(), cx) + }) + .await; + let history_items = finder.read_with(cx, |finder, _| { + assert_eq!( + finder.delegate().matches.len(), + expected_matches, + "Unexpected number of matches found for query {input}" + ); + finder.delegate().history_items.clone() + }); + + let active_pane = cx.read(|cx| workspace.read(cx).active_pane().clone()); + cx.dispatch_action(window_id, SelectNext); + cx.dispatch_action(window_id, Confirm); + deterministic.run_until_parked(); + active_pane + .condition(cx, |pane, _| pane.active_item().is_some()) + .await; + cx.read(|cx| { + let active_item = active_pane.read(cx).active_item().unwrap(); + let active_editor_title = active_item + .as_any() + .downcast_ref::() + .unwrap() + .read(cx) + .title(cx); + assert_eq!( + expected_editor_title, active_editor_title, + "Unexpected editor title for query {input}" + ); + }); + + let mut original_items = HashMap::new(); + cx.read(|cx| { + for pane in workspace.read(cx).panes() { + let pane_id = pane.id(); + let pane = pane.read(cx); + let insertion_result = original_items.insert(pane_id, pane.items().count()); + assert!(insertion_result.is_none(), "Pane id {pane_id} collision"); + } + }); + workspace.update(cx, |workspace, cx| { + Pane::close_active_item(workspace, &workspace::CloseActiveItem, cx); + }); + deterministic.run_until_parked(); + cx.read(|cx| { + for pane in workspace.read(cx).panes() { + let pane_id = pane.id(); + let pane = pane.read(cx); + match original_items.remove(&pane_id) { + Some(original_items) => { + assert_eq!( + pane.items().count(), + original_items.saturating_sub(1), + "Pane id {pane_id} should have item closed" + ); + } + None => panic!("Pane id {pane_id} not found in original items"), + } + } + }); + + history_items + } + fn init_test(cx: &mut TestAppContext) -> Arc { cx.foreground().forbid_parking(); cx.update(|cx| { diff --git a/crates/project/src/worktree.rs b/crates/project/src/worktree.rs index 403d893425..550a27ea9f 100644 --- a/crates/project/src/worktree.rs +++ b/crates/project/src/worktree.rs @@ -58,7 +58,7 @@ use sum_tree::{Bias, Edit, SeekTarget, SumTree, TreeMap, TreeSet}; use util::{paths::HOME, ResultExt, TakeUntilExt, TryFutureExt}; #[derive(Copy, Clone, PartialEq, Eq, Debug, Hash, PartialOrd, Ord)] -pub struct WorktreeId(usize); +pub struct WorktreeId(pub usize); pub enum Worktree { Local(LocalWorktree), diff --git a/crates/workspace/Cargo.toml b/crates/workspace/Cargo.toml index 26797e8d6c..33e5e7aefe 100644 --- a/crates/workspace/Cargo.toml +++ b/crates/workspace/Cargo.toml @@ -38,6 +38,7 @@ theme = { path = "../theme" } util = { path = "../util" } async-recursion = "1.0.0" +itertools = "0.10" bincode = "1.2.1" anyhow.workspace = true futures.workspace = true diff --git a/crates/workspace/src/dock.rs b/crates/workspace/src/dock.rs index d1ec80de95..beec6a0515 100644 --- a/crates/workspace/src/dock.rs +++ b/crates/workspace/src/dock.rs @@ -12,6 +12,7 @@ use gpui::{ platform::{CursorStyle, MouseButton}, AnyElement, AppContext, Border, Element, SizeConstraint, ViewContext, ViewHandle, }; +use std::sync::{atomic::AtomicUsize, Arc}; use theme::Theme; pub use toggle_dock_button::ToggleDockButton; @@ -170,13 +171,21 @@ impl Dock { pub fn new( default_item_factory: DockDefaultItemFactory, background_actions: BackgroundActions, + pane_history_timestamp: Arc, cx: &mut ViewContext, ) -> Self { let position = DockPosition::Hidden(settings::get::(cx).default_dock_anchor); let workspace = cx.weak_handle(); - let pane = - cx.add_view(|cx| Pane::new(workspace, Some(position.anchor()), background_actions, cx)); + let pane = cx.add_view(|cx| { + Pane::new( + workspace, + Some(position.anchor()), + background_actions, + pane_history_timestamp, + cx, + ) + }); pane.update(cx, |pane, cx| { pane.set_active(false, cx); }); diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index 200f83700b..368afcd16c 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -30,7 +30,17 @@ use gpui::{ }; use project::{Project, ProjectEntryId, ProjectPath}; use serde::Deserialize; -use std::{any::Any, cell::RefCell, cmp, mem, path::Path, rc::Rc}; +use std::{ + any::Any, + cell::RefCell, + cmp, mem, + path::Path, + rc::Rc, + sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }, +}; use theme::Theme; use util::ResultExt; @@ -159,6 +169,8 @@ pub struct ItemNavHistory { item: Rc, } +pub struct PaneNavHistory(Rc>); + struct NavHistory { mode: NavigationMode, backward_stack: VecDeque, @@ -166,6 +178,7 @@ struct NavHistory { closed_stack: VecDeque, paths_by_item: HashMap, pane: WeakViewHandle, + next_timestamp: Arc, } #[derive(Copy, Clone)] @@ -187,6 +200,7 @@ impl Default for NavigationMode { pub struct NavigationEntry { pub item: Rc, pub data: Option>, + pub timestamp: usize, } struct DraggedItem { @@ -226,6 +240,7 @@ impl Pane { workspace: WeakViewHandle, docked: Option, background_actions: BackgroundActions, + next_timestamp: Arc, cx: &mut ViewContext, ) -> Self { let pane_view_id = cx.view_id(); @@ -249,6 +264,7 @@ impl Pane { closed_stack: Default::default(), paths_by_item: Default::default(), pane: handle.clone(), + next_timestamp, })), toolbar: cx.add_view(|_| Toolbar::new(handle)), tab_bar_context_menu: TabBarContextMenu { @@ -292,6 +308,10 @@ impl Pane { } } + pub fn nav_history(&self) -> PaneNavHistory { + PaneNavHistory(self.nav_history.clone()) + } + pub fn go_back( workspace: &mut Workspace, pane: Option>, @@ -1942,6 +1962,7 @@ impl NavHistory { self.backward_stack.push_back(NavigationEntry { item, data: data.map(|data| Box::new(data) as Box), + timestamp: self.next_timestamp.fetch_add(1, Ordering::SeqCst), }); self.forward_stack.clear(); } @@ -1952,6 +1973,7 @@ impl NavHistory { self.forward_stack.push_back(NavigationEntry { item, data: data.map(|data| Box::new(data) as Box), + timestamp: self.next_timestamp.fetch_add(1, Ordering::SeqCst), }); } NavigationMode::GoingForward => { @@ -1961,6 +1983,7 @@ impl NavHistory { self.backward_stack.push_back(NavigationEntry { item, data: data.map(|data| Box::new(data) as Box), + timestamp: self.next_timestamp.fetch_add(1, Ordering::SeqCst), }); } NavigationMode::ClosingItem => { @@ -1970,6 +1993,7 @@ impl NavHistory { self.closed_stack.push_back(NavigationEntry { item, data: data.map(|data| Box::new(data) as Box), + timestamp: self.next_timestamp.fetch_add(1, Ordering::SeqCst), }); } } @@ -1985,6 +2009,31 @@ impl NavHistory { } } +impl PaneNavHistory { + pub fn for_each_entry( + &self, + cx: &AppContext, + mut f: impl FnMut(&NavigationEntry, ProjectPath), + ) { + let borrowed_history = self.0.borrow(); + borrowed_history + .forward_stack + .iter() + .chain(borrowed_history.backward_stack.iter()) + .chain(borrowed_history.closed_stack.iter()) + .for_each(|entry| { + if let Some(path) = borrowed_history.paths_by_item.get(&entry.item.id()) { + f(entry, path.clone()); + } else if let Some(item) = entry.item.upgrade(cx) { + let path = item.project_path(cx); + if let Some(path) = path { + f(entry, path); + } + } + }) + } +} + pub struct PaneBackdrop { child_view: usize, child: AnyElement, diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 8ca6358f9a..28ad294798 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -47,6 +47,7 @@ use gpui::{ WindowContext, }; use item::{FollowableItem, FollowableItemHandle, Item, ItemHandle, ProjectItem}; +use itertools::Itertools; use language::{LanguageRegistry, Rope}; use std::{ any::TypeId, @@ -55,7 +56,7 @@ use std::{ future::Future, path::{Path, PathBuf}, str, - sync::Arc, + sync::{atomic::AtomicUsize, Arc}, time::Duration, }; @@ -481,6 +482,7 @@ pub struct Workspace { _window_subscriptions: [Subscription; 3], _apply_leader_updates: Task>, _observe_current_user: Task>, + pane_history_timestamp: Arc, } #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] @@ -542,15 +544,24 @@ impl Workspace { .detach(); let weak_handle = cx.weak_handle(); + let pane_history_timestamp = Arc::new(AtomicUsize::new(0)); - let center_pane = cx - .add_view(|cx| Pane::new(weak_handle.clone(), None, app_state.background_actions, cx)); + let center_pane = cx.add_view(|cx| { + Pane::new( + weak_handle.clone(), + None, + app_state.background_actions, + pane_history_timestamp.clone(), + cx, + ) + }); cx.subscribe(¢er_pane, Self::handle_pane_event).detach(); cx.focus(¢er_pane); cx.emit(Event::PaneAdded(center_pane.clone())); let dock = Dock::new( app_state.dock_default_item_factory, app_state.background_actions, + pane_history_timestamp.clone(), cx, ); let dock_pane = dock.pane().clone(); @@ -665,6 +676,7 @@ impl Workspace { _apply_leader_updates, leader_updates_tx, _window_subscriptions: subscriptions, + pane_history_timestamp, }; this.project_remote_id_changed(project.read(cx).remote_id(), cx); cx.defer(|this, cx| this.update_window_title(cx)); @@ -825,6 +837,39 @@ impl Workspace { &self.project } + pub fn recent_navigation_history( + &self, + limit: Option, + cx: &AppContext, + ) -> Vec { + let mut history: HashMap = HashMap::default(); + for pane in &self.panes { + let pane = pane.read(cx); + pane.nav_history() + .for_each_entry(cx, |entry, project_path| { + let timestamp = entry.timestamp; + match history.entry(project_path) { + hash_map::Entry::Occupied(mut entry) => { + if ×tamp > entry.get() { + entry.insert(timestamp); + } + } + hash_map::Entry::Vacant(entry) => { + entry.insert(timestamp); + } + } + }); + } + + history + .into_iter() + .sorted_by_key(|(_, timestamp)| *timestamp) + .map(|(project_path, _)| project_path) + .rev() + .take(limit.unwrap_or(usize::MAX)) + .collect() + } + pub fn client(&self) -> &Client { &self.app_state.client } @@ -1386,6 +1431,7 @@ impl Workspace { self.weak_handle(), None, self.app_state.background_actions, + self.pane_history_timestamp.clone(), cx, ) });