- Change the branch button's tooltip to be more accurate given it displays more stuff than only branches - Hide the worktree dropdown menu when in a non-Git repo project - Improve provisioned title truncation - Remove the plus icon from the "view more" item to improve sidebar's overall feel - Remove the always visible "new thread" button but make it visible only when you're in an empty thread state - Add worktree icon in the thread item and tooltip with full path - Space out the worktree name from the branch name in the git picker in the title bar - Swap order of views in the git picker to "worktree | branches | stash" - Improve the "creating worktree" loading indicator --- <!-- Check before requesting review: --> - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - N/A
425 lines
14 KiB
Rust
425 lines
14 KiB
Rust
use std::collections::HashSet;
|
|
use std::sync::Arc;
|
|
|
|
use chrono::{DateTime, Utc};
|
|
use fuzzy::{StringMatch, StringMatchCandidate};
|
|
use gpui::{
|
|
Action, AnyElement, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
|
|
Subscription, Task, WeakEntity, Window,
|
|
};
|
|
use picker::{
|
|
Picker, PickerDelegate,
|
|
highlighted_match_with_paths::{HighlightedMatch, HighlightedMatchWithPaths},
|
|
};
|
|
use remote::RemoteConnectionOptions;
|
|
use settings::Settings;
|
|
use ui::{KeyBinding, ListItem, ListItemSpacing, Tooltip, prelude::*};
|
|
use ui_input::ErasedEditor;
|
|
use util::{ResultExt, paths::PathExt};
|
|
use workspace::{
|
|
MultiWorkspace, OpenOptions, PathList, SerializedWorkspaceLocation, Workspace, WorkspaceDb,
|
|
WorkspaceId, notifications::DetachAndPromptErr,
|
|
};
|
|
|
|
use crate::{highlights_for_path, icon_for_remote_connection, open_remote_project};
|
|
|
|
pub struct SidebarRecentProjects {
|
|
pub picker: Entity<Picker<SidebarRecentProjectsDelegate>>,
|
|
_subscription: Subscription,
|
|
}
|
|
|
|
impl SidebarRecentProjects {
|
|
pub fn popover(
|
|
workspace: WeakEntity<Workspace>,
|
|
sibling_workspace_ids: HashSet<WorkspaceId>,
|
|
_focus_handle: FocusHandle,
|
|
window: &mut Window,
|
|
cx: &mut App,
|
|
) -> Entity<Self> {
|
|
let fs = workspace
|
|
.upgrade()
|
|
.map(|ws| ws.read(cx).app_state().fs.clone());
|
|
|
|
cx.new(|cx| {
|
|
let delegate = SidebarRecentProjectsDelegate {
|
|
workspace,
|
|
sibling_workspace_ids,
|
|
workspaces: Vec::new(),
|
|
filtered_workspaces: Vec::new(),
|
|
selected_index: 0,
|
|
has_any_non_local_projects: false,
|
|
focus_handle: cx.focus_handle(),
|
|
};
|
|
|
|
let picker: Entity<Picker<SidebarRecentProjectsDelegate>> = cx.new(|cx| {
|
|
Picker::list(delegate, window, cx)
|
|
.list_measure_all()
|
|
.show_scrollbar(true)
|
|
});
|
|
|
|
let picker_focus_handle = picker.focus_handle(cx);
|
|
picker.update(cx, |picker, _| {
|
|
picker.delegate.focus_handle = picker_focus_handle;
|
|
});
|
|
|
|
let _subscription =
|
|
cx.subscribe(&picker, |_this: &mut Self, _, _, cx| cx.emit(DismissEvent));
|
|
|
|
let db = WorkspaceDb::global(cx);
|
|
cx.spawn_in(window, async move |this, cx| {
|
|
let Some(fs) = fs else { return };
|
|
let workspaces = db
|
|
.recent_workspaces_on_disk(fs.as_ref())
|
|
.await
|
|
.log_err()
|
|
.unwrap_or_default();
|
|
let workspaces =
|
|
workspace::resolve_worktree_workspaces(workspaces, fs.as_ref()).await;
|
|
this.update_in(cx, move |this, window, cx| {
|
|
this.picker.update(cx, move |picker, cx| {
|
|
picker.delegate.set_workspaces(workspaces);
|
|
picker.update_matches(picker.query(cx), window, cx)
|
|
})
|
|
})
|
|
.ok();
|
|
})
|
|
.detach();
|
|
|
|
picker.focus_handle(cx).focus(window, cx);
|
|
|
|
Self {
|
|
picker,
|
|
_subscription,
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
impl EventEmitter<DismissEvent> for SidebarRecentProjects {}
|
|
|
|
impl Focusable for SidebarRecentProjects {
|
|
fn focus_handle(&self, cx: &App) -> FocusHandle {
|
|
self.picker.focus_handle(cx)
|
|
}
|
|
}
|
|
|
|
impl Render for SidebarRecentProjects {
|
|
fn render(&mut self, _: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
|
v_flex()
|
|
.key_context("SidebarRecentProjects")
|
|
.w(rems(18.))
|
|
.child(self.picker.clone())
|
|
}
|
|
}
|
|
|
|
pub struct SidebarRecentProjectsDelegate {
|
|
workspace: WeakEntity<Workspace>,
|
|
sibling_workspace_ids: HashSet<WorkspaceId>,
|
|
workspaces: Vec<(
|
|
WorkspaceId,
|
|
SerializedWorkspaceLocation,
|
|
PathList,
|
|
DateTime<Utc>,
|
|
)>,
|
|
filtered_workspaces: Vec<StringMatch>,
|
|
selected_index: usize,
|
|
has_any_non_local_projects: bool,
|
|
focus_handle: FocusHandle,
|
|
}
|
|
|
|
impl SidebarRecentProjectsDelegate {
|
|
pub fn set_workspaces(
|
|
&mut self,
|
|
workspaces: Vec<(
|
|
WorkspaceId,
|
|
SerializedWorkspaceLocation,
|
|
PathList,
|
|
DateTime<Utc>,
|
|
)>,
|
|
) {
|
|
self.has_any_non_local_projects = workspaces
|
|
.iter()
|
|
.any(|(_, location, _, _)| !matches!(location, SerializedWorkspaceLocation::Local));
|
|
self.workspaces = workspaces;
|
|
}
|
|
}
|
|
|
|
impl EventEmitter<DismissEvent> for SidebarRecentProjectsDelegate {}
|
|
|
|
impl PickerDelegate for SidebarRecentProjectsDelegate {
|
|
type ListItem = AnyElement;
|
|
|
|
fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
|
|
"Search recent projects…".into()
|
|
}
|
|
|
|
fn render_editor(
|
|
&self,
|
|
editor: &Arc<dyn ErasedEditor>,
|
|
window: &mut Window,
|
|
cx: &mut Context<Picker<Self>>,
|
|
) -> Div {
|
|
h_flex()
|
|
.flex_none()
|
|
.h_9()
|
|
.px_2p5()
|
|
.justify_between()
|
|
.border_b_1()
|
|
.border_color(cx.theme().colors().border_variant)
|
|
.child(editor.render(window, cx))
|
|
}
|
|
|
|
fn match_count(&self) -> usize {
|
|
self.filtered_workspaces.len()
|
|
}
|
|
|
|
fn selected_index(&self) -> usize {
|
|
self.selected_index
|
|
}
|
|
|
|
fn set_selected_index(
|
|
&mut self,
|
|
ix: usize,
|
|
_window: &mut Window,
|
|
_cx: &mut Context<Picker<Self>>,
|
|
) {
|
|
self.selected_index = ix;
|
|
}
|
|
|
|
fn update_matches(
|
|
&mut self,
|
|
query: String,
|
|
_: &mut Window,
|
|
cx: &mut Context<Picker<Self>>,
|
|
) -> Task<()> {
|
|
let query = query.trim_start();
|
|
let smart_case = query.chars().any(|c| c.is_uppercase());
|
|
let is_empty_query = query.is_empty();
|
|
|
|
let current_workspace_id = self
|
|
.workspace
|
|
.upgrade()
|
|
.and_then(|ws| ws.read(cx).database_id());
|
|
|
|
let candidates: Vec<_> = self
|
|
.workspaces
|
|
.iter()
|
|
.enumerate()
|
|
.filter(|(_, (id, _, _, _))| {
|
|
Some(*id) != current_workspace_id && !self.sibling_workspace_ids.contains(id)
|
|
})
|
|
.map(|(id, (_, _, paths, _))| {
|
|
let combined_string = paths
|
|
.ordered_paths()
|
|
.map(|path| path.compact().to_string_lossy().into_owned())
|
|
.collect::<Vec<_>>()
|
|
.join("");
|
|
StringMatchCandidate::new(id, &combined_string)
|
|
})
|
|
.collect();
|
|
|
|
if is_empty_query {
|
|
self.filtered_workspaces = candidates
|
|
.into_iter()
|
|
.map(|candidate| StringMatch {
|
|
candidate_id: candidate.id,
|
|
score: 0.0,
|
|
positions: Vec::new(),
|
|
string: candidate.string,
|
|
})
|
|
.collect();
|
|
} else {
|
|
let mut matches = smol::block_on(fuzzy::match_strings(
|
|
&candidates,
|
|
query,
|
|
smart_case,
|
|
true,
|
|
100,
|
|
&Default::default(),
|
|
cx.background_executor().clone(),
|
|
));
|
|
matches.sort_unstable_by(|a, b| {
|
|
b.score
|
|
.partial_cmp(&a.score)
|
|
.unwrap_or(std::cmp::Ordering::Equal)
|
|
.then_with(|| a.candidate_id.cmp(&b.candidate_id))
|
|
});
|
|
self.filtered_workspaces = matches;
|
|
}
|
|
|
|
self.selected_index = 0;
|
|
Task::ready(())
|
|
}
|
|
|
|
fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
|
|
let Some(hit) = self.filtered_workspaces.get(self.selected_index) else {
|
|
return;
|
|
};
|
|
let Some((_, location, candidate_workspace_paths, _)) =
|
|
self.workspaces.get(hit.candidate_id)
|
|
else {
|
|
return;
|
|
};
|
|
|
|
let Some(workspace) = self.workspace.upgrade() else {
|
|
return;
|
|
};
|
|
|
|
match location {
|
|
SerializedWorkspaceLocation::Local => {
|
|
if let Some(handle) = window.window_handle().downcast::<MultiWorkspace>() {
|
|
let paths = candidate_workspace_paths.paths().to_vec();
|
|
cx.defer(move |cx| {
|
|
if let Some(task) = handle
|
|
.update(cx, |multi_workspace, window, cx| {
|
|
multi_workspace.open_project(paths, window, cx)
|
|
})
|
|
.log_err()
|
|
{
|
|
task.detach_and_log_err(cx);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
SerializedWorkspaceLocation::Remote(connection) => {
|
|
let mut connection = connection.clone();
|
|
workspace.update(cx, |workspace, cx| {
|
|
let app_state = workspace.app_state().clone();
|
|
let replace_window = window.window_handle().downcast::<MultiWorkspace>();
|
|
let open_options = OpenOptions {
|
|
replace_window,
|
|
..Default::default()
|
|
};
|
|
if let RemoteConnectionOptions::Ssh(connection) = &mut connection {
|
|
crate::RemoteSettings::get_global(cx)
|
|
.fill_connection_options_from_settings(connection);
|
|
};
|
|
let paths = candidate_workspace_paths.paths().to_vec();
|
|
cx.spawn_in(window, async move |_, cx| {
|
|
open_remote_project(connection.clone(), paths, app_state, open_options, cx)
|
|
.await
|
|
})
|
|
.detach_and_prompt_err(
|
|
"Failed to open project",
|
|
window,
|
|
cx,
|
|
|_, _, _| None,
|
|
);
|
|
});
|
|
}
|
|
}
|
|
cx.emit(DismissEvent);
|
|
}
|
|
|
|
fn dismissed(&mut self, _window: &mut Window, _cx: &mut Context<Picker<Self>>) {}
|
|
|
|
fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
|
|
let text = if self.workspaces.is_empty() {
|
|
"Recently opened projects will show up here"
|
|
} else {
|
|
"No matches"
|
|
};
|
|
Some(text.into())
|
|
}
|
|
|
|
fn render_match(
|
|
&self,
|
|
ix: usize,
|
|
selected: bool,
|
|
window: &mut Window,
|
|
cx: &mut Context<Picker<Self>>,
|
|
) -> Option<Self::ListItem> {
|
|
let hit = self.filtered_workspaces.get(ix)?;
|
|
let (_, location, paths, _) = self.workspaces.get(hit.candidate_id)?;
|
|
|
|
let ordered_paths: Vec<_> = paths
|
|
.ordered_paths()
|
|
.map(|p| p.compact().to_string_lossy().to_string())
|
|
.collect();
|
|
|
|
let tooltip_path: SharedString = match &location {
|
|
SerializedWorkspaceLocation::Remote(options) => {
|
|
let host = options.display_name();
|
|
if ordered_paths.len() == 1 {
|
|
format!("{} ({})", ordered_paths[0], host).into()
|
|
} else {
|
|
format!("{}\n({})", ordered_paths.join("\n"), host).into()
|
|
}
|
|
}
|
|
_ => ordered_paths.join("\n").into(),
|
|
};
|
|
|
|
let mut path_start_offset = 0;
|
|
let match_labels: Vec<_> = paths
|
|
.ordered_paths()
|
|
.map(|p| p.compact())
|
|
.map(|path| {
|
|
let (label, path_match) =
|
|
highlights_for_path(path.as_ref(), &hit.positions, path_start_offset);
|
|
path_start_offset += path_match.text.len();
|
|
label
|
|
})
|
|
.collect();
|
|
|
|
let prefix = match &location {
|
|
SerializedWorkspaceLocation::Remote(options) => {
|
|
Some(SharedString::from(options.display_name()))
|
|
}
|
|
_ => None,
|
|
};
|
|
|
|
let highlighted_match = HighlightedMatchWithPaths {
|
|
prefix,
|
|
match_label: HighlightedMatch::join(match_labels.into_iter().flatten(), ", "),
|
|
paths: Vec::new(),
|
|
};
|
|
|
|
let icon = icon_for_remote_connection(match location {
|
|
SerializedWorkspaceLocation::Local => None,
|
|
SerializedWorkspaceLocation::Remote(options) => Some(options),
|
|
});
|
|
|
|
Some(
|
|
ListItem::new(ix)
|
|
.toggle_state(selected)
|
|
.inset(true)
|
|
.spacing(ListItemSpacing::Sparse)
|
|
.child(
|
|
h_flex()
|
|
.gap_3()
|
|
.flex_grow()
|
|
.when(self.has_any_non_local_projects, |this| {
|
|
this.child(Icon::new(icon).color(Color::Muted))
|
|
})
|
|
.child(highlighted_match.render(window, cx)),
|
|
)
|
|
.tooltip(Tooltip::text(tooltip_path))
|
|
.into_any_element(),
|
|
)
|
|
}
|
|
|
|
fn render_footer(&self, _: &mut Window, cx: &mut Context<Picker<Self>>) -> Option<AnyElement> {
|
|
let focus_handle = self.focus_handle.clone();
|
|
|
|
Some(
|
|
v_flex()
|
|
.flex_1()
|
|
.p_1p5()
|
|
.gap_1()
|
|
.border_t_1()
|
|
.border_color(cx.theme().colors().border_variant)
|
|
.child({
|
|
let open_action = workspace::Open {
|
|
create_new_window: false,
|
|
};
|
|
Button::new("open_local_folder", "Add Local Project")
|
|
.key_binding(KeyBinding::for_action_in(&open_action, &focus_handle, cx))
|
|
.on_click(move |_, window, cx| {
|
|
window.dispatch_action(open_action.boxed_clone(), cx)
|
|
})
|
|
})
|
|
.into_any(),
|
|
)
|
|
}
|
|
}
|