logiguard fork v3: full patch set on verified 8c74db0 tree

Includes prior-session patches (carry forward so the app compiles):
  - crates/gpui/build.rs: cross-compile manifest fix
  - crates/gpui/src/platform.rs: PlatformWindow::activate_with_token trait method
  - crates/gpui/src/window.rs: Window::activate_with_token public API
  - crates/gpui_linux/src/linux/wayland/window.rs: WaylandWindow::activate_with_token + activate() keyboard-serial fix

Plus the focus-serial fix:
  - serial.rs: SerialKind::KeyboardEnter
  - client.rs: store wl_keyboard.enter serial; latest_serial_of()

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mohamad Khani
2026-07-14 01:52:12 +03:30
commit b9819977a5
3984 changed files with 1487015 additions and 0 deletions

View File

@@ -0,0 +1,89 @@
[package]
name = "workspace"
version = "0.1.0"
edition.workspace = true
publish.workspace = true
license = "GPL-3.0-or-later"
[lints]
workspace = true
[lib]
path = "src/workspace.rs"
doctest = false
[features]
test-support = [
"client/test-support",
"http_client/test-support",
"db/test-support",
"project/test-support",
"remote/test-support",
"session/test-support",
"settings/test-support",
"gpui/test-support",
"fs/test-support",
]
[dependencies]
any_vec.workspace = true
agent_settings.workspace = true
anyhow.workspace = true
async-recursion.workspace = true
client.workspace = true
chrono.workspace = true
clock.workspace = true
collections.workspace = true
component.workspace = true
db.workspace = true
futures-lite.workspace = true
fs.workspace = true
futures.workspace = true
git.workspace = true
gpui.workspace = true
http_client.workspace = true
itertools.workspace = true
language.workspace = true
log.workspace = true
menu.workspace = true
markdown.workspace = true
node_runtime.workspace = true
parking_lot.workspace = true
postage.workspace = true
project.workspace = true
remote.workspace = true
schemars.workspace = true
serde.workspace = true
serde_json.workspace = true
session.workspace = true
settings.workspace = true
smallvec.workspace = true
sqlez.workspace = true
strum.workspace = true
task.workspace = true
telemetry.workspace = true
theme.workspace = true
theme_settings.workspace = true
ui.workspace = true
util.workspace = true
uuid.workspace = true
zed_actions.workspace = true
[target.'cfg(target_os = "windows")'.dependencies]
windows.workspace = true
[dev-dependencies]
client = { workspace = true, features = ["test-support"] }
db = { workspace = true, features = ["test-support"] }
fs = { workspace = true, features = ["test-support"] }
gpui = { workspace = true, features = ["test-support"] }
project = { workspace = true, features = ["test-support"] }
remote = { workspace = true, features = ["test-support"] }
session = { workspace = true, features = ["test-support"] }
settings = { workspace = true, features = ["test-support"] }
http_client = { workspace = true, features = ["test-support"] }
pretty_assertions.workspace = true
proptest.workspace = true
proptest-derive.workspace = true
tempfile.workspace = true
zlog.workspace = true

View File

@@ -0,0 +1 @@
../../LICENSE-GPL

View File

@@ -0,0 +1,77 @@
use gpui::{
App, Context, Empty, EventEmitter, IntoElement, ParentElement, Render, SharedString, Window,
};
use settings::Settings;
use ui::{Button, Tooltip, prelude::*};
use util::paths::PathStyle;
use crate::{
HideStatusItem, StatusItemView, item::ItemHandle, workspace_settings::StatusBarSettings,
};
pub struct ActiveFileName {
project_path: Option<SharedString>,
full_path: Option<SharedString>,
}
impl ActiveFileName {
pub fn new() -> Self {
Self {
project_path: None,
full_path: None,
}
}
}
impl Render for ActiveFileName {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
if !StatusBarSettings::get_global(cx).show_active_file {
return Empty.into_any_element();
}
let Some(project_path) = self.project_path.clone() else {
return Empty.into_any_element();
};
let tooltip_text = self
.full_path
.clone()
.unwrap_or_else(|| project_path.clone());
div()
.child(
Button::new("active-file-name-button", project_path)
.label_size(LabelSize::Small)
.tooltip(Tooltip::text(tooltip_text)),
)
.into_any_element()
}
}
impl EventEmitter<crate::ToolbarItemEvent> for ActiveFileName {}
impl StatusItemView for ActiveFileName {
fn set_active_pane_item(
&mut self,
active_pane_item: Option<&dyn ItemHandle>,
_window: &mut Window,
cx: &mut Context<Self>,
) {
if let Some(item) = active_pane_item {
self.project_path = item
.project_path(cx)
.map(|path| path.path.display(PathStyle::local()).into_owned().into());
self.full_path = item.tab_tooltip_text(cx);
} else {
self.project_path = None;
self.full_path = None;
}
cx.notify();
}
fn hide_setting(&self, _: &App) -> Option<HideStatusItem> {
Some(HideStatusItem::new(|settings| {
settings.status_bar.get_or_insert_default().show_active_file = Some(false);
}))
}
}

1551
crates/workspace/src/dock.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,71 @@
use gpui::{
AnyWindowHandle, AppContext as _, Context, FocusHandle, Focusable, Global,
StatefulInteractiveElement, Task,
};
use crate::workspace_settings;
#[derive(Default)]
struct FfmState {
// The window and element to be focused
handles: Option<(AnyWindowHandle, FocusHandle)>,
// The debounced task which will do the focusing
_debounce_task: Option<Task<()>>,
}
impl Global for FfmState {}
pub trait FocusFollowsMouse<E: Focusable>: StatefulInteractiveElement {
fn focus_follows_mouse(
self,
settings: workspace_settings::FocusFollowsMouse,
cx: &Context<E>,
) -> Self {
if settings.enabled {
self.on_hover(cx.listener(move |this, enter, window, cx| {
if *enter {
let window_handle = window.window_handle();
let focus_handle = this.focus_handle(cx);
let state = cx.try_global::<FfmState>();
// Only replace the target if the new handle doesn't contain the existing one.
// This ensures that hovering over a parent (e.g., Dock) doesn't override
// a more specific child target (e.g., a Pane inside the Dock).
let should_replace = state
.and_then(|s| s.handles.as_ref())
.map(|(_, existing)| !focus_handle.contains(existing, window))
.unwrap_or(true);
if !should_replace {
return;
}
let debounce_task = cx.spawn(async move |_this, cx| {
cx.background_executor().timer(settings.debounce).await;
cx.update(|cx| {
let state = cx.default_global::<FfmState>();
let Some((window, focus)) = state.handles.take() else {
return;
};
let _ = cx.update_window(window, move |_view, window, cx| {
window.focus(&focus, cx);
});
});
});
cx.set_global(FfmState {
handles: Some((window_handle, focus_handle)),
_debounce_task: Some(debounce_task),
});
}
}))
} else {
self
}
}
}
impl<E: Focusable, T: StatefulInteractiveElement> FocusFollowsMouse<E> for T {}

View File

@@ -0,0 +1,144 @@
use std::{path::PathBuf, sync::Arc};
use fs::Fs;
use gpui::{AppContext, Entity, Global, MenuItem};
use smallvec::SmallVec;
use ui::{App, Context};
use util::{ResultExt, paths::PathExt};
use crate::{
NewWindow, SerializedWorkspaceLocation, WorkspaceId, path_list::PathList,
persistence::WorkspaceDb,
};
pub fn init(fs: Arc<dyn Fs>, cx: &mut App) {
let manager = cx.new(|_| HistoryManager::new());
HistoryManager::set_global(manager.clone(), cx);
HistoryManager::init(manager, fs, cx);
}
pub struct HistoryManager {
/// The history of workspaces that have been opened in the past, in reverse order.
/// The most recent workspace is at the end of the vector.
history: Vec<HistoryManagerEntry>,
}
#[derive(Debug)]
pub struct HistoryManagerEntry {
pub id: WorkspaceId,
pub path: SmallVec<[PathBuf; 2]>,
}
struct GlobalHistoryManager(Entity<HistoryManager>);
impl Global for GlobalHistoryManager {}
impl HistoryManager {
fn new() -> Self {
Self {
history: Vec::new(),
}
}
fn init(this: Entity<HistoryManager>, fs: Arc<dyn Fs>, cx: &App) {
let db = WorkspaceDb::global(cx);
cx.spawn(async move |cx| {
let recent_folders = db
.recent_project_workspaces(fs.as_ref())
.await
.unwrap_or_default()
.into_iter()
.rev()
.filter_map(|workspace| {
if matches!(workspace.location, SerializedWorkspaceLocation::Local) {
Some(HistoryManagerEntry::new(
workspace.workspace_id,
&workspace.paths,
))
} else {
None
}
})
.collect::<Vec<_>>();
this.update(cx, |this, cx| {
this.history = recent_folders;
this.update_jump_list(cx);
})
})
.detach();
}
pub fn global(cx: &App) -> Option<Entity<Self>> {
cx.try_global::<GlobalHistoryManager>()
.map(|model| model.0.clone())
}
fn set_global(history_manager: Entity<Self>, cx: &mut App) {
cx.set_global(GlobalHistoryManager(history_manager));
}
pub fn update_history(
&mut self,
id: WorkspaceId,
entry: HistoryManagerEntry,
cx: &mut Context<'_, HistoryManager>,
) {
if let Some(pos) = self.history.iter().position(|e| e.id == id) {
self.history.remove(pos);
}
self.history.push(entry);
self.update_jump_list(cx);
}
pub fn delete_history(&mut self, id: WorkspaceId, cx: &mut Context<'_, HistoryManager>) {
let Some(pos) = self.history.iter().position(|e| e.id == id) else {
return;
};
self.history.remove(pos);
self.update_jump_list(cx);
}
fn update_jump_list(&mut self, cx: &mut Context<'_, HistoryManager>) {
let menus = vec![MenuItem::action("New Window", NewWindow)];
let entries = self
.history
.iter()
.rev()
.map(|entry| entry.path.clone())
.collect::<Vec<_>>();
let user_removed = cx.update_jump_list(menus, entries);
let db = WorkspaceDb::global(cx);
cx.spawn(async move |this, cx| {
let user_removed = user_removed.await;
if user_removed.is_empty() {
return;
}
let mut deleted_ids = Vec::new();
if let Ok(()) = this.update(cx, |this, _| {
for idx in (0..this.history.len()).rev() {
if let Some(entry) = this.history.get(idx)
&& user_removed.contains(&entry.path)
{
deleted_ids.push(entry.id);
this.history.remove(idx);
}
}
}) {
for id in deleted_ids.iter() {
db.delete_workspace_by_id(*id).await.log_err();
}
}
})
.detach();
}
}
impl HistoryManagerEntry {
pub fn new(id: WorkspaceId, paths: &PathList) -> Self {
let path = paths
.ordered_paths()
.map(|path| path.compact())
.collect::<SmallVec<[PathBuf; 2]>>();
Self { id, path }
}
}

View File

@@ -0,0 +1,114 @@
use std::{path::Path, sync::Arc};
use gpui::{EventEmitter, FocusHandle, Focusable};
use ui::{
App, Button, ButtonCommon, ButtonStyle, Clickable, Context, FluentBuilder, InteractiveElement,
KeyBinding, Label, LabelCommon, LabelSize, ParentElement, Render, SharedString, Styled as _,
Window, h_flex, v_flex,
};
use zed_actions::workspace::OpenWithSystem;
use crate::Item;
/// A view to display when a certain buffer/image/other item fails to open.
#[derive(Debug)]
pub struct InvalidItemView {
/// Which path was attempted to open.
pub abs_path: Arc<Path>,
/// An error message, happened when opening the item.
pub error: SharedString,
is_local: bool,
focus_handle: FocusHandle,
}
impl InvalidItemView {
pub fn new(
abs_path: &Path,
is_local: bool,
e: &anyhow::Error,
_: &mut Window,
cx: &mut App,
) -> Self {
Self {
is_local,
abs_path: Arc::from(abs_path),
error: format!("{}", e.root_cause()).into(),
focus_handle: cx.focus_handle(),
}
}
}
impl Item for InvalidItemView {
type Event = ();
fn tab_content_text(&self, mut detail: usize, _: &App) -> SharedString {
// Ensure we always render at least the filename.
detail += 1;
let path = self.abs_path.as_ref();
let mut prefix = path;
while detail > 0 {
if let Some(parent) = prefix.parent() {
prefix = parent;
detail -= 1;
} else {
break;
}
}
let path = if detail > 0 {
path
} else {
path.strip_prefix(prefix).unwrap_or(path)
};
SharedString::new(path.to_string_lossy())
}
}
impl EventEmitter<()> for InvalidItemView {}
impl Focusable for InvalidItemView {
fn focus_handle(&self, _: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for InvalidItemView {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl gpui::IntoElement {
let abs_path = self.abs_path.clone();
v_flex()
.size_full()
.track_focus(&self.focus_handle(cx))
.flex_none()
.justify_center()
.overflow_hidden()
.key_context("InvalidItem")
.child(
h_flex().size_full().justify_center().child(
v_flex()
.justify_center()
.gap_2()
.child(h_flex().justify_center().child("Could not open file"))
.child(
h_flex()
.justify_center()
.child(Label::new(self.error.clone()).size(LabelSize::Small)),
)
.when(self.is_local, |contents| {
contents.child(
h_flex().justify_center().child(
Button::new("open-with-system", "Open in Default App")
.on_click(move |_, _, cx| {
cx.open_with_system(&abs_path);
})
.style(ButtonStyle::Outlined)
.key_binding(KeyBinding::for_action(&OpenWithSystem, cx)),
),
)
}),
),
)
}
}

1847
crates/workspace/src/item.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,237 @@
use gpui::{
AnyView, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable as _, ManagedView,
MouseButton, Subscription,
};
use ui::prelude::*;
#[derive(Debug)]
pub enum DismissDecision {
Dismiss(bool),
Pending,
}
pub trait ModalView: ManagedView {
fn on_before_dismiss(
&mut self,
_window: &mut Window,
_: &mut Context<Self>,
) -> DismissDecision {
DismissDecision::Dismiss(true)
}
fn fade_out_background(&self) -> bool {
false
}
fn render_bare(&self) -> bool {
false
}
}
trait ModalViewHandle {
fn on_before_dismiss(&mut self, window: &mut Window, cx: &mut App) -> DismissDecision;
fn view(&self) -> AnyView;
fn fade_out_background(&self, cx: &mut App) -> bool;
fn render_bare(&self, cx: &mut App) -> bool;
}
impl<V: ModalView> ModalViewHandle for Entity<V> {
fn on_before_dismiss(&mut self, window: &mut Window, cx: &mut App) -> DismissDecision {
self.update(cx, |this, cx| this.on_before_dismiss(window, cx))
}
fn view(&self) -> AnyView {
self.clone().into()
}
fn fade_out_background(&self, cx: &mut App) -> bool {
self.read(cx).fade_out_background()
}
fn render_bare(&self, cx: &mut App) -> bool {
self.read(cx).render_bare()
}
}
pub struct ActiveModal {
modal: Box<dyn ModalViewHandle>,
_subscriptions: [Subscription; 2],
previous_focus_handle: Option<FocusHandle>,
focus_handle: FocusHandle,
}
pub struct ModalLayer {
active_modal: Option<ActiveModal>,
dismiss_on_focus_lost: bool,
}
pub(crate) struct ModalOpenedEvent;
impl EventEmitter<ModalOpenedEvent> for ModalLayer {}
impl Default for ModalLayer {
fn default() -> Self {
Self::new()
}
}
impl ModalLayer {
pub fn new() -> Self {
Self {
active_modal: None,
dismiss_on_focus_lost: false,
}
}
/// Toggles a modal of type `V`. If a modal of the same type is currently active,
/// it will be hidden. If a different modal is active, it will be replaced with the new one.
/// If no modal is active, the new modal will be shown.
///
/// If closing the current modal fails (e.g., due to `on_before_dismiss` returning
/// `DismissDecision::Dismiss(false)` or `DismissDecision::Pending`), the new modal
/// will not be shown.
pub fn toggle_modal<V, B>(&mut self, window: &mut Window, cx: &mut Context<Self>, build_view: B)
where
V: ModalView,
B: FnOnce(&mut Window, &mut Context<V>) -> V,
{
if let Some(active_modal) = &self.active_modal {
let should_close = active_modal.modal.view().downcast::<V>().is_ok();
let did_close = self.hide_modal(window, cx);
if should_close || !did_close {
return;
}
}
let new_modal = cx.new(|cx| build_view(window, cx));
self.show_modal(new_modal, window, cx);
cx.emit(ModalOpenedEvent);
}
/// Shows a modal and sets up subscriptions for dismiss events and focus tracking.
/// The modal is automatically focused after being shown.
fn show_modal<V>(&mut self, new_modal: Entity<V>, window: &mut Window, cx: &mut Context<Self>)
where
V: ModalView,
{
let focus_handle = cx.focus_handle();
self.active_modal = Some(ActiveModal {
modal: Box::new(new_modal.clone()),
_subscriptions: [
cx.subscribe_in(
&new_modal,
window,
|this, _, _: &DismissEvent, window, cx| {
this.hide_modal(window, cx);
},
),
cx.on_focus_out(&focus_handle, window, |this, _event, window, cx| {
if this.dismiss_on_focus_lost {
this.hide_modal(window, cx);
}
}),
],
previous_focus_handle: window.focused(cx),
focus_handle,
});
cx.defer_in(window, move |_, window, cx| {
window.focus(&new_modal.focus_handle(cx), cx);
});
cx.notify();
}
/// Attempts to hide the currently active modal.
///
/// The modal's `on_before_dismiss` method is called to determine if dismissal should proceed.
/// If dismissal is allowed, the modal is removed and focus is restored to the previously
/// focused element.
///
/// Returns `true` if the modal was successfully hidden, `false` otherwise.
pub fn hide_modal(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
let Some(active_modal) = self.active_modal.as_mut() else {
self.dismiss_on_focus_lost = false;
return false;
};
match active_modal.modal.on_before_dismiss(window, cx) {
DismissDecision::Dismiss(should_dismiss) => {
if !should_dismiss {
self.dismiss_on_focus_lost = !should_dismiss;
return false;
}
}
DismissDecision::Pending => {
self.dismiss_on_focus_lost = false;
return false;
}
}
if let Some(active_modal) = self.active_modal.take() {
if let Some(previous_focus) = active_modal.previous_focus_handle
&& active_modal.focus_handle.contains_focused(window, cx)
{
previous_focus.focus(window, cx);
}
cx.notify();
}
self.dismiss_on_focus_lost = false;
true
}
/// Returns the currently active modal if it is of type `V`.
pub fn active_modal<V>(&self) -> Option<Entity<V>>
where
V: 'static,
{
let active_modal = self.active_modal.as_ref()?;
active_modal.modal.view().downcast::<V>().ok()
}
pub fn has_active_modal(&self) -> bool {
self.active_modal.is_some()
}
}
impl Render for ModalLayer {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let Some(active_modal) = &self.active_modal else {
return div().into_any_element();
};
if active_modal.modal.render_bare(cx) {
return active_modal.modal.view().into_any_element();
}
div()
.absolute()
.size_full()
.inset_0()
.occlude()
.when(active_modal.modal.fade_out_background(cx), |this| {
let mut background = cx.theme().colors().elevated_surface_background;
background.fade_out(0.2);
this.bg(background)
})
.on_mouse_down(
MouseButton::Left,
cx.listener(|this, _, window, cx| {
this.hide_modal(window, cx);
}),
)
.child(
v_flex()
.h(px(0.0))
.top_20()
.items_center()
.track_focus(&active_modal.focus_handle)
.child(
h_flex()
.occlude()
.child(active_modal.modal.view())
.on_mouse_down(MouseButton::Left, |_, _, cx| {
cx.stop_propagation();
}),
),
)
.into_any_element()
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,944 @@
use std::path::PathBuf;
use super::*;
use crate::item::test::TestItem;
use agent_settings::AgentSettings;
use client::proto;
use fs::{FakeFs, Fs};
use gpui::{TestAppContext, VisualTestContext};
use project::DisableAiSettings;
use serde_json::json;
use settings::{Settings, SettingsStore};
use util::path;
fn init_test(cx: &mut TestAppContext) {
cx.update(|cx| {
let settings_store = SettingsStore::test(cx);
cx.set_global(settings_store);
theme_settings::init(theme::LoadThemes::JustBase, cx);
DisableAiSettings::register(cx);
});
}
#[gpui::test]
async fn test_sidebar_disabled_when_disable_ai_is_enabled(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, [], cx).await;
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
multi_workspace.read_with(cx, |mw, cx| {
assert!(mw.multi_workspace_enabled(cx));
});
multi_workspace.update_in(cx, |mw, _window, cx| {
mw.open_sidebar(cx);
assert!(mw.sidebar_open());
});
cx.update(|_window, cx| {
DisableAiSettings::override_global(DisableAiSettings { disable_ai: true }, cx);
});
cx.run_until_parked();
multi_workspace.read_with(cx, |mw, cx| {
assert!(
!mw.sidebar_open(),
"Sidebar should be closed when disable_ai is true"
);
assert!(
!mw.multi_workspace_enabled(cx),
"Multi-workspace should be disabled when disable_ai is true"
);
});
multi_workspace.update_in(cx, |mw, window, cx| {
mw.toggle_sidebar(window, cx);
});
multi_workspace.read_with(cx, |mw, _cx| {
assert!(
!mw.sidebar_open(),
"Sidebar should remain closed when toggled with disable_ai true"
);
});
cx.update(|_window, cx| {
DisableAiSettings::override_global(DisableAiSettings { disable_ai: false }, cx);
});
cx.run_until_parked();
multi_workspace.read_with(cx, |mw, cx| {
assert!(
mw.multi_workspace_enabled(cx),
"Multi-workspace should be enabled after re-enabling AI"
);
assert!(
!mw.sidebar_open(),
"Sidebar should still be closed after re-enabling AI (not auto-opened)"
);
});
multi_workspace.update_in(cx, |mw, window, cx| {
mw.toggle_sidebar(window, cx);
});
multi_workspace.read_with(cx, |mw, _cx| {
assert!(
mw.sidebar_open(),
"Sidebar should open when toggled after re-enabling AI"
);
});
}
#[gpui::test]
async fn test_multi_workspace_collapses_when_agent_is_disabled(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree("/root_a", json!({ "file.txt": "" })).await;
fs.insert_tree("/root_b", json!({ "file.txt": "" })).await;
let project_a = Project::test(fs.clone(), ["/root_a".as_ref()], cx).await;
let project_b = Project::test(fs, ["/root_b".as_ref()], cx).await;
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
multi_workspace.update_in(cx, |multi_workspace, window, cx| {
multi_workspace.test_add_workspace(project_b, window, cx);
});
cx.run_until_parked();
multi_workspace.read_with(cx, |multi_workspace, cx| {
assert!(multi_workspace.multi_workspace_enabled(cx));
assert_eq!(multi_workspace.workspaces().count(), 2);
});
cx.update(|_window, cx| {
let mut settings = AgentSettings::get_global(cx).clone();
settings.enabled = false;
AgentSettings::override_global(settings, cx);
});
cx.run_until_parked();
multi_workspace.read_with(cx, |multi_workspace, cx| {
assert!(!multi_workspace.multi_workspace_enabled(cx));
assert!(!multi_workspace.sidebar_open());
assert_eq!(multi_workspace.workspaces().count(), 1);
assert!(multi_workspace.project_group_keys().is_empty());
});
}
#[gpui::test]
async fn test_project_group_keys_initial(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree("/root_a", json!({ "file.txt": "" })).await;
let project = Project::test(fs, ["/root_a".as_ref()], cx).await;
let expected_key = project.read_with(cx, |project, cx| project.project_group_key(cx));
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
multi_workspace.update(cx, |mw, cx| {
mw.open_sidebar(cx);
});
multi_workspace.read_with(cx, |mw, _cx| {
let keys: Vec<ProjectGroupKey> = mw.project_group_keys();
assert_eq!(keys.len(), 1, "should have exactly one key on creation");
assert_eq!(keys[0], expected_key);
});
}
#[gpui::test]
async fn test_project_group_keys_add_workspace(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree("/root_a", json!({ "file.txt": "" })).await;
fs.insert_tree("/root_b", json!({ "file.txt": "" })).await;
let project_a = Project::test(fs.clone(), ["/root_a".as_ref()], cx).await;
let project_b = Project::test(fs.clone(), ["/root_b".as_ref()], cx).await;
let key_a = project_a.read_with(cx, |p, cx| p.project_group_key(cx));
let key_b = project_b.read_with(cx, |p, cx| p.project_group_key(cx));
assert_ne!(
key_a, key_b,
"different roots should produce different keys"
);
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
multi_workspace.update(cx, |mw, cx| {
mw.open_sidebar(cx);
});
multi_workspace.read_with(cx, |mw, _cx| {
assert_eq!(mw.project_group_keys().len(), 1);
});
// Adding a workspace with a different project root adds a new key.
multi_workspace.update_in(cx, |mw, window, cx| {
mw.test_add_workspace(project_b, window, cx);
});
multi_workspace.read_with(cx, |mw, _cx| {
let keys: Vec<ProjectGroupKey> = mw.project_group_keys();
assert_eq!(
keys.len(),
2,
"should have two keys after adding a second workspace"
);
assert_eq!(keys[0], key_b);
assert_eq!(keys[1], key_a);
});
}
#[gpui::test]
async fn test_open_new_window_does_not_open_sidebar_on_existing_window(cx: &mut TestAppContext) {
init_test(cx);
let app_state = cx.update(AppState::test);
let fs = app_state.fs.as_fake();
fs.insert_tree(path!("/project_a"), json!({ "file.txt": "" }))
.await;
fs.insert_tree(path!("/project_b"), json!({ "file.txt": "" }))
.await;
let project = Project::test(app_state.fs.clone(), [path!("/project_a").as_ref()], cx).await;
let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx));
window
.read_with(cx, |mw, _cx| {
assert!(!mw.sidebar_open(), "sidebar should start closed",);
})
.unwrap();
cx.update(|cx| {
open_paths(
&[PathBuf::from(path!("/project_b"))],
app_state,
OpenOptions {
open_mode: OpenMode::NewWindow,
..OpenOptions::default()
},
cx,
)
})
.await
.unwrap();
window
.read_with(cx, |mw, _cx| {
assert!(
!mw.sidebar_open(),
"opening a project in a new window must not open the sidebar on the original window",
);
})
.unwrap();
}
#[gpui::test]
async fn test_open_directory_in_empty_workspace_does_not_open_sidebar(cx: &mut TestAppContext) {
init_test(cx);
let app_state = cx.update(AppState::test);
let fs = app_state.fs.as_fake();
fs.insert_tree(path!("/project"), json!({ "file.txt": "" }))
.await;
let project = Project::test(app_state.fs.clone(), [], cx).await;
let window = cx.add_window(|window, cx| {
let mw = MultiWorkspace::test_new(project, window, cx);
// Simulate a blank project that has an untitled editor tab,
// so that workspace_windows_for_location finds this window.
mw.workspace().update(cx, |workspace, cx| {
workspace.active_pane().update(cx, |pane, cx| {
let item = cx.new(|cx| item::test::TestItem::new(cx));
pane.add_item(Box::new(item), false, false, None, window, cx);
});
});
mw
});
window
.read_with(cx, |mw, _cx| {
assert!(!mw.sidebar_open(), "sidebar should start closed");
})
.unwrap();
// Simulate what open_workspace_for_paths does for an empty workspace:
// it downgrades OpenMode::NewWindow to Activate and sets requesting_window.
cx.update(|cx| {
open_paths(
&[PathBuf::from(path!("/project"))],
app_state,
OpenOptions {
requesting_window: Some(window),
open_mode: OpenMode::Activate,
..OpenOptions::default()
},
cx,
)
})
.await
.unwrap();
window
.read_with(cx, |mw, _cx| {
assert!(
!mw.sidebar_open(),
"opening a directory in a blank project via the file picker must not open the sidebar",
);
})
.unwrap();
}
#[gpui::test]
async fn test_project_group_keys_duplicate_not_added(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree("/root_a", json!({ "file.txt": "" })).await;
let project_a = Project::test(fs.clone(), ["/root_a".as_ref()], cx).await;
// A second project entity pointing at the same path produces the same key.
let project_a2 = Project::test(fs.clone(), ["/root_a".as_ref()], cx).await;
let key_a = project_a.read_with(cx, |p, cx| p.project_group_key(cx));
let key_a2 = project_a2.read_with(cx, |p, cx| p.project_group_key(cx));
assert_eq!(key_a, key_a2, "same root path should produce the same key");
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
multi_workspace.update(cx, |mw, cx| {
mw.open_sidebar(cx);
});
multi_workspace.update_in(cx, |mw, window, cx| {
mw.test_add_workspace(project_a2, window, cx);
});
multi_workspace.read_with(cx, |mw, _cx| {
let keys: Vec<ProjectGroupKey> = mw.project_group_keys();
assert_eq!(
keys.len(),
1,
"duplicate key should not be added when a workspace with the same root is inserted"
);
});
}
#[gpui::test]
async fn test_adding_worktree_updates_project_group_key(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree("/root_a", json!({ "file.txt": "" })).await;
fs.insert_tree("/root_b", json!({ "other.txt": "" })).await;
let project = Project::test(fs.clone(), ["/root_a".as_ref()], cx).await;
let initial_key = project.read_with(cx, |p, cx| p.project_group_key(cx));
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
// Open sidebar to retain the workspace and create the initial group.
multi_workspace.update(cx, |mw, cx| {
mw.open_sidebar(cx);
});
cx.run_until_parked();
multi_workspace.read_with(cx, |mw, _cx| {
let keys = mw.project_group_keys();
assert_eq!(keys.len(), 1);
assert_eq!(keys[0], initial_key);
});
// Add a second worktree to the project. This triggers WorktreeAdded →
// handle_workspace_key_change, which should update the group key.
project
.update(cx, |project, cx| {
project.find_or_create_worktree("/root_b", true, cx)
})
.await
.expect("adding worktree should succeed");
cx.run_until_parked();
let updated_key = project.read_with(cx, |p, cx| p.project_group_key(cx));
assert_ne!(
initial_key, updated_key,
"adding a worktree should change the project group key"
);
multi_workspace.read_with(cx, |mw, _cx| {
let keys = mw.project_group_keys();
assert!(
keys.contains(&updated_key),
"should contain the updated key; got {keys:?}"
);
});
}
#[gpui::test]
async fn test_find_or_create_local_workspace_reuses_active_workspace_when_sidebar_closed(
cx: &mut TestAppContext,
) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree("/root_a", json!({ "file.txt": "" })).await;
let project = Project::test(fs, ["/root_a".as_ref()], cx).await;
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
let active_workspace = multi_workspace.read_with(cx, |mw, cx| {
assert!(
mw.project_groups(cx).is_empty(),
"sidebar-closed setup should start with no retained project groups"
);
mw.workspace().clone()
});
let active_workspace_id = active_workspace.entity_id();
let workspace = multi_workspace
.update_in(cx, |mw, window, cx| {
mw.find_or_create_local_workspace(
PathList::new(&[PathBuf::from("/root_a")]),
None,
&[],
None,
OpenMode::Activate,
window,
cx,
)
})
.await
.expect("reopening the same local workspace should succeed");
assert_eq!(
workspace.entity_id(),
active_workspace_id,
"should reuse the current active workspace when the sidebar is closed"
);
multi_workspace.read_with(cx, |mw, _cx| {
assert_eq!(
mw.workspace().entity_id(),
active_workspace_id,
"active workspace should remain unchanged after reopening the same path"
);
assert_eq!(
mw.workspaces().count(),
1,
"reusing the active workspace should not create a second open workspace"
);
});
}
#[gpui::test]
async fn test_find_or_create_workspace_uses_project_group_key_when_paths_are_missing(
cx: &mut TestAppContext,
) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
"/project",
json!({
".git": {},
"src": {},
}),
)
.await;
cx.update(|cx| <dyn Fs>::set_global(fs.clone(), cx));
let project = Project::test(fs.clone(), ["/project".as_ref()], cx).await;
project
.update(cx, |project, cx| project.git_scans_complete(cx))
.await;
let project_group_key = project.read_with(cx, |project, cx| project.project_group_key(cx));
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
let main_workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
let main_workspace_id = main_workspace.entity_id();
let workspace = multi_workspace
.update_in(cx, |mw, window, cx| {
mw.find_or_create_workspace(
PathList::new(&[PathBuf::from("/wt-feature-a")]),
None,
Some(project_group_key.clone()),
|_options, _window, _cx| Task::ready(Ok(None)),
&[],
None,
OpenMode::Activate,
window,
cx,
)
})
.await
.expect("opening a missing linked-worktree path should fall back to the project group key workspace");
assert_eq!(
workspace.entity_id(),
main_workspace_id,
"missing linked-worktree paths should reuse the main worktree workspace from the project group key"
);
multi_workspace.read_with(cx, |mw, cx| {
assert_eq!(
mw.workspace().entity_id(),
main_workspace_id,
"the active workspace should remain the main worktree workspace"
);
assert_eq!(
PathList::new(&mw.workspace().read(cx).root_paths(cx)),
project_group_key.path_list().clone(),
"the activated workspace should use the project group key path list rather than the missing linked-worktree path"
);
assert_eq!(
mw.workspaces().count(),
1,
"falling back to the project group key should not create a second workspace"
);
});
}
#[gpui::test]
async fn test_find_or_create_local_workspace_reuses_active_workspace_after_sidebar_open(
cx: &mut TestAppContext,
) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree("/root_a", json!({ "file.txt": "" })).await;
let project = Project::test(fs, ["/root_a".as_ref()], cx).await;
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
multi_workspace.update(cx, |mw, cx| {
mw.open_sidebar(cx);
});
cx.run_until_parked();
let active_workspace = multi_workspace.read_with(cx, |mw, cx| {
assert_eq!(
mw.project_groups(cx).len(),
1,
"opening the sidebar should retain the active workspace in a project group"
);
mw.workspace().clone()
});
let active_workspace_id = active_workspace.entity_id();
let workspace = multi_workspace
.update_in(cx, |mw, window, cx| {
mw.find_or_create_local_workspace(
PathList::new(&[PathBuf::from("/root_a")]),
None,
&[],
None,
OpenMode::Activate,
window,
cx,
)
})
.await
.expect("reopening the same retained local workspace should succeed");
assert_eq!(
workspace.entity_id(),
active_workspace_id,
"should reuse the retained active workspace after the sidebar is opened"
);
multi_workspace.read_with(cx, |mw, _cx| {
assert_eq!(
mw.workspaces().count(),
1,
"reopening the same retained workspace should not create another workspace"
);
});
}
#[gpui::test]
async fn test_close_workspace_prefers_already_loaded_neighboring_workspace(
cx: &mut TestAppContext,
) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree("/root_a", json!({ "file_a.txt": "" })).await;
fs.insert_tree("/root_b", json!({ "file_b.txt": "" })).await;
fs.insert_tree("/root_c", json!({ "file_c.txt": "" })).await;
let project_a = Project::test(fs.clone(), ["/root_a".as_ref()], cx).await;
let project_b = Project::test(fs.clone(), ["/root_b".as_ref()], cx).await;
let project_b_key = project_b.read_with(cx, |project, cx| project.project_group_key(cx));
let project_c = Project::test(fs, ["/root_c".as_ref()], cx).await;
let project_c_key = project_c.read_with(cx, |project, cx| project.project_group_key(cx));
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
multi_workspace.update(cx, |multi_workspace, cx| {
multi_workspace.open_sidebar(cx);
});
cx.run_until_parked();
let workspace_a = multi_workspace.read_with(cx, |multi_workspace, _cx| {
multi_workspace.workspace().clone()
});
let workspace_b = multi_workspace.update_in(cx, |multi_workspace, window, cx| {
multi_workspace.test_add_workspace(project_b, window, cx)
});
multi_workspace.update_in(cx, |multi_workspace, window, cx| {
multi_workspace.activate(workspace_a.clone(), None, window, cx);
multi_workspace.test_add_project_group(ProjectGroup {
key: project_c_key.clone(),
workspaces: Vec::new(),
expanded: true,
});
});
multi_workspace.read_with(cx, |multi_workspace, _cx| {
let keys = multi_workspace.project_group_keys();
assert_eq!(
keys.len(),
3,
"expected three project groups in the test setup"
);
assert_eq!(keys[0], project_b_key);
assert_eq!(
keys[1],
workspace_a.read_with(cx, |workspace, cx| { workspace.project_group_key(cx) })
);
assert_eq!(keys[2], project_c_key);
assert_eq!(
multi_workspace.workspace().entity_id(),
workspace_a.entity_id(),
"workspace A should be active before closing"
);
});
let closed = multi_workspace
.update_in(cx, |multi_workspace, window, cx| {
multi_workspace.close_workspace(&workspace_a, window, cx)
})
.await
.expect("closing the active workspace should succeed");
assert!(
closed,
"close_workspace should report that it removed a workspace"
);
multi_workspace.read_with(cx, |multi_workspace, cx| {
assert_eq!(
multi_workspace.workspace().entity_id(),
workspace_b.entity_id(),
"closing workspace A should activate the already-loaded workspace B instead of opening group C"
);
assert_eq!(
multi_workspace.workspaces().count(),
1,
"only workspace B should remain loaded after closing workspace A"
);
assert!(
multi_workspace
.workspaces_for_project_group(&project_c_key, cx)
.unwrap_or_default()
.is_empty(),
"the unloaded neighboring group C should remain unopened"
);
});
}
#[gpui::test]
async fn test_switching_projects_with_sidebar_closed_retains_old_active_workspace(
cx: &mut TestAppContext,
) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree("/root_a", json!({ "file_a.txt": "" })).await;
fs.insert_tree("/root_b", json!({ "file_b.txt": "" })).await;
let project_a = Project::test(fs.clone(), ["/root_a".as_ref()], cx).await;
let project_b = Project::test(fs, ["/root_b".as_ref()], cx).await;
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
let workspace_a = multi_workspace.read_with(cx, |mw, cx| {
assert!(
mw.project_groups(cx).is_empty(),
"sidebar-closed setup should start with no retained project groups"
);
mw.workspace().clone()
});
assert!(
workspace_a.read_with(cx, |workspace, _cx| workspace.session_id().is_some()),
"initial active workspace should start attached to the session"
);
let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
mw.test_add_workspace(project_b, window, cx)
});
cx.run_until_parked();
multi_workspace.read_with(cx, |mw, cx| {
assert_eq!(
mw.workspace().entity_id(),
workspace_b.entity_id(),
"the new workspace should become active"
);
assert_eq!(
mw.workspaces().count(),
2,
"the previous active workspace should remain open after switching with the sidebar closed"
);
assert_eq!(mw.project_groups(cx).len(), 2);
});
assert!(
workspace_a.read_with(cx, |workspace, _cx| workspace.session_id().is_some()),
"the previous active workspace should remain attached when switching away with the sidebar closed"
);
}
#[gpui::test]
async fn test_remote_project_root_dir_changes_update_groups(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree("/root_a", json!({ "file.txt": "" })).await;
fs.insert_tree("/local_b", json!({ "file.txt": "" })).await;
let project_a = Project::test(fs.clone(), ["/root_a".as_ref()], cx).await;
let project_b = Project::test(fs.clone(), ["/local_b".as_ref()], cx).await;
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a, window, cx));
multi_workspace.update(cx, |mw, cx| {
mw.open_sidebar(cx);
});
cx.run_until_parked();
let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
let workspace = cx.new(|cx| Workspace::test_new(project_b.clone(), window, cx));
let key = workspace.read(cx).project_group_key(cx);
mw.activate_provisional_workspace(workspace.clone(), key, window, cx);
workspace
});
cx.run_until_parked();
multi_workspace.read_with(cx, |mw, _cx| {
assert_eq!(
mw.workspace().entity_id(),
workspace_b.entity_id(),
"registered workspace should become active"
);
});
let initial_key = project_b.read_with(cx, |p, cx| p.project_group_key(cx));
multi_workspace.read_with(cx, |mw, _cx| {
let keys = mw.project_group_keys();
assert!(
keys.contains(&initial_key),
"project groups should contain the initial key for the registered workspace"
);
});
let remote_worktree = project_b.update(cx, |project, cx| {
project.add_test_remote_worktree("/remote/project", cx)
});
cx.run_until_parked();
let worktree_id = remote_worktree.read_with(cx, |wt, _| wt.id().to_proto());
remote_worktree.update(cx, |worktree, _cx| {
worktree
.as_remote()
.unwrap()
.update_from_remote(proto::UpdateWorktree {
project_id: 0,
worktree_id,
abs_path: "/remote/project".to_string(),
root_name: "project".to_string(),
updated_entries: vec![proto::Entry {
id: 1,
is_dir: true,
path: "".to_string(),
inode: 1,
mtime: Some(proto::Timestamp {
seconds: 0,
nanos: 0,
}),
is_ignored: false,
is_hidden: false,
is_external: false,
is_fifo: false,
size: None,
canonical_path: None,
}],
removed_entries: vec![],
scan_id: 1,
is_last_update: true,
updated_repositories: vec![],
removed_repositories: vec![],
root_repo_common_dir: None,
});
});
cx.run_until_parked();
let updated_key = project_b.read_with(cx, |p, cx| p.project_group_key(cx));
assert_ne!(
initial_key, updated_key,
"remote worktree update should change the project group key"
);
multi_workspace.read_with(cx, |mw, _cx| {
let keys = mw.project_group_keys();
assert!(
keys.contains(&updated_key),
"project groups should contain the updated key after remote change; got {keys:?}"
);
assert!(
!keys.contains(&initial_key),
"project groups should no longer contain the stale initial key; got {keys:?}"
);
});
}
#[gpui::test]
async fn test_open_project_closes_empty_workspace_but_not_non_empty_ones(cx: &mut TestAppContext) {
init_test(cx);
let app_state = cx.update(AppState::test);
let fs = app_state.fs.as_fake();
fs.insert_tree(path!("/project_a"), json!({ "file_a.txt": "" }))
.await;
fs.insert_tree(path!("/project_b"), json!({ "file_b.txt": "" }))
.await;
// Start with an empty (no-worktrees) workspace.
let project = Project::test(app_state.fs.clone(), [], cx).await;
let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx));
cx.run_until_parked();
window
.update(cx, |mw, _window, cx| mw.open_sidebar(cx))
.unwrap();
cx.run_until_parked();
let empty_workspace = window
.read_with(cx, |mw, _| mw.workspace().clone())
.unwrap();
let cx = &mut VisualTestContext::from_window(window.into(), cx);
// Add a dirty untitled item to the empty workspace.
let dirty_item = cx.new(|cx| TestItem::new(cx).with_dirty(true));
empty_workspace.update_in(cx, |workspace, window, cx| {
workspace.add_item_to_active_pane(Box::new(dirty_item.clone()), None, true, window, cx);
});
// Opening a project while the lone empty workspace has unsaved
// changes prompts the user.
let open_task = window
.update(cx, |mw, window, cx| {
mw.open_project(
vec![PathBuf::from(path!("/project_a"))],
OpenMode::Activate,
window,
cx,
)
})
.unwrap();
cx.run_until_parked();
// Cancelling keeps the empty workspace.
assert!(cx.has_pending_prompt(),);
cx.simulate_prompt_answer("Cancel");
cx.run_until_parked();
assert_eq!(open_task.await.unwrap(), empty_workspace);
window
.read_with(cx, |mw, _cx| {
assert_eq!(mw.workspaces().count(), 1);
assert_eq!(mw.workspace(), &empty_workspace);
assert_eq!(mw.project_group_keys(), vec![]);
})
.unwrap();
// Discarding the unsaved changes closes the empty workspace
// and opens the new project in its place.
let open_task = window
.update(cx, |mw, window, cx| {
mw.open_project(
vec![PathBuf::from(path!("/project_a"))],
OpenMode::Activate,
window,
cx,
)
})
.unwrap();
cx.run_until_parked();
assert!(cx.has_pending_prompt(),);
cx.simulate_prompt_answer("Don't Save");
cx.run_until_parked();
let workspace_a = open_task.await.unwrap();
assert_ne!(workspace_a, empty_workspace);
window
.read_with(cx, |mw, _cx| {
assert_eq!(mw.workspaces().count(), 1);
assert_eq!(mw.workspace(), &workspace_a);
assert_eq!(
mw.project_group_keys(),
vec![ProjectGroupKey::new(
None,
PathList::new(&[path!("/project_a")])
)]
);
})
.unwrap();
assert!(
empty_workspace.read_with(cx, |workspace, _cx| workspace.session_id().is_none()),
"the detached empty workspace should no longer be attached to the session",
);
let dirty_item = cx.new(|cx| TestItem::new(cx).with_dirty(true));
workspace_a.update_in(cx, |workspace, window, cx| {
workspace.add_item_to_active_pane(Box::new(dirty_item.clone()), None, true, window, cx);
});
// Opening another project does not close the existing project or prompt.
let workspace_b = window
.update(cx, |mw, window, cx| {
mw.open_project(
vec![PathBuf::from(path!("/project_b"))],
OpenMode::Activate,
window,
cx,
)
})
.unwrap()
.await
.unwrap();
cx.run_until_parked();
assert!(!cx.has_pending_prompt());
assert_ne!(workspace_b, workspace_a);
window
.read_with(cx, |mw, _cx| {
assert_eq!(mw.workspaces().count(), 2);
assert_eq!(mw.workspace(), &workspace_b);
assert_eq!(
mw.project_group_keys(),
vec![
ProjectGroupKey::new(None, PathList::new(&[path!("/project_b")])),
ProjectGroupKey::new(None, PathList::new(&[path!("/project_a")]))
]
);
})
.unwrap();
assert!(workspace_a.read_with(cx, |workspace, _cx| workspace.session_id().is_some()),);
}

File diff suppressed because it is too large Load Diff

9428
crates/workspace/src/pane.rs Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,484 @@
use super::{SerializedAxis, SerializedWindowBounds};
use crate::{
Member, Pane, PaneAxis, SerializableItemRegistry, Workspace, WorkspaceId, item::ItemHandle,
multi_workspace::SerializedProjectGroupState, path_list::PathList,
};
use anyhow::{Context, Result};
use async_recursion::async_recursion;
use collections::IndexSet;
use db::sqlez::{
bindable::{Bind, Column, StaticColumnCount},
statement::Statement,
};
use gpui::{AsyncWindowContext, Entity, WeakEntity, WindowId};
use language::{Toolchain, ToolchainScope};
use project::{
Project, ProjectGroupKey, bookmark_store::SerializedBookmark,
debugger::breakpoint_store::SourceBreakpoint,
};
use remote::RemoteConnectionOptions;
use serde::{Deserialize, Serialize};
use std::{
collections::BTreeMap,
path::{Path, PathBuf},
sync::Arc,
};
use util::{ResultExt, path_list::SerializedPathList};
use uuid::Uuid;
#[derive(
Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy, serde::Serialize, serde::Deserialize,
)]
pub(crate) struct RemoteConnectionId(pub u64);
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub(crate) enum RemoteConnectionKind {
Ssh,
Wsl,
Docker,
}
#[derive(Debug, PartialEq, Clone, serde::Serialize, serde::Deserialize)]
pub enum SerializedWorkspaceLocation {
Local,
Remote(RemoteConnectionOptions),
}
impl SerializedWorkspaceLocation {
/// Get sorted paths
pub fn sorted_paths(&self) -> Arc<Vec<PathBuf>> {
unimplemented!()
}
}
/// A workspace entry from a previous session, containing all the info needed
/// to restore it including which window it belonged to (for MultiWorkspace grouping).
#[derive(Debug, PartialEq, Clone)]
pub struct SessionWorkspace {
pub workspace_id: WorkspaceId,
pub location: SerializedWorkspaceLocation,
pub paths: PathList,
pub window_id: Option<WindowId>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct SerializedProjectGroup {
pub path_list: SerializedPathList,
pub(crate) location: SerializedWorkspaceLocation,
#[serde(default = "default_expanded")]
pub expanded: bool,
}
fn default_expanded() -> bool {
true
}
impl SerializedProjectGroup {
pub fn from_group(key: &ProjectGroupKey, expanded: bool) -> Self {
Self {
path_list: key.path_list().serialize(),
location: match key.host() {
Some(host) => SerializedWorkspaceLocation::Remote(host),
None => SerializedWorkspaceLocation::Local,
},
expanded,
}
}
pub fn into_restored_state(self) -> SerializedProjectGroupState {
let path_list = PathList::deserialize(&self.path_list);
let host = match self.location {
SerializedWorkspaceLocation::Local => None,
SerializedWorkspaceLocation::Remote(opts) => Some(opts),
};
SerializedProjectGroupState {
key: ProjectGroupKey::new(host, path_list),
expanded: self.expanded,
}
}
}
impl From<SerializedProjectGroup> for ProjectGroupKey {
fn from(value: SerializedProjectGroup) -> Self {
value.into_restored_state().key
}
}
/// Per-window state for a MultiWorkspace, persisted to KVP.
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct MultiWorkspaceState {
pub active_workspace_id: Option<WorkspaceId>,
pub sidebar_open: bool,
#[serde(alias = "project_group_keys")]
pub project_groups: Vec<SerializedProjectGroup>,
#[serde(default)]
pub sidebar_state: Option<String>,
}
/// The serialized state of a single MultiWorkspace window from a previous session:
/// the active workspace to restore plus window-level state (project group keys,
/// sidebar).
#[derive(Debug, Clone)]
pub struct SerializedMultiWorkspace {
pub active_workspace: SessionWorkspace,
pub state: MultiWorkspaceState,
}
#[derive(Debug, PartialEq, Clone)]
pub(crate) struct SerializedWorkspace {
pub(crate) id: WorkspaceId,
pub(crate) location: SerializedWorkspaceLocation,
pub(crate) paths: PathList,
/// The workspace's main worktree paths at the time this workspace was saved.
///
/// These paths are used for grouping, deduping, and display in recent-workspace
/// UIs. They are not authoritative for reopening the workspace, because they may
/// become stale if the repository layout changes after the save. Use `paths` when
/// reopening the workspace.
pub(crate) identity_paths: Option<PathList>,
pub(crate) center_group: SerializedPaneGroup,
pub(crate) window_bounds: Option<SerializedWindowBounds>,
pub(crate) centered_layout: bool,
pub(crate) display: Option<Uuid>,
pub(crate) docks: DockStructure,
pub(crate) session_id: Option<String>,
pub(crate) bookmarks: BTreeMap<Arc<Path>, Vec<SerializedBookmark>>,
pub(crate) breakpoints: BTreeMap<Arc<Path>, Vec<SourceBreakpoint>>,
pub(crate) user_toolchains: BTreeMap<ToolchainScope, IndexSet<Toolchain>>,
pub(crate) window_id: Option<u64>,
}
#[derive(Debug, PartialEq, Clone, Default, Serialize, Deserialize)]
pub struct DockStructure {
pub left: DockData,
pub right: DockData,
pub bottom: DockData,
}
impl RemoteConnectionKind {
pub(crate) fn serialize(&self) -> &'static str {
match self {
RemoteConnectionKind::Ssh => "ssh",
RemoteConnectionKind::Wsl => "wsl",
RemoteConnectionKind::Docker => "docker",
}
}
pub(crate) fn deserialize(text: &str) -> Option<Self> {
match text {
"ssh" => Some(Self::Ssh),
"wsl" => Some(Self::Wsl),
"docker" => Some(Self::Docker),
_ => None,
}
}
}
impl Column for DockStructure {
fn column(statement: &mut Statement, start_index: i32) -> Result<(Self, i32)> {
let (left, next_index) = DockData::column(statement, start_index)?;
let (right, next_index) = DockData::column(statement, next_index)?;
let (bottom, next_index) = DockData::column(statement, next_index)?;
Ok((
DockStructure {
left,
right,
bottom,
},
next_index,
))
}
}
impl Bind for DockStructure {
fn bind(&self, statement: &Statement, start_index: i32) -> Result<i32> {
let next_index = statement.bind(&self.left, start_index)?;
let next_index = statement.bind(&self.right, next_index)?;
statement.bind(&self.bottom, next_index)
}
}
#[derive(Debug, PartialEq, Clone, Default, Serialize, Deserialize)]
pub struct DockData {
pub visible: bool,
pub active_panel: Option<String>,
pub zoom: bool,
}
impl Column for DockData {
fn column(statement: &mut Statement, start_index: i32) -> Result<(Self, i32)> {
let (visible, next_index) = Option::<bool>::column(statement, start_index)?;
let (active_panel, next_index) = Option::<String>::column(statement, next_index)?;
let (zoom, next_index) = Option::<bool>::column(statement, next_index)?;
Ok((
DockData {
visible: visible.unwrap_or(false),
active_panel,
zoom: zoom.unwrap_or(false),
},
next_index,
))
}
}
impl Bind for DockData {
fn bind(&self, statement: &Statement, start_index: i32) -> Result<i32> {
let next_index = statement.bind(&self.visible, start_index)?;
let next_index = statement.bind(&self.active_panel, next_index)?;
statement.bind(&self.zoom, next_index)
}
}
#[derive(Debug, PartialEq, Clone)]
pub(crate) enum SerializedPaneGroup {
Group {
axis: SerializedAxis,
flexes: Option<Vec<f32>>,
children: Vec<SerializedPaneGroup>,
},
Pane(SerializedPane),
}
#[cfg(test)]
impl Default for SerializedPaneGroup {
fn default() -> Self {
Self::Pane(SerializedPane {
children: vec![SerializedItem::default()],
active: false,
pinned_count: 0,
})
}
}
impl SerializedPaneGroup {
#[async_recursion(?Send)]
pub(crate) async fn deserialize(
self,
project: &Entity<Project>,
workspace_id: WorkspaceId,
workspace: WeakEntity<Workspace>,
cx: &mut AsyncWindowContext,
) -> Option<(
Member,
Option<Entity<Pane>>,
Vec<Option<Box<dyn ItemHandle>>>,
)> {
match self {
SerializedPaneGroup::Group {
axis,
children,
flexes,
} => {
let mut current_active_pane = None;
let mut members = Vec::new();
let mut items = Vec::new();
for child in children {
if let Some((new_member, active_pane, new_items)) = child
.deserialize(project, workspace_id, workspace.clone(), cx)
.await
{
members.push(new_member);
items.extend(new_items);
current_active_pane = current_active_pane.or(active_pane);
}
}
if members.is_empty() {
return None;
}
if members.len() == 1 {
return Some((members.remove(0), current_active_pane, items));
}
Some((
Member::Axis(PaneAxis::load(axis.0, members, flexes)),
current_active_pane,
items,
))
}
SerializedPaneGroup::Pane(serialized_pane) => {
let pane = workspace
.update_in(cx, |workspace, window, cx| {
workspace.add_pane(window, cx).downgrade()
})
.log_err()?;
let active = serialized_pane.active;
let new_items = serialized_pane
.deserialize_to(project, &pane, workspace_id, workspace.clone(), cx)
.await
.context("Could not deserialize pane)")
.log_err()?;
if pane
.read_with(cx, |pane, _| pane.items_len() != 0)
.log_err()?
{
let pane = pane.upgrade()?;
Some((
Member::Pane(pane.clone()),
active.then_some(pane),
new_items,
))
} else {
let pane = pane.upgrade()?;
workspace
.update_in(cx, |workspace, window, cx| {
workspace.force_remove_pane(&pane, &None, window, cx)
})
.log_err()?;
None
}
}
}
}
}
#[derive(Debug, PartialEq, Eq, Default, Clone)]
pub struct SerializedPane {
pub(crate) active: bool,
pub(crate) children: Vec<SerializedItem>,
pub(crate) pinned_count: usize,
}
impl SerializedPane {
pub fn new(children: Vec<SerializedItem>, active: bool, pinned_count: usize) -> Self {
SerializedPane {
children,
active,
pinned_count,
}
}
pub async fn deserialize_to(
&self,
project: &Entity<Project>,
pane: &WeakEntity<Pane>,
workspace_id: WorkspaceId,
workspace: WeakEntity<Workspace>,
cx: &mut AsyncWindowContext,
) -> Result<Vec<Option<Box<dyn ItemHandle>>>> {
let mut item_tasks = Vec::new();
let mut active_item_index = None;
let mut preview_item_index = None;
for (index, item) in self.children.iter().enumerate() {
let project = project.clone();
item_tasks.push(pane.update_in(cx, |_, window, cx| {
SerializableItemRegistry::deserialize(
&item.kind,
project,
workspace.clone(),
workspace_id,
item.item_id,
window,
cx,
)
})?);
if item.active {
active_item_index = Some(index);
}
if item.preview {
preview_item_index = Some(index);
}
}
let mut items = Vec::new();
for item_handle in futures::future::join_all(item_tasks).await {
let item_handle = item_handle.log_err();
items.push(item_handle.clone());
if let Some(item_handle) = item_handle {
pane.update_in(cx, |pane, window, cx| {
pane.add_item(item_handle.clone(), true, true, None, window, cx);
})?;
}
}
if let Some(active_item_index) = active_item_index {
pane.update_in(cx, |pane, window, cx| {
pane.activate_item(active_item_index, false, false, window, cx);
})?;
}
if let Some(preview_item_index) = preview_item_index {
pane.update(cx, |pane, cx| {
if let Some(item) = pane.item_for_index(preview_item_index) {
pane.set_preview_item_id(Some(item.item_id()), cx);
}
})?;
}
pane.update(cx, |pane, _| {
pane.set_pinned_count(self.pinned_count.min(items.len()));
})?;
anyhow::Ok(items)
}
}
pub type GroupId = i64;
pub type PaneId = i64;
pub type ItemId = u64;
#[derive(Debug, PartialEq, Eq, Clone)]
pub struct SerializedItem {
pub kind: Arc<str>,
pub item_id: ItemId,
pub active: bool,
pub preview: bool,
}
impl SerializedItem {
pub fn new(kind: impl AsRef<str>, item_id: ItemId, active: bool, preview: bool) -> Self {
Self {
kind: Arc::from(kind.as_ref()),
item_id,
active,
preview,
}
}
}
#[cfg(test)]
impl Default for SerializedItem {
fn default() -> Self {
SerializedItem {
kind: Arc::from("Terminal"),
item_id: 100000,
active: false,
preview: false,
}
}
}
impl StaticColumnCount for SerializedItem {
fn column_count() -> usize {
4
}
}
impl Bind for &SerializedItem {
fn bind(&self, statement: &Statement, start_index: i32) -> Result<i32> {
let next_index = statement.bind(&self.kind, start_index)?;
let next_index = statement.bind(&self.item_id, next_index)?;
let next_index = statement.bind(&self.active, next_index)?;
statement.bind(&self.preview, next_index)
}
}
impl Column for SerializedItem {
fn column(statement: &mut Statement, start_index: i32) -> Result<(Self, i32)> {
let (kind, next_index) = Arc::<str>::column(statement, start_index)?;
let (item_id, next_index) = ItemId::column(statement, next_index)?;
let (active, next_index) = bool::column(statement, next_index)?;
let (preview, next_index) = bool::column(statement, next_index)?;
Ok((
SerializedItem {
kind,
item_id,
active,
preview,
},
next_index,
))
}
}

View File

@@ -0,0 +1,554 @@
use std::{any::Any, sync::Arc};
use any_vec::AnyVec;
use gpui::{
AnyView, AnyWeakEntity, App, Context, Entity, EventEmitter, Subscription, Task, WeakEntity,
Window,
};
use project::search::SearchQuery;
use crate::{
ItemHandle,
item::{Item, WeakItemHandle},
};
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
pub struct SearchToken(u64);
impl SearchToken {
pub fn new(value: u64) -> Self {
Self(value)
}
pub fn value(&self) -> u64 {
self.0
}
}
#[derive(Debug, Clone)]
pub enum SearchEvent {
MatchesInvalidated,
ActiveMatchChanged,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub enum Direction {
Prev,
#[default]
Next,
}
impl Direction {
pub fn opposite(&self) -> Self {
match self {
Direction::Prev => Direction::Next,
Direction::Next => Direction::Prev,
}
}
}
#[derive(Clone, Copy, Debug, Default)]
pub struct SearchOptions {
pub case: bool,
pub word: bool,
pub regex: bool,
/// Specifies whether the supports search & replace.
pub replacement: bool,
pub selection: bool,
pub select_all: bool,
pub find_in_results: bool,
}
// Whether to always select the current selection (even if empty)
// or to use the default (restoring the previous search ranges if some,
// otherwise using the whole file).
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub enum FilteredSearchRange {
Selection,
#[default]
Default,
}
pub trait SearchableItem: Item + EventEmitter<SearchEvent> {
type Match: Any + Sync + Send + Clone;
fn supported_options(&self) -> SearchOptions {
SearchOptions {
case: true,
word: true,
regex: true,
replacement: true,
selection: true,
select_all: true,
find_in_results: false,
}
}
fn search_bar_visibility_changed(
&mut self,
_visible: bool,
_window: &mut Window,
_cx: &mut Context<Self>,
) {
}
fn has_filtered_search_ranges(&mut self) -> bool {
self.supported_options().selection
}
fn toggle_filtered_search_ranges(
&mut self,
_enabled: Option<FilteredSearchRange>,
_window: &mut Window,
_cx: &mut Context<Self>,
) {
}
fn get_matches(&self, _window: &mut Window, _: &mut App) -> (Vec<Self::Match>, SearchToken) {
(Vec::new(), SearchToken::default())
}
fn clear_matches(&mut self, window: &mut Window, cx: &mut Context<Self>);
fn update_matches(
&mut self,
matches: &[Self::Match],
active_match_index: Option<usize>,
token: SearchToken,
window: &mut Window,
cx: &mut Context<Self>,
);
fn query_suggestion(
&mut self,
ignore_settings: bool,
window: &mut Window,
cx: &mut Context<Self>,
) -> String;
fn activate_match(
&mut self,
index: usize,
matches: &[Self::Match],
token: SearchToken,
window: &mut Window,
cx: &mut Context<Self>,
);
fn select_matches(
&mut self,
matches: &[Self::Match],
token: SearchToken,
window: &mut Window,
cx: &mut Context<Self>,
);
fn replace(
&mut self,
_: &Self::Match,
_: &SearchQuery,
_token: SearchToken,
_window: &mut Window,
_: &mut Context<Self>,
);
fn replace_all(
&mut self,
matches: &mut dyn Iterator<Item = &Self::Match>,
query: &SearchQuery,
token: SearchToken,
window: &mut Window,
cx: &mut Context<Self>,
) {
for item in matches {
self.replace(item, query, token, window, cx);
}
}
fn match_index_for_direction(
&mut self,
matches: &[Self::Match],
current_index: usize,
direction: Direction,
count: usize,
_token: SearchToken,
_window: &mut Window,
_: &mut Context<Self>,
) -> usize {
match direction {
Direction::Prev => {
let count = count % matches.len();
if current_index >= count {
current_index - count
} else {
matches.len() - (count - current_index)
}
}
Direction::Next => (current_index + count) % matches.len(),
}
}
fn find_matches(
&mut self,
query: Arc<SearchQuery>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Task<Vec<Self::Match>>;
fn find_matches_with_token(
&mut self,
query: Arc<SearchQuery>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Task<(Vec<Self::Match>, SearchToken)> {
let matches = self.find_matches(query, window, cx);
cx.spawn(async move |_, _| (matches.await, SearchToken::default()))
}
fn active_match_index(
&mut self,
direction: Direction,
matches: &[Self::Match],
token: SearchToken,
window: &mut Window,
cx: &mut Context<Self>,
) -> Option<usize>;
fn set_search_is_case_sensitive(&mut self, _: Option<bool>, _: &mut Context<Self>) {}
}
pub trait SearchableItemHandle: ItemHandle {
fn downgrade(&self) -> Box<dyn WeakSearchableItemHandle>;
fn boxed_clone(&self) -> Box<dyn SearchableItemHandle>;
fn supported_options(&self, cx: &App) -> SearchOptions;
fn subscribe_to_search_events(
&self,
window: &mut Window,
cx: &mut App,
handler: Box<dyn Fn(&SearchEvent, &mut Window, &mut App) + Send>,
) -> Subscription;
fn clear_matches(&self, window: &mut Window, cx: &mut App);
fn update_matches(
&self,
matches: &AnyVec<dyn Send>,
active_match_index: Option<usize>,
token: SearchToken,
window: &mut Window,
cx: &mut App,
);
fn query_suggestion(&self, ignore_settings: bool, window: &mut Window, cx: &mut App) -> String;
fn activate_match(
&self,
index: usize,
matches: &AnyVec<dyn Send>,
token: SearchToken,
window: &mut Window,
cx: &mut App,
);
fn select_matches(
&self,
matches: &AnyVec<dyn Send>,
token: SearchToken,
window: &mut Window,
cx: &mut App,
);
fn replace(
&self,
_: any_vec::element::ElementRef<'_, dyn Send>,
_: &SearchQuery,
token: SearchToken,
_window: &mut Window,
_: &mut App,
);
fn replace_all(
&self,
matches: &mut dyn Iterator<Item = any_vec::element::ElementRef<'_, dyn Send>>,
query: &SearchQuery,
token: SearchToken,
window: &mut Window,
cx: &mut App,
);
fn match_index_for_direction(
&self,
matches: &AnyVec<dyn Send>,
current_index: usize,
direction: Direction,
count: usize,
token: SearchToken,
window: &mut Window,
cx: &mut App,
) -> usize;
fn find_matches(
&self,
query: Arc<SearchQuery>,
window: &mut Window,
cx: &mut App,
) -> Task<AnyVec<dyn Send>>;
fn find_matches_with_token(
&self,
query: Arc<SearchQuery>,
window: &mut Window,
cx: &mut App,
) -> Task<(AnyVec<dyn Send>, SearchToken)>;
fn active_match_index(
&self,
direction: Direction,
matches: &AnyVec<dyn Send>,
token: SearchToken,
window: &mut Window,
cx: &mut App,
) -> Option<usize>;
fn search_bar_visibility_changed(&self, visible: bool, window: &mut Window, cx: &mut App);
fn toggle_filtered_search_ranges(
&mut self,
enabled: Option<FilteredSearchRange>,
window: &mut Window,
cx: &mut App,
);
fn set_search_is_case_sensitive(&self, is_case_sensitive: Option<bool>, cx: &mut App);
}
impl<T: SearchableItem> SearchableItemHandle for Entity<T> {
fn downgrade(&self) -> Box<dyn WeakSearchableItemHandle> {
Box::new(self.downgrade())
}
fn boxed_clone(&self) -> Box<dyn SearchableItemHandle> {
Box::new(self.clone())
}
fn supported_options(&self, cx: &App) -> SearchOptions {
self.read(cx).supported_options()
}
fn subscribe_to_search_events(
&self,
window: &mut Window,
cx: &mut App,
handler: Box<dyn Fn(&SearchEvent, &mut Window, &mut App) + Send>,
) -> Subscription {
window.subscribe(self, cx, move |_, event: &SearchEvent, window, cx| {
handler(event, window, cx)
})
}
fn clear_matches(&self, window: &mut Window, cx: &mut App) {
self.update(cx, |this, cx| this.clear_matches(window, cx));
}
fn update_matches(
&self,
matches: &AnyVec<dyn Send>,
active_match_index: Option<usize>,
token: SearchToken,
window: &mut Window,
cx: &mut App,
) {
let matches = matches.downcast_ref().unwrap();
self.update(cx, |this, cx| {
this.update_matches(matches.as_slice(), active_match_index, token, window, cx)
});
}
fn query_suggestion(&self, ignore_settings: bool, window: &mut Window, cx: &mut App) -> String {
self.update(cx, |this, cx| {
this.query_suggestion(ignore_settings, window, cx)
})
}
fn activate_match(
&self,
index: usize,
matches: &AnyVec<dyn Send>,
token: SearchToken,
window: &mut Window,
cx: &mut App,
) {
let matches = matches.downcast_ref().unwrap();
self.update(cx, |this, cx| {
this.activate_match(index, matches.as_slice(), token, window, cx)
});
}
fn select_matches(
&self,
matches: &AnyVec<dyn Send>,
token: SearchToken,
window: &mut Window,
cx: &mut App,
) {
let matches = matches.downcast_ref().unwrap();
self.update(cx, |this, cx| {
this.select_matches(matches.as_slice(), token, window, cx)
});
}
fn match_index_for_direction(
&self,
matches: &AnyVec<dyn Send>,
current_index: usize,
direction: Direction,
count: usize,
token: SearchToken,
window: &mut Window,
cx: &mut App,
) -> usize {
let matches = matches.downcast_ref().unwrap();
self.update(cx, |this, cx| {
this.match_index_for_direction(
matches.as_slice(),
current_index,
direction,
count,
token,
window,
cx,
)
})
}
fn find_matches(
&self,
query: Arc<SearchQuery>,
window: &mut Window,
cx: &mut App,
) -> Task<AnyVec<dyn Send>> {
let matches = self.update(cx, |this, cx| this.find_matches(query, window, cx));
window.spawn(cx, async |_| {
let matches = matches.await;
let mut any_matches = AnyVec::with_capacity::<T::Match>(matches.len());
{
let mut any_matches = any_matches.downcast_mut::<T::Match>().unwrap();
for mat in matches {
any_matches.push(mat);
}
}
any_matches
})
}
fn find_matches_with_token(
&self,
query: Arc<SearchQuery>,
window: &mut Window,
cx: &mut App,
) -> Task<(AnyVec<dyn Send>, SearchToken)> {
let matches_with_token = self.update(cx, |this, cx| {
this.find_matches_with_token(query, window, cx)
});
window.spawn(cx, async |_| {
let (matches, token) = matches_with_token.await;
let mut any_matches = AnyVec::with_capacity::<T::Match>(matches.len());
{
let mut any_matches = any_matches.downcast_mut::<T::Match>().unwrap();
for mat in matches {
any_matches.push(mat);
}
}
(any_matches, token)
})
}
fn active_match_index(
&self,
direction: Direction,
matches: &AnyVec<dyn Send>,
token: SearchToken,
window: &mut Window,
cx: &mut App,
) -> Option<usize> {
let matches = matches.downcast_ref()?;
self.update(cx, |this, cx| {
this.active_match_index(direction, matches.as_slice(), token, window, cx)
})
}
fn replace(
&self,
mat: any_vec::element::ElementRef<'_, dyn Send>,
query: &SearchQuery,
token: SearchToken,
window: &mut Window,
cx: &mut App,
) {
let mat = mat.downcast_ref().unwrap();
self.update(cx, |this, cx| this.replace(mat, query, token, window, cx))
}
fn replace_all(
&self,
matches: &mut dyn Iterator<Item = any_vec::element::ElementRef<'_, dyn Send>>,
query: &SearchQuery,
token: SearchToken,
window: &mut Window,
cx: &mut App,
) {
self.update(cx, |this, cx| {
this.replace_all(
&mut matches.map(|m| m.downcast_ref().unwrap()),
query,
token,
window,
cx,
);
})
}
fn search_bar_visibility_changed(&self, visible: bool, window: &mut Window, cx: &mut App) {
self.update(cx, |this, cx| {
this.search_bar_visibility_changed(visible, window, cx)
});
}
fn toggle_filtered_search_ranges(
&mut self,
enabled: Option<FilteredSearchRange>,
window: &mut Window,
cx: &mut App,
) {
self.update(cx, |this, cx| {
this.toggle_filtered_search_ranges(enabled, window, cx)
});
}
fn set_search_is_case_sensitive(&self, enabled: Option<bool>, cx: &mut App) {
self.update(cx, |this, cx| {
this.set_search_is_case_sensitive(enabled, cx)
});
}
}
impl From<Box<dyn SearchableItemHandle>> for AnyView {
fn from(this: Box<dyn SearchableItemHandle>) -> Self {
this.to_any_view()
}
}
impl From<&Box<dyn SearchableItemHandle>> for AnyView {
fn from(this: &Box<dyn SearchableItemHandle>) -> Self {
this.to_any_view()
}
}
impl PartialEq for Box<dyn SearchableItemHandle> {
fn eq(&self, other: &Self) -> bool {
self.item_id() == other.item_id()
}
}
impl Eq for Box<dyn SearchableItemHandle> {}
pub trait WeakSearchableItemHandle: WeakItemHandle {
fn upgrade(&self, cx: &App) -> Option<Box<dyn SearchableItemHandle>>;
fn into_any(self) -> AnyWeakEntity;
}
impl<T: SearchableItem> WeakSearchableItemHandle for WeakEntity<T> {
fn upgrade(&self, _cx: &App) -> Option<Box<dyn SearchableItemHandle>> {
Some(Box::new(self.upgrade()?))
}
fn into_any(self) -> AnyWeakEntity {
self.into()
}
}
impl PartialEq for Box<dyn WeakSearchableItemHandle> {
fn eq(&self, other: &Self) -> bool {
self.id() == other.id()
}
}
impl Eq for Box<dyn WeakSearchableItemHandle> {}
impl std::hash::Hash for Box<dyn WeakSearchableItemHandle> {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.id().hash(state)
}
}

View File

@@ -0,0 +1,369 @@
//! A UI interface for managing the [`TrustedWorktrees`] data.
use std::{
borrow::Cow,
path::{Path, PathBuf},
sync::Arc,
};
use collections::{HashMap, HashSet};
use gpui::{DismissEvent, EventEmitter, FocusHandle, Focusable, ScrollHandle, WeakEntity};
use project::{
WorktreeId,
trusted_worktrees::{PathTrust, RemoteHostLocation, TrustedWorktrees},
worktree_store::WorktreeStore,
};
use smallvec::SmallVec;
use theme::ActiveTheme;
use ui::{
AlertModal, Checkbox, FluentBuilder, KeyBinding, ListBulletItem, ToggleState, WithScrollbar,
prelude::*,
};
use crate::{DismissDecision, ModalView, ToggleWorktreeSecurity};
pub struct SecurityModal {
restricted_paths: HashMap<WorktreeId, RestrictedPath>,
home_dir: Option<PathBuf>,
trust_parents: bool,
worktree_store: WeakEntity<WorktreeStore>,
remote_host: Option<RemoteHostLocation>,
focus_handle: FocusHandle,
project_list_scroll_handle: ScrollHandle,
trusted: Option<bool>,
}
#[derive(Debug, PartialEq, Eq)]
struct RestrictedPath {
abs_path: Arc<Path>,
is_file: bool,
host: Option<RemoteHostLocation>,
}
impl Focusable for SecurityModal {
fn focus_handle(&self, _: &ui::App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl EventEmitter<DismissEvent> for SecurityModal {}
impl ModalView for SecurityModal {
fn fade_out_background(&self) -> bool {
true
}
fn on_before_dismiss(&mut self, _: &mut Window, _: &mut Context<Self>) -> DismissDecision {
match self.trusted {
Some(false) => telemetry::event!("Open in Restricted", source = "Worktree Trust Modal"),
Some(true) => telemetry::event!("Trust and Continue", source = "Worktree Trust Modal"),
None => telemetry::event!("Dismissed", source = "Worktree Trust Modal"),
}
DismissDecision::Dismiss(true)
}
}
impl Render for SecurityModal {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
if self.restricted_paths.is_empty() {
self.dismiss(cx);
return v_flex().into_any_element();
}
let restricted_count = self.restricted_paths.len();
let header_label: SharedString = if restricted_count == 1 {
"Unrecognized Project".into()
} else {
format!("Unrecognized Projects ({})", restricted_count).into()
};
let trust_label = self.build_trust_label();
AlertModal::new("security-modal")
.width(rems(40.))
.key_context("SecurityModal")
.track_focus(&self.focus_handle(cx))
.on_action(cx.listener(|this, _: &menu::Confirm, _window, cx| {
this.trust_and_dismiss(cx);
}))
.on_action(cx.listener(|security_modal, _: &ToggleWorktreeSecurity, _window, cx| {
security_modal.trusted = Some(false);
security_modal.dismiss(cx);
}))
.header(
v_flex()
.p_3()
.gap_1()
.rounded_t_md()
.bg(cx.theme().colors().editor_background.opacity(0.5))
.border_b_1()
.border_color(cx.theme().colors().border_variant)
.child(
h_flex()
.gap_2()
.child(Icon::new(IconName::Warning).color(Color::Warning))
.child(Label::new(header_label)),
)
.child(
div()
.size_full()
.vertical_scrollbar_for(&self.project_list_scroll_handle, window, cx)
.child(
v_flex()
.id("paths_container")
.max_h_24()
.overflow_y_scroll()
.track_scroll(&self.project_list_scroll_handle)
.children(
self.restricted_paths.values().filter_map(
|restricted_path| {
let abs_path = if restricted_path.is_file {
restricted_path.abs_path.parent()
} else {
Some(restricted_path.abs_path.as_ref())
}?;
let label = match &restricted_path.host {
Some(remote_host) => {
match &remote_host.user_name {
Some(user_name) => format!(
"{} ({}@{})",
self.shorten_path(abs_path)
.display(),
user_name,
remote_host.host_identifier
),
None => format!(
"{} ({})",
self.shorten_path(abs_path)
.display(),
remote_host.host_identifier
),
}
}
None => self
.shorten_path(abs_path)
.display()
.to_string(),
};
Some(
h_flex()
.pl(
IconSize::default().rems() + rems(0.5),
)
.child(
Label::new(label).color(Color::Muted),
),
)
},
),
),
),
),
)
.child(
v_flex()
.gap_2()
.child(
v_flex()
.child(
Label::new(
"Untrusted projects are opened in Restricted Mode to protect your system.",
)
.color(Color::Muted),
)
.child(
Label::new(
"Review .zed/settings.json for any extensions or commands configured by this project.",
)
.color(Color::Muted),
),
)
.child(
v_flex()
.child(Label::new("Restricted Mode prevents:").color(Color::Muted))
.child(ListBulletItem::new("Project settings from being applied"))
.child(ListBulletItem::new("Language servers from running"))
.child(ListBulletItem::new("MCP Server integrations from installing")),
)
.map(|this| match trust_label {
Some(trust_label) => this.child(
Checkbox::new("trust-parents", ToggleState::from(self.trust_parents))
.label(trust_label)
.on_click(cx.listener(
|security_modal, state: &ToggleState, _, cx| {
security_modal.trust_parents = state.selected();
cx.notify();
cx.stop_propagation();
},
)),
),
None => this,
}),
)
.footer(
h_flex()
.px_3()
.pb_3()
.gap_1()
.justify_end()
.child(
Button::new("rm", "Stay in Restricted Mode")
.key_binding(
KeyBinding::for_action(
&ToggleWorktreeSecurity,
cx,
)
.map(|kb| kb.size(rems_from_px(12.))),
)
.on_click(cx.listener(move |security_modal, _, _, cx| {
security_modal.trusted = Some(false);
security_modal.dismiss(cx);
cx.stop_propagation();
})),
)
.child(
Button::new("tc", "Trust and Continue")
.style(ButtonStyle::Filled)
.layer(ui::ElevationIndex::ModalSurface)
.key_binding(
KeyBinding::for_action(&menu::Confirm, cx)
.map(|kb| kb.size(rems_from_px(12.))),
)
.on_click(cx.listener(move |security_modal, _, _, cx| {
security_modal.trust_and_dismiss(cx);
cx.stop_propagation();
})),
),
)
.into_any_element()
}
}
impl SecurityModal {
pub fn new(
worktree_store: WeakEntity<WorktreeStore>,
remote_host: Option<impl Into<RemoteHostLocation>>,
cx: &mut Context<Self>,
) -> Self {
let mut this = Self {
worktree_store,
remote_host: remote_host.map(|host| host.into()),
restricted_paths: HashMap::default(),
focus_handle: cx.focus_handle(),
project_list_scroll_handle: ScrollHandle::new(),
trust_parents: false,
home_dir: std::env::home_dir(),
trusted: None,
};
this.refresh_restricted_paths(cx);
this
}
fn build_trust_label(&self) -> Option<Cow<'static, str>> {
let mut has_restricted_files = false;
let available_parents = self
.restricted_paths
.values()
.filter(|restricted_path| {
has_restricted_files |= restricted_path.is_file;
!restricted_path.is_file
})
.filter_map(|restricted_path| restricted_path.abs_path.parent())
.collect::<SmallVec<[_; 2]>>();
match available_parents.len() {
0 => {
if has_restricted_files {
Some(Cow::Borrowed("Trust all single files"))
} else {
None
}
}
1 => Some(Cow::Owned(format!(
"Trust all projects in the {:} folder",
self.shorten_path(available_parents[0]).display()
))),
_ => Some(Cow::Borrowed("Trust all projects in the parent folders")),
}
}
fn shorten_path<'a>(&self, path: &'a Path) -> Cow<'a, Path> {
match &self.home_dir {
Some(home_dir) => path
.strip_prefix(home_dir)
.map(|stripped| Path::new("~").join(stripped))
.map(Cow::Owned)
.unwrap_or(Cow::Borrowed(path)),
None => Cow::Borrowed(path),
}
}
fn trust_and_dismiss(&mut self, cx: &mut Context<Self>) {
if let Some((trusted_worktrees, worktree_store)) =
TrustedWorktrees::try_get_global(cx).zip(self.worktree_store.upgrade())
{
trusted_worktrees.update(cx, |trusted_worktrees, cx| {
let mut paths_to_trust = self
.restricted_paths
.keys()
.copied()
.map(PathTrust::Worktree)
.collect::<HashSet<_>>();
if self.trust_parents {
paths_to_trust.extend(self.restricted_paths.values().filter_map(
|restricted_paths| {
if restricted_paths.is_file {
None
} else {
let parent_abs_path =
restricted_paths.abs_path.parent()?.to_owned();
Some(PathTrust::AbsPath(parent_abs_path))
}
},
));
}
trusted_worktrees.trust(&worktree_store, paths_to_trust, cx);
});
}
self.trusted = Some(true);
self.dismiss(cx);
}
pub fn dismiss(&mut self, cx: &mut Context<Self>) {
cx.emit(DismissEvent);
}
pub fn refresh_restricted_paths(&mut self, cx: &mut Context<Self>) {
if let Some(trusted_worktrees) = TrustedWorktrees::try_get_global(cx) {
if let Some(worktree_store) = self.worktree_store.upgrade() {
let new_restricted_worktrees = trusted_worktrees
.read(cx)
.restricted_worktrees(&worktree_store, cx)
.into_iter()
.filter_map(|(worktree_id, abs_path)| {
let worktree = worktree_store.read(cx).worktree_for_id(worktree_id, cx)?;
Some((
worktree_id,
RestrictedPath {
abs_path,
is_file: worktree.read(cx).is_single_file(),
host: self.remote_host.clone(),
},
))
})
.collect::<HashMap<_, _>>();
if self.restricted_paths != new_restricted_worktrees {
self.trust_parents = false;
self.restricted_paths = new_restricted_worktrees;
cx.notify();
}
}
} else if !self.restricted_paths.is_empty() {
self.restricted_paths.clear();
cx.notify();
}
}
}

View File

@@ -0,0 +1,124 @@
use crate::{
ItemNavHistory, WorkspaceId,
item::{Item, ItemEvent},
};
use client::{User, proto::PeerId};
use gpui::{
AnyView, AppContext as _, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement,
ParentElement, Render, SharedString, Styled, Task, div,
};
use std::sync::Arc;
use ui::{Icon, IconName, prelude::*};
pub enum Event {
Close,
}
pub struct SharedScreen {
pub peer_id: PeerId,
user: Arc<User>,
nav_history: Option<ItemNavHistory>,
view: AnyView,
clone_view: fn(&AnyView, &mut Window, &mut App) -> AnyView,
focus: FocusHandle,
}
impl SharedScreen {
pub fn new(
peer_id: PeerId,
user: Arc<User>,
view: AnyView,
clone_view: fn(&AnyView, &mut Window, &mut App) -> AnyView,
cx: &mut Context<Self>,
) -> Self {
Self {
view,
peer_id,
user,
nav_history: Default::default(),
focus: cx.focus_handle(),
clone_view,
}
}
}
impl EventEmitter<Event> for SharedScreen {}
impl Focusable for SharedScreen {
fn focus_handle(&self, _: &App) -> FocusHandle {
self.focus.clone()
}
}
impl Render for SharedScreen {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
div()
.bg(cx.theme().colors().editor_background)
.track_focus(&self.focus)
.key_context("SharedScreen")
.size_full()
.child(self.view.clone())
}
}
impl Item for SharedScreen {
type Event = Event;
fn tab_tooltip_text(&self, _: &App) -> Option<SharedString> {
Some(format!("{}'s screen", self.user.github_login).into())
}
fn deactivated(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
if let Some(nav_history) = self.nav_history.as_mut() {
nav_history.push::<()>(None, None, cx);
}
}
fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
Some(Icon::new(IconName::Screen))
}
fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
format!("{}'s screen", self.user.github_login).into()
}
fn telemetry_event_text(&self) -> Option<&'static str> {
None
}
fn set_nav_history(
&mut self,
history: ItemNavHistory,
_window: &mut Window,
_cx: &mut Context<Self>,
) {
self.nav_history = Some(history);
}
fn can_split(&self) -> bool {
true
}
fn clone_on_split(
&self,
_workspace_id: Option<WorkspaceId>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Task<Option<Entity<Self>>> {
let clone_view = self.clone_view;
let cloned_view = clone_view(&self.view, window, cx);
Task::ready(Some(cx.new(|cx| Self {
view: cloned_view,
peer_id: self.peer_id,
user: self.user.clone(),
nav_history: Default::default(),
focus: cx.focus_handle(),
clone_view,
})))
}
fn to_item_events(event: &Self::Event, f: &mut dyn FnMut(ItemEvent)) {
match event {
Event::Close => f(ItemEvent::CloseItem),
}
}
}

View File

@@ -0,0 +1,446 @@
use crate::{
ItemHandle, MultiWorkspace, Pane, SidebarSide, ToggleWorkspaceSidebar,
sidebar_side_context_menu,
};
use gpui::{
Anchor, AnyView, App, Context, Decorations, Entity, IntoElement, ParentElement, Render,
SharedString, Styled, Subscription, WeakEntity, Window,
};
use settings::{SettingsContent, update_settings_file};
use std::{any::TypeId, sync::Arc};
use theme::CLIENT_SIDE_DECORATION_ROUNDING;
use ui::{ContextMenu, Divider, IconPosition, Indicator, Tooltip, prelude::*, right_click_menu};
/// Describes how a status-bar item can be hidden by the user.
///
/// Every [`StatusItemView`] must either provide this (so that the user gets a
/// "Hide Button" entry in the right-click menu) or explicitly return `None`
/// to opt out. Returning `None` should be reserved for items that are
/// already conditional on some other setting exposed elsewhere (e.g., the
/// activity indicator, which disappears on its own once there's no work to
/// display).
#[derive(Clone)]
pub struct HideStatusItem {
hide: Arc<dyn Fn(&mut SettingsContent) + Send + Sync>,
}
impl HideStatusItem {
pub fn new(hide: impl Fn(&mut SettingsContent) + Send + Sync + 'static) -> Self {
Self {
hide: Arc::new(hide),
}
}
/// Persists the hide by updating the user settings file.
pub fn apply(&self, cx: &App) {
let hide = self.hide.clone();
let fs = <dyn fs::Fs>::global(cx);
update_settings_file(fs, cx, move |settings, _cx| (hide)(settings));
}
}
pub trait StatusItemView: Render {
/// Event callback that is triggered when the active pane item changes.
fn set_active_pane_item(
&mut self,
active_pane_item: Option<&dyn crate::ItemHandle>,
window: &mut Window,
cx: &mut Context<Self>,
);
/// Returns metadata describing how this item can be hidden from the
/// status bar by writing to the user settings file.
///
/// Implementors that return `None` must be inherently conditional on
/// another user-exposed setting; otherwise, they should return `Some` so
/// that the status bar can show a "Hide Button" entry in its
/// right-click menu.
fn hide_setting(&self, cx: &App) -> Option<HideStatusItem>;
}
trait StatusItemViewHandle: Send {
fn to_any(&self) -> AnyView;
fn set_active_pane_item(
&self,
active_pane_item: Option<&dyn ItemHandle>,
window: &mut Window,
cx: &mut App,
);
fn item_type(&self) -> TypeId;
fn hide_setting(&self, cx: &App) -> Option<HideStatusItem>;
}
#[derive(Default)]
struct SidebarStatus {
open: bool,
side: SidebarSide,
has_notifications: bool,
show_toggle: bool,
}
impl SidebarStatus {
fn query(multi_workspace: &Option<WeakEntity<MultiWorkspace>>, cx: &App) -> Self {
multi_workspace
.as_ref()
.and_then(|mw| mw.upgrade())
.map(|mw| {
let mw = mw.read(cx);
let enabled = mw.multi_workspace_enabled(cx);
Self {
open: mw.sidebar_open() && enabled,
side: mw.sidebar_side(cx),
has_notifications: mw.sidebar_has_notifications(cx),
show_toggle: enabled,
}
})
.unwrap_or_default()
}
}
pub struct StatusBar {
left_items: Vec<Box<dyn StatusItemViewHandle>>,
right_items: Vec<Box<dyn StatusItemViewHandle>>,
active_pane: Entity<Pane>,
multi_workspace: Option<WeakEntity<MultiWorkspace>>,
_observe_active_pane: Subscription,
}
impl Render for StatusBar {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let sidebar = SidebarStatus::query(&self.multi_workspace, cx);
h_flex()
.w_full()
.justify_between()
.gap(DynamicSpacing::Base08.rems(cx))
.p(DynamicSpacing::Base04.rems(cx))
.bg(cx.theme().colors().status_bar_background)
.map(|el| match window.window_decorations() {
Decorations::Server => el,
Decorations::Client { tiling, .. } => el
.when(
!(tiling.bottom || tiling.right)
&& !(sidebar.open && sidebar.side == SidebarSide::Right),
|el| el.rounded_br(CLIENT_SIDE_DECORATION_ROUNDING),
)
.when(
!(tiling.bottom || tiling.left)
&& !(sidebar.open && sidebar.side == SidebarSide::Left),
|el| el.rounded_bl(CLIENT_SIDE_DECORATION_ROUNDING),
)
// This border is to avoid a transparent gap in the rounded corners
.mb(px(-1.))
.mt({
#[cfg(target_os = "linux")]
let needs_gap_fix = {
// Running on Wayland and using some scaling levels other than 100% causes a
// 1px gap above the status bar; adding a margin avoids this.
gpui::guess_compositor() == "Wayland" && window.scale_factor() != 1.0
};
#[cfg(not(target_os = "linux"))]
let needs_gap_fix = false;
if needs_gap_fix { px(-1.) } else { px(0.) }
})
.border_b(px(1.0))
.border_color(cx.theme().colors().status_bar_background),
})
.child(self.render_left_tools(&sidebar, cx))
.child(self.render_right_tools(&sidebar, cx))
}
}
impl StatusBar {
fn render_left_tools(
&self,
sidebar: &SidebarStatus,
cx: &mut Context<Self>,
) -> impl IntoElement {
h_flex()
.gap_1()
.min_w_0()
.overflow_x_hidden()
.when(
sidebar.show_toggle && !sidebar.open && sidebar.side == SidebarSide::Left,
|this| this.child(self.render_sidebar_toggle(sidebar, cx)),
)
.children(self.left_items.iter().enumerate().map(|(index, item)| {
render_hideable_item("status-bar-left", index, item.as_ref(), cx)
}))
}
fn render_right_tools(
&self,
sidebar: &SidebarStatus,
cx: &mut Context<Self>,
) -> impl IntoElement {
h_flex()
.flex_shrink_0()
.gap_1()
.overflow_x_hidden()
.children(
self.right_items
.iter()
.enumerate()
.rev()
.map(|(index, item)| {
render_hideable_item("status-bar-right", index, item.as_ref(), cx)
}),
)
.when(
sidebar.show_toggle && !sidebar.open && sidebar.side == SidebarSide::Right,
|this| this.child(self.render_sidebar_toggle(sidebar, cx)),
)
}
fn render_sidebar_toggle(
&self,
sidebar: &SidebarStatus,
cx: &mut Context<Self>,
) -> impl IntoElement {
let on_right = sidebar.side == SidebarSide::Right;
let has_notifications = sidebar.has_notifications;
let indicator_border = cx.theme().colors().status_bar_background;
let toggle = sidebar_side_context_menu("sidebar-status-toggle-menu", cx)
.anchor(if on_right {
Anchor::BottomRight
} else {
Anchor::BottomLeft
})
.attach(if on_right {
Anchor::TopRight
} else {
Anchor::TopLeft
})
.trigger(move |_is_active, _window, _cx| {
IconButton::new(
"toggle-workspace-sidebar",
if on_right {
IconName::ThreadsSidebarRightClosed
} else {
IconName::ThreadsSidebarLeftClosed
},
)
.icon_size(IconSize::Small)
.when(has_notifications, |this| {
this.indicator(Indicator::dot().color(Color::Accent))
.indicator_border_color(Some(indicator_border))
})
.tooltip(move |_, cx| {
Tooltip::for_action("Open Threads Sidebar", &ToggleWorkspaceSidebar, cx)
})
.on_click(move |_, window, cx| {
if let Some(multi_workspace) = window.root::<MultiWorkspace>().flatten() {
multi_workspace.update(cx, |multi_workspace, cx| {
multi_workspace.toggle_sidebar(window, cx);
});
}
})
});
h_flex()
.gap_0p5()
.when(on_right, |this| {
this.child(Divider::vertical().color(ui::DividerColor::Border))
})
.child(toggle)
.when(!on_right, |this| {
this.child(Divider::vertical().color(ui::DividerColor::Border))
})
}
}
fn render_hideable_item(
side: &'static str,
index: usize,
item: &dyn StatusItemViewHandle,
cx: &App,
) -> impl IntoElement {
let view = item.to_any();
let Some(hide) = item.hide_setting(cx) else {
return view.into_any_element();
};
let menu_id: SharedString = format!("{side}-item-menu-{index}").into();
right_click_menu(menu_id)
.trigger(move |_is_active, _window, _cx| view)
.menu(move |window, cx| {
let hide = hide.clone();
ContextMenu::build(window, cx, move |menu, _window, _cx| {
add_hide_button_entry(menu, hide)
})
})
.into_any_element()
}
/// Appends a "Hide Button" entry aligned with surrounding toggleable entries.
pub fn add_hide_button_entry(menu: ContextMenu, hide: HideStatusItem) -> ContextMenu {
menu.toggleable_entry(
"Hide Button",
false,
IconPosition::Start,
None,
move |_window, cx| hide.apply(cx),
)
}
impl StatusBar {
pub fn new(
active_pane: &Entity<Pane>,
multi_workspace: Option<WeakEntity<MultiWorkspace>>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let mut this = Self {
left_items: Default::default(),
right_items: Default::default(),
active_pane: active_pane.clone(),
multi_workspace,
_observe_active_pane: cx.observe_in(active_pane, window, |this, _, window, cx| {
this.update_active_pane_item(window, cx)
}),
};
this.update_active_pane_item(window, cx);
this
}
pub fn set_multi_workspace(
&mut self,
multi_workspace: WeakEntity<MultiWorkspace>,
cx: &mut Context<Self>,
) {
self.multi_workspace = Some(multi_workspace);
cx.notify();
}
pub fn add_left_item<T>(&mut self, item: Entity<T>, window: &mut Window, cx: &mut Context<Self>)
where
T: 'static + StatusItemView,
{
let active_pane_item = self.active_pane.read(cx).active_item();
item.set_active_pane_item(active_pane_item.as_deref(), window, cx);
self.left_items.push(Box::new(item));
cx.notify();
}
pub fn item_of_type<T: StatusItemView>(&self) -> Option<Entity<T>> {
self.left_items
.iter()
.chain(self.right_items.iter())
.find_map(|item| item.to_any().downcast().ok())
}
pub fn position_of_item<T>(&self) -> Option<usize>
where
T: StatusItemView,
{
for (index, item) in self.left_items.iter().enumerate() {
if item.item_type() == TypeId::of::<T>() {
return Some(index);
}
}
for (index, item) in self.right_items.iter().enumerate() {
if item.item_type() == TypeId::of::<T>() {
return Some(index + self.left_items.len());
}
}
None
}
pub fn insert_item_after<T>(
&mut self,
position: usize,
item: Entity<T>,
window: &mut Window,
cx: &mut Context<Self>,
) where
T: 'static + StatusItemView,
{
let active_pane_item = self.active_pane.read(cx).active_item();
item.set_active_pane_item(active_pane_item.as_deref(), window, cx);
if position < self.left_items.len() {
self.left_items.insert(position + 1, Box::new(item))
} else {
self.right_items
.insert(position + 1 - self.left_items.len(), Box::new(item))
}
cx.notify()
}
pub fn remove_item_at(&mut self, position: usize, cx: &mut Context<Self>) {
if position < self.left_items.len() {
self.left_items.remove(position);
} else {
self.right_items.remove(position - self.left_items.len());
}
cx.notify();
}
pub fn add_right_item<T>(
&mut self,
item: Entity<T>,
window: &mut Window,
cx: &mut Context<Self>,
) where
T: 'static + StatusItemView,
{
let active_pane_item = self.active_pane.read(cx).active_item();
item.set_active_pane_item(active_pane_item.as_deref(), window, cx);
self.right_items.push(Box::new(item));
cx.notify();
}
pub fn set_active_pane(
&mut self,
active_pane: &Entity<Pane>,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.active_pane = active_pane.clone();
self._observe_active_pane = cx.observe_in(active_pane, window, |this, _, window, cx| {
this.update_active_pane_item(window, cx)
});
self.update_active_pane_item(window, cx);
}
fn update_active_pane_item(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let active_pane_item = self.active_pane.read(cx).active_item();
for item in self.left_items.iter().chain(&self.right_items) {
item.set_active_pane_item(active_pane_item.as_deref(), window, cx);
}
}
}
impl<T: StatusItemView> StatusItemViewHandle for Entity<T> {
fn to_any(&self) -> AnyView {
self.clone().into()
}
fn set_active_pane_item(
&self,
active_pane_item: Option<&dyn ItemHandle>,
window: &mut Window,
cx: &mut App,
) {
self.update(cx, |this, cx| {
this.set_active_pane_item(active_pane_item, window, cx)
});
}
fn item_type(&self) -> TypeId {
TypeId::of::<T>()
}
fn hide_setting(&self, cx: &App) -> Option<HideStatusItem> {
self.read(cx).hide_setting(cx)
}
}
impl From<&dyn StatusItemViewHandle> for AnyView {
fn from(val: &dyn StatusItemViewHandle) -> Self {
val.to_any()
}
}

View File

@@ -0,0 +1,506 @@
use std::process::ExitStatus;
use anyhow::Result;
use collections::HashSet;
use gpui::{AppContext, AsyncWindowContext, Context, Entity, Task, TaskExt, WeakEntity};
use language::Buffer;
use project::{TaskSourceKind, WorktreeId};
use remote::ConnectionState;
use task::{
DebugScenario, ResolvedTask, SaveStrategy, SharedTaskContext, SpawnInTerminal, TaskContext,
TaskHook, TaskTemplate, TaskVariables, VariableName,
};
use ui::Window;
use util::TryFutureExt;
use crate::{SaveIntent, Toast, Workspace, notifications::NotificationId};
impl Workspace {
pub fn schedule_task(
self: &mut Workspace,
task_source_kind: TaskSourceKind,
task_to_resolve: &TaskTemplate,
task_cx: &TaskContext,
omit_history: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
match self.project.read(cx).remote_connection_state(cx) {
None | Some(ConnectionState::Connected) => {}
Some(
ConnectionState::Connecting
| ConnectionState::Disconnected
| ConnectionState::HeartbeatMissed
| ConnectionState::Reconnecting,
) => {
log::warn!("Cannot schedule tasks when disconnected from a remote host");
return;
}
}
if let Some(spawn_in_terminal) =
task_to_resolve.resolve_task(&task_source_kind.to_id_base(), task_cx)
{
self.schedule_resolved_task(
task_source_kind,
spawn_in_terminal,
omit_history,
window,
cx,
);
}
}
pub fn schedule_resolved_task(
self: &mut Workspace,
task_source_kind: TaskSourceKind,
resolved_task: ResolvedTask,
omit_history: bool,
window: &mut Window,
cx: &mut Context<Workspace>,
) {
let spawn_in_terminal = resolved_task.resolved.clone();
if !omit_history {
if let Some(debugger_provider) = self.debugger_provider.as_ref() {
debugger_provider.task_scheduled(cx);
}
self.project().update(cx, |project, cx| {
if let Some(task_inventory) =
project.task_store().read(cx).task_inventory().cloned()
{
task_inventory.update(cx, |inventory, _| {
inventory.task_scheduled(task_source_kind, resolved_task);
})
}
});
}
if self.terminal_provider.is_some() {
let task = cx.spawn_in(window, async move |workspace, cx| {
Self::save_for_task(&workspace, spawn_in_terminal.save, cx).await;
let spawn_task = workspace.update_in(cx, |workspace, window, cx| {
workspace
.terminal_provider
.as_ref()
.map(|terminal_provider| {
terminal_provider.spawn(spawn_in_terminal, window, cx)
})
});
if let Some(spawn_task) = spawn_task.ok().flatten() {
let res = cx.background_spawn(spawn_task).await;
match res {
Some(Ok(status)) => {
if status.success() {
log::debug!("Task spawn succeeded");
} else {
log::debug!("Task spawn failed, code: {:?}", status.code());
}
}
Some(Err(e)) => {
log::error!("Task spawn failed: {e:#}");
_ = workspace.update(cx, |w, cx| {
let id = NotificationId::unique::<ResolvedTask>();
w.show_toast(Toast::new(id, format!("Task spawn failed: {e}")), cx);
})
}
None => log::debug!("Task spawn got cancelled"),
};
}
});
self.scheduled_tasks.push(task);
}
}
pub async fn save_for_task(
workspace: &WeakEntity<Self>,
save_strategy: SaveStrategy,
cx: &mut AsyncWindowContext,
) {
let save_action = match save_strategy {
SaveStrategy::All => {
let save_all = workspace.update_in(cx, |workspace, window, cx| {
let task = workspace.save_all_internal(SaveIntent::SaveAll, true, window, cx);
cx.background_spawn(async { task.await.map(|_| ()) })
});
save_all.ok()
}
SaveStrategy::Current => {
let save_current = workspace.update_in(cx, |workspace, window, cx| {
workspace.save_active_item(SaveIntent::SaveAll, window, cx)
});
save_current.ok()
}
SaveStrategy::None => None,
};
if let Some(save_action) = save_action {
save_action.log_err().await;
}
}
pub fn start_debug_session(
&mut self,
scenario: DebugScenario,
task_context: SharedTaskContext,
active_buffer: Option<Entity<Buffer>>,
worktree_id: Option<WorktreeId>,
window: &mut Window,
cx: &mut Context<Self>,
) {
if let Some(provider) = self.debugger_provider.as_mut() {
provider.start_session(
scenario,
task_context,
active_buffer,
worktree_id,
window,
cx,
)
}
}
pub fn spawn_in_terminal(
self: &mut Workspace,
spawn_in_terminal: SpawnInTerminal,
window: &mut Window,
cx: &mut Context<Workspace>,
) -> Task<Option<Result<ExitStatus>>> {
if let Some(terminal_provider) = self.terminal_provider.as_ref() {
terminal_provider.spawn(spawn_in_terminal, window, cx)
} else {
Task::ready(None)
}
}
pub fn run_create_worktree_tasks(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let project = self.project().clone();
let hooks = HashSet::from_iter([TaskHook::CreateWorktree]);
let worktree_tasks: Vec<(WorktreeId, TaskContext, Vec<TaskTemplate>)> = {
let project = project.read(cx);
let task_store = project.task_store();
let Some(inventory) = task_store.read(cx).task_inventory().cloned() else {
return;
};
let git_store = project.git_store().read(cx);
let mut worktree_tasks = Vec::new();
for worktree in project.worktrees(cx) {
let worktree = worktree.read(cx);
let worktree_id = worktree.id();
let worktree_abs_path = worktree.abs_path();
let templates: Vec<TaskTemplate> = inventory
.read(cx)
.templates_with_hooks(&hooks, worktree_id)
.into_iter()
.map(|(_, template)| template)
.collect();
if templates.is_empty() {
continue;
}
let mut task_variables = TaskVariables::default();
task_variables.insert(
VariableName::WorktreeRoot,
worktree_abs_path.to_string_lossy().into_owned(),
);
if let Some(path) = git_store.original_repo_path_for_worktree(worktree_id, cx) {
task_variables.insert(
VariableName::MainGitWorktree,
path.to_string_lossy().into_owned(),
);
}
let task_context = TaskContext {
cwd: Some(worktree_abs_path.to_path_buf()),
task_variables,
project_env: Default::default(),
};
worktree_tasks.push((worktree_id, task_context, templates));
}
worktree_tasks
};
if worktree_tasks.is_empty() {
return;
}
let task = cx.spawn_in(window, async move |workspace, cx| {
let mut tasks = Vec::new();
for (worktree_id, task_context, templates) in worktree_tasks {
let id_base = format!("worktree_setup_{worktree_id}");
tasks.push(cx.spawn({
let workspace = workspace.clone();
async move |cx| {
for task_template in templates {
let Some(resolved) =
task_template.resolve_task(&id_base, &task_context)
else {
continue;
};
let status = workspace.update_in(cx, |workspace, window, cx| {
workspace.spawn_in_terminal(resolved.resolved, window, cx)
})?;
if let Some(result) = status.await {
match result {
Ok(exit_status) if !exit_status.success() => {
log::error!(
"Git worktree setup task failed with status: {:?}",
exit_status.code()
);
break;
}
Err(error) => {
log::error!("Git worktree setup task error: {error:#}");
break;
}
_ => {}
}
}
}
anyhow::Ok(())
}
}));
}
futures::future::join_all(tasks).await;
anyhow::Ok(())
});
task.detach_and_log_err(cx);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
TerminalProvider,
item::test::{TestItem, TestProjectItem},
register_serializable_item,
};
use gpui::{App, TestAppContext};
use parking_lot::Mutex;
use project::{FakeFs, Project, TaskSourceKind};
use serde_json::json;
use std::sync::Arc;
use task::TaskTemplate;
struct Fixture {
workspace: Entity<Workspace>,
item: Entity<TestItem>,
task: ResolvedTask,
dirty_before_spawn: Arc<Mutex<Option<bool>>>,
}
#[gpui::test]
async fn test_schedule_resolved_task_save_all(cx: &mut TestAppContext) {
let (fixture, cx) = create_fixture(cx, SaveStrategy::All).await;
fixture.workspace.update_in(cx, |workspace, window, cx| {
workspace.schedule_resolved_task(
TaskSourceKind::UserInput,
fixture.task,
false,
window,
cx,
);
});
cx.executor().run_until_parked();
assert_eq!(*fixture.dirty_before_spawn.lock(), Some(false));
assert!(cx.read(|cx| !fixture.item.read(cx).is_dirty));
}
#[gpui::test]
async fn test_schedule_resolved_task_save_current(cx: &mut TestAppContext) {
let (fixture, cx) = create_fixture(cx, SaveStrategy::Current).await;
// Add a second inactive dirty item
let inactive = add_test_item(&fixture.workspace, "file2.txt", false, cx);
fixture.workspace.update_in(cx, |workspace, window, cx| {
workspace.schedule_resolved_task(
TaskSourceKind::UserInput,
fixture.task,
false,
window,
cx,
);
});
cx.executor().run_until_parked();
// The active item (fixture.item) should be saved
assert_eq!(*fixture.dirty_before_spawn.lock(), Some(false));
assert!(cx.read(|cx| !fixture.item.read(cx).is_dirty));
// The inactive item should not be saved
assert!(cx.read(|cx| inactive.read(cx).is_dirty));
}
#[gpui::test]
async fn test_schedule_resolved_task_save_none(cx: &mut TestAppContext) {
let (fixture, cx) = create_fixture(cx, SaveStrategy::None).await;
fixture.workspace.update_in(cx, |workspace, window, cx| {
workspace.schedule_resolved_task(
TaskSourceKind::UserInput,
fixture.task,
false,
window,
cx,
);
});
cx.executor().run_until_parked();
assert_eq!(*fixture.dirty_before_spawn.lock(), Some(true));
assert!(cx.read(|cx| fixture.item.read(cx).is_dirty));
}
async fn create_fixture(
cx: &mut TestAppContext,
save_strategy: SaveStrategy,
) -> (Fixture, &mut gpui::VisualTestContext) {
cx.update(|cx| {
let settings_store = settings::SettingsStore::test(cx);
cx.set_global(settings_store);
theme_settings::init(theme::LoadThemes::JustBase, cx);
register_serializable_item::<TestItem>(cx);
});
let fs = FakeFs::new(cx.executor());
fs.insert_tree("/root", json!({ "file.txt": "dirty" }))
.await;
let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
let (workspace, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
// Add a dirty item to the workspace
let item = add_test_item(&workspace, "file.txt", true, cx);
let template = TaskTemplate {
label: "test".to_string(),
command: "echo".to_string(),
save: save_strategy,
..Default::default()
};
let task = template
.resolve_task("test", &task::TaskContext::default())
.unwrap();
let dirty_before_spawn: Arc<Mutex<Option<bool>>> = Arc::default();
let terminal_provider = Box::new(TestTerminalProvider {
item: item.clone(),
dirty_before_spawn: dirty_before_spawn.clone(),
});
workspace.update(cx, |workspace, _| {
workspace.terminal_provider = Some(terminal_provider);
});
let fixture = Fixture {
workspace,
item,
task,
dirty_before_spawn,
};
(fixture, cx)
}
fn add_test_item(
workspace: &Entity<Workspace>,
name: &str,
active: bool,
cx: &mut gpui::VisualTestContext,
) -> Entity<TestItem> {
let item = cx.new(|cx| {
TestItem::new(cx)
.with_dirty(true)
.with_project_items(&[TestProjectItem::new(1, name, cx)])
});
workspace.update_in(cx, |workspace, window, cx| {
let pane = workspace.active_pane().clone();
workspace.add_item(pane, Box::new(item.clone()), None, true, active, window, cx);
});
item
}
#[gpui::test]
async fn test_save_for_task_all(cx: &mut TestAppContext) {
let (fixture, cx) = create_fixture(cx, SaveStrategy::All).await;
let workspace = fixture.workspace.downgrade();
cx.run_until_parked();
assert!(cx.read(|cx| fixture.item.read(cx).is_dirty));
fixture.workspace.update_in(cx, |_workspace, window, cx| {
cx.spawn_in(window, {
let workspace = workspace.clone();
async move |_this, cx| {
Workspace::save_for_task(&workspace, SaveStrategy::All, cx).await;
}
})
.detach();
});
cx.run_until_parked();
assert!(cx.read(|cx| !fixture.item.read(cx).is_dirty));
}
#[gpui::test]
async fn test_save_for_task_none(cx: &mut TestAppContext) {
let (fixture, cx) = create_fixture(cx, SaveStrategy::None).await;
let workspace = fixture.workspace.downgrade();
cx.run_until_parked();
assert!(cx.read(|cx| fixture.item.read(cx).is_dirty));
fixture.workspace.update_in(cx, |_workspace, window, cx| {
cx.spawn_in(window, {
let workspace = workspace.clone();
async move |_this, cx| {
Workspace::save_for_task(&workspace, SaveStrategy::None, cx).await;
}
})
.detach();
});
cx.run_until_parked();
assert!(cx.read(|cx| fixture.item.read(cx).is_dirty));
}
#[gpui::test]
async fn test_save_for_task_current(cx: &mut TestAppContext) {
let (fixture, cx) = create_fixture(cx, SaveStrategy::Current).await;
let inactive = add_test_item(&fixture.workspace, "file2.txt", false, cx);
let workspace = fixture.workspace.downgrade();
cx.run_until_parked();
assert!(cx.read(|cx| fixture.item.read(cx).is_dirty));
assert!(cx.read(|cx| inactive.read(cx).is_dirty));
fixture.workspace.update_in(cx, |_workspace, window, cx| {
cx.spawn_in(window, {
let workspace = workspace.clone();
async move |_this, cx| {
Workspace::save_for_task(&workspace, SaveStrategy::Current, cx).await;
}
})
.detach();
});
cx.run_until_parked();
assert!(cx.read(|cx| !fixture.item.read(cx).is_dirty));
assert!(cx.read(|cx| inactive.read(cx).is_dirty));
}
struct TestTerminalProvider {
item: Entity<TestItem>,
dirty_before_spawn: Arc<Mutex<Option<bool>>>,
}
impl TerminalProvider for TestTerminalProvider {
fn spawn(
&self,
_task: task::SpawnInTerminal,
_window: &mut ui::Window,
cx: &mut App,
) -> Task<Option<Result<ExitStatus>>> {
*self.dirty_before_spawn.lock() = Some(cx.read_entity(&self.item, |e, _| e.is_dirty));
Task::ready(Some(Ok(ExitStatus::default())))
}
}
}

View File

@@ -0,0 +1,429 @@
#![allow(unused, dead_code)]
use gpui::{
AnyElement, App, Entity, EventEmitter, FocusHandle, Focusable, Hsla, Task, actions, hsla,
};
use strum::IntoEnumIterator;
use theme::all_theme_colors;
use ui::{
AudioStatus, Avatar, AvatarAudioStatusIndicator, AvatarAvailabilityIndicator, ButtonLike,
Checkbox, CollaboratorAvailability, DecoratedIcon, ElevationIndex, Facepile, IconDecoration,
Indicator, KeybindingHint, Switch, TintColor, Tooltip, prelude::*,
utils::calculate_contrast_ratio,
};
use crate::{Item, Workspace};
actions!(
dev,
[
/// Opens the theme preview window.
OpenThemePreview
]
);
pub fn init(cx: &mut App) {
cx.observe_new(|workspace: &mut Workspace, _, _| {
workspace.register_action(|workspace, _: &OpenThemePreview, window, cx| {
let theme_preview = cx.new(|cx| ThemePreview::new(window, cx));
workspace.add_item_to_active_pane(Box::new(theme_preview), None, true, window, cx)
});
})
.detach();
}
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, strum::EnumIter)]
enum ThemePreviewPage {
Overview,
Typography,
}
impl ThemePreviewPage {
pub fn name(&self) -> &'static str {
match self {
Self::Overview => "Overview",
Self::Typography => "Typography",
}
}
}
struct ThemePreview {
current_page: ThemePreviewPage,
focus_handle: FocusHandle,
}
impl ThemePreview {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
Self {
current_page: ThemePreviewPage::Overview,
focus_handle: cx.focus_handle(),
}
}
pub fn view(
&self,
page: ThemePreviewPage,
window: &mut Window,
cx: &mut Context<ThemePreview>,
) -> impl IntoElement {
match page {
ThemePreviewPage::Overview => self.render_overview_page(window, cx).into_any_element(),
ThemePreviewPage::Typography => {
self.render_typography_page(window, cx).into_any_element()
}
}
}
}
impl EventEmitter<()> for ThemePreview {}
impl Focusable for ThemePreview {
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
self.focus_handle.clone()
}
}
impl ThemePreview {}
impl Item for ThemePreview {
type Event = ();
fn to_item_events(_: &Self::Event, _: &mut dyn FnMut(crate::item::ItemEvent)) {}
fn tab_content_text(&self, _detail: usize, cx: &App) -> SharedString {
let name = cx.theme().name.clone();
format!("{} Preview", name).into()
}
fn telemetry_event_text(&self) -> Option<&'static str> {
None
}
fn can_split(&self) -> bool {
true
}
fn clone_on_split(
&self,
_workspace_id: Option<crate::WorkspaceId>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Task<Option<Entity<Self>>>
where
Self: Sized,
{
Task::ready(Some(cx.new(|cx| Self::new(window, cx))))
}
}
const AVATAR_URL: &str = "https://avatars.githubusercontent.com/u/1714999?v=4";
impl ThemePreview {
fn preview_bg(window: &mut Window, cx: &mut App) -> Hsla {
cx.theme().colors().editor_background
}
fn render_text(
&self,
layer: ElevationIndex,
window: &mut Window,
cx: &mut Context<Self>,
) -> impl IntoElement {
let bg = layer.bg(cx);
let label_with_contrast = |label: &str, fg: Hsla| {
let contrast = calculate_contrast_ratio(fg, bg);
format!("{} ({:.2})", label, contrast)
};
v_flex()
.gap_1()
.child(Headline::new("Text").size(HeadlineSize::Small).color(Color::Muted))
.child(
h_flex()
.items_start()
.gap_4()
.child(
v_flex()
.gap_1()
.child(Headline::new("Headline Sizes").size(HeadlineSize::Small).color(Color::Muted))
.child(Headline::new("XLarge Headline").size(HeadlineSize::XLarge))
.child(Headline::new("Large Headline").size(HeadlineSize::Large))
.child(Headline::new("Medium Headline").size(HeadlineSize::Medium))
.child(Headline::new("Small Headline").size(HeadlineSize::Small))
.child(Headline::new("XSmall Headline").size(HeadlineSize::XSmall)),
)
.child(
v_flex()
.gap_1()
.child(Headline::new("Text Colors").size(HeadlineSize::Small).color(Color::Muted))
.child(
Label::new(label_with_contrast(
"Default Text",
Color::Default.color(cx),
))
.color(Color::Default),
)
.child(
Label::new(label_with_contrast(
"Accent Text",
Color::Accent.color(cx),
))
.color(Color::Accent),
)
.child(
Label::new(label_with_contrast(
"Conflict Text",
Color::Conflict.color(cx),
))
.color(Color::Conflict),
)
.child(
Label::new(label_with_contrast(
"Created Text",
Color::Created.color(cx),
))
.color(Color::Created),
)
.child(
Label::new(label_with_contrast(
"Deleted Text",
Color::Deleted.color(cx),
))
.color(Color::Deleted),
)
.child(
Label::new(label_with_contrast(
"Disabled Text",
Color::Disabled.color(cx),
))
.color(Color::Disabled),
)
.child(
Label::new(label_with_contrast(
"Error Text",
Color::Error.color(cx),
))
.color(Color::Error),
)
.child(
Label::new(label_with_contrast(
"Hidden Text",
Color::Hidden.color(cx),
))
.color(Color::Hidden),
)
.child(
Label::new(label_with_contrast(
"Hint Text",
Color::Hint.color(cx),
))
.color(Color::Hint),
)
.child(
Label::new(label_with_contrast(
"Ignored Text",
Color::Ignored.color(cx),
))
.color(Color::Ignored),
)
.child(
Label::new(label_with_contrast(
"Info Text",
Color::Info.color(cx),
))
.color(Color::Info),
)
.child(
Label::new(label_with_contrast(
"Modified Text",
Color::Modified.color(cx),
))
.color(Color::Modified),
)
.child(
Label::new(label_with_contrast(
"Muted Text",
Color::Muted.color(cx),
))
.color(Color::Muted),
)
.child(
Label::new(label_with_contrast(
"Placeholder Text",
Color::Placeholder.color(cx),
))
.color(Color::Placeholder),
)
.child(
Label::new(label_with_contrast(
"Selected Text",
Color::Selected.color(cx),
))
.color(Color::Selected),
)
.child(
Label::new(label_with_contrast(
"Success Text",
Color::Success.color(cx),
))
.color(Color::Success),
)
.child(
Label::new(label_with_contrast(
"Warning Text",
Color::Warning.color(cx),
))
.color(Color::Warning),
)
)
.child(
v_flex()
.gap_1()
.child(Headline::new("Wrapping Text").size(HeadlineSize::Small).color(Color::Muted))
.child(
div().max_w(px(200.)).child(
"This is a longer piece of text that should wrap to multiple lines. It demonstrates how text behaves when it exceeds the width of its container."
))
)
)
}
fn render_colors(
&self,
layer: ElevationIndex,
window: &mut Window,
cx: &mut Context<Self>,
) -> impl IntoElement {
let bg = layer.bg(cx);
let all_colors = all_theme_colors(cx);
v_flex()
.gap_1()
.child(
Headline::new("Colors")
.size(HeadlineSize::Small)
.color(Color::Muted),
)
.child(
h_flex()
.flex_wrap()
.gap_1()
.children(all_colors.into_iter().map(|(color, name)| {
let id = ElementId::Name(format!("{:?}-preview", color).into());
div().size_8().flex_none().child(
ButtonLike::new(id)
.child(
div()
.size_8()
.bg(color)
.border_1()
.border_color(cx.theme().colors().border)
.overflow_hidden(),
)
.size(ButtonSize::None)
.style(ButtonStyle::Transparent)
.tooltip(move |window, cx| {
let name = name.clone();
Tooltip::with_meta(name, None, format!("{:?}", color), cx)
}),
)
})),
)
}
fn render_theme_layer(
&self,
layer: ElevationIndex,
window: &mut Window,
cx: &mut Context<Self>,
) -> impl IntoElement {
v_flex()
.p_4()
.bg(layer.bg(cx))
.text_color(cx.theme().colors().text)
.gap_2()
.child(Headline::new(layer.clone().to_string()).size(HeadlineSize::Medium))
.child(self.render_text(layer, window, cx))
.child(self.render_colors(layer, window, cx))
}
fn render_overview_page(
&self,
window: &mut Window,
cx: &mut Context<Self>,
) -> impl IntoElement {
v_flex()
.id("theme-preview-overview")
.overflow_scroll()
.size_full()
.child(
v_flex()
.child(Headline::new("Theme Preview").size(HeadlineSize::Large))
.child(div().w_full().text_color(cx.theme().colors().text_muted).child("This view lets you preview a range of UI elements across a theme. Use it for testing out changes to the theme."))
)
.child(self.render_theme_layer(ElevationIndex::Background, window, cx))
.child(self.render_theme_layer(ElevationIndex::Surface, window, cx))
.child(self.render_theme_layer(ElevationIndex::EditorSurface, window, cx))
.child(self.render_theme_layer(ElevationIndex::ElevatedSurface, window, cx))
}
fn render_typography_page(
&self,
window: &mut Window,
cx: &mut Context<Self>,
) -> impl IntoElement {
v_flex()
.id("theme-preview-typography")
.overflow_scroll()
.size_full()
.child(v_flex()
.gap_4()
.child(Headline::new("Headline 1").size(HeadlineSize::XLarge))
.child(Label::new("Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua."))
.child(Headline::new("Headline 2").size(HeadlineSize::Large))
.child(Label::new("Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat."))
.child(Headline::new("Headline 3").size(HeadlineSize::Medium))
.child(Label::new("Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur."))
.child(Headline::new("Headline 4").size(HeadlineSize::Small))
.child(Label::new("Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."))
.child(Headline::new("Headline 5").size(HeadlineSize::XSmall))
.child(Label::new("Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat."))
.child(Headline::new("Body Text").size(HeadlineSize::Small))
.child(Label::new("Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."))
)
}
fn render_page_nav(&self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
h_flex()
.id("theme-preview-nav")
.items_center()
.gap_4()
.py_2()
.bg(Self::preview_bg(window, cx))
.children(ThemePreviewPage::iter().map(|p| {
Button::new(ElementId::Name(p.name().into()), p.name())
.on_click(cx.listener(move |this, _, window, cx| {
this.current_page = p;
cx.notify();
}))
.toggle_state(p == self.current_page)
.selected_style(ButtonStyle::Tinted(TintColor::Accent))
}))
}
}
impl Render for ThemePreview {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl ui::IntoElement {
v_flex()
.id("theme-preview")
.key_context("ThemePreview")
.items_start()
.overflow_hidden()
.size_full()
.max_h_full()
.track_focus(&self.focus_handle)
.px_2()
.bg(Self::preview_bg(window, cx))
.child(self.render_page_nav(window, cx))
.child(self.view(self.current_page, window, cx))
}
}

View File

@@ -0,0 +1,268 @@
use std::{
rc::Rc,
time::{Duration, Instant},
};
use gpui::{
AnyView, DismissEvent, Entity, EntityId, FocusHandle, ManagedView, MouseButton, Subscription,
Task,
};
use ui::{animation::DefaultAnimations, prelude::*};
use zed_actions::toast;
use crate::Workspace;
const DEFAULT_TOAST_DURATION: Duration = Duration::from_secs(10);
const MINIMUM_RESUME_DURATION: Duration = Duration::from_millis(800);
pub fn init(cx: &mut App) {
cx.observe_new(|workspace: &mut Workspace, _window, _cx| {
workspace.register_action(|_workspace, _: &toast::RunAction, window, cx| {
let workspace = cx.entity();
let window = window.window_handle();
cx.defer(move |cx| {
let action = workspace
.read(cx)
.toast_layer
.read(cx)
.active_toast
.as_ref()
.and_then(|active_toast| active_toast.action.clone());
if let Some(on_click) = action.and_then(|action| action.on_click) {
window
.update(cx, |_, window, cx| {
on_click(window, cx);
})
.ok();
}
});
});
})
.detach();
}
pub trait ToastView: ManagedView {
fn action(&self) -> Option<ToastAction>;
fn auto_dismiss(&self) -> bool {
true
}
}
#[derive(Clone)]
pub struct ToastAction {
pub id: ElementId,
pub label: SharedString,
pub on_click: Option<Rc<dyn Fn(&mut Window, &mut App) + 'static>>,
}
impl ToastAction {
pub fn new(
label: SharedString,
on_click: Option<Rc<dyn Fn(&mut Window, &mut App) + 'static>>,
) -> Self {
let id = ElementId::Name(label.clone());
Self {
id,
label,
on_click,
}
}
}
trait ToastViewHandle {
fn view(&self) -> AnyView;
}
impl<V: ToastView> ToastViewHandle for Entity<V> {
fn view(&self) -> AnyView {
self.clone().into()
}
}
pub struct ActiveToast {
id: EntityId,
toast: Box<dyn ToastViewHandle>,
action: Option<ToastAction>,
_subscriptions: [Subscription; 1],
focus_handle: FocusHandle,
}
struct DismissTimer {
instant_started: Instant,
_task: Task<()>,
}
pub struct ToastLayer {
active_toast: Option<ActiveToast>,
duration_remaining: Option<Duration>,
dismiss_timer: Option<DismissTimer>,
}
impl Default for ToastLayer {
fn default() -> Self {
Self::new()
}
}
impl ToastLayer {
pub fn new() -> Self {
Self {
active_toast: None,
duration_remaining: None,
dismiss_timer: None,
}
}
pub fn toggle_toast<V>(&mut self, cx: &mut Context<Self>, new_toast: Entity<V>)
where
V: ToastView,
{
if let Some(active_toast) = &self.active_toast {
let show_new = active_toast.id != new_toast.entity_id();
self.hide_toast(cx);
if !show_new {
return;
}
}
self.show_toast(new_toast, cx);
}
pub fn show_toast<V>(&mut self, new_toast: Entity<V>, cx: &mut Context<Self>)
where
V: ToastView,
{
let action = new_toast.read(cx).action();
let auto_dismiss = new_toast.read(cx).auto_dismiss();
let focus_handle = cx.focus_handle();
self.active_toast = Some(ActiveToast {
_subscriptions: [cx.subscribe(&new_toast, |this, _, _: &DismissEvent, cx| {
this.hide_toast(cx);
})],
id: new_toast.entity_id(),
toast: Box::new(new_toast),
action,
focus_handle,
});
if auto_dismiss {
self.start_dismiss_timer(DEFAULT_TOAST_DURATION, cx);
}
cx.notify();
}
pub fn hide_toast(&mut self, cx: &mut Context<Self>) {
self.active_toast.take();
cx.notify();
}
pub fn active_toast<V>(&self) -> Option<Entity<V>>
where
V: 'static,
{
let active_toast = self.active_toast.as_ref()?;
active_toast.toast.view().downcast::<V>().ok()
}
pub fn has_active_toast(&self) -> bool {
self.active_toast.is_some()
}
fn pause_dismiss_timer(&mut self) {
let Some(dismiss_timer) = self.dismiss_timer.take() else {
return;
};
let Some(duration_remaining) = self.duration_remaining.as_mut() else {
return;
};
*duration_remaining =
duration_remaining.saturating_sub(dismiss_timer.instant_started.elapsed());
if *duration_remaining < MINIMUM_RESUME_DURATION {
*duration_remaining = MINIMUM_RESUME_DURATION;
}
}
/// Starts a timer to automatically dismiss the toast after the specified duration
pub fn start_dismiss_timer(&mut self, duration: Duration, cx: &mut Context<Self>) {
self.clear_dismiss_timer(cx);
let instant_started = std::time::Instant::now();
let task = cx.spawn(async move |this, cx| {
cx.background_executor().timer(duration).await;
if let Some(this) = this.upgrade() {
this.update(cx, |this, cx| this.hide_toast(cx));
}
});
self.duration_remaining = Some(duration);
self.dismiss_timer = Some(DismissTimer {
instant_started,
_task: task,
});
cx.notify();
}
/// Restarts the dismiss timer with a new duration
pub fn restart_dismiss_timer(&mut self, cx: &mut Context<Self>) {
let Some(duration) = self.duration_remaining else {
return;
};
self.start_dismiss_timer(duration, cx);
cx.notify();
}
/// Clears the dismiss timer if one exists
pub fn clear_dismiss_timer(&mut self, cx: &mut Context<Self>) {
self.dismiss_timer.take();
cx.notify();
}
}
impl Render for ToastLayer {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let Some(active_toast) = &self.active_toast else {
return div();
};
div().absolute().size_full().bottom_0().left_0().child(
v_flex()
.id(("toast-layer-container", active_toast.id))
.absolute()
.w_full()
.bottom(px(0.))
.flex()
.flex_col()
.items_center()
.track_focus(&active_toast.focus_handle)
.child(
h_flex()
.id("active-toast-container")
.occlude()
.on_hover(cx.listener(|this, hover_start, _window, cx| {
if *hover_start {
this.pause_dismiss_timer();
} else {
this.restart_dismiss_timer(cx);
}
cx.stop_propagation();
}))
.on_click(|_, _, cx| {
cx.stop_propagation();
})
.on_mouse_down(
MouseButton::Middle,
cx.listener(|this, _, _, cx| {
this.hide_toast(cx);
}),
)
.child(active_toast.toast.view()),
)
.animate_in(AnimationDirection::FromBottom, true),
)
}
}

View File

@@ -0,0 +1,287 @@
use crate::ItemHandle;
use gpui::{
AnyView, App, Context, Div, Entity, EntityId, EventEmitter, Global, KeyContext,
ParentElement as _, Render, Styled, Window,
};
use language::LanguageRegistry;
use std::sync::Arc;
use ui::prelude::*;
use ui::{h_flex, v_flex};
pub struct PaneSearchBarCallbacks {
pub setup_search_bar:
fn(Option<Arc<LanguageRegistry>>, &Entity<Toolbar>, &mut Window, &mut App),
pub wrap_div_with_search_actions: fn(Div, Entity<crate::Pane>) -> Div,
}
impl Global for PaneSearchBarCallbacks {}
#[derive(Copy, Clone, Debug, PartialEq)]
pub enum ToolbarItemEvent {
ChangeLocation(ToolbarItemLocation),
}
pub trait ToolbarItemView: Render + EventEmitter<ToolbarItemEvent> {
fn set_active_pane_item(
&mut self,
active_pane_item: Option<&dyn crate::ItemHandle>,
window: &mut Window,
cx: &mut Context<Self>,
) -> ToolbarItemLocation;
fn pane_focus_update(
&mut self,
_pane_focused: bool,
_window: &mut Window,
_cx: &mut Context<Self>,
) {
}
fn contribute_context(&self, _context: &mut KeyContext, _cx: &App) {}
}
trait ToolbarItemViewHandle: Send {
fn id(&self) -> EntityId;
fn to_any(&self) -> AnyView;
fn set_active_pane_item(
&self,
active_pane_item: Option<&dyn ItemHandle>,
window: &mut Window,
cx: &mut App,
) -> ToolbarItemLocation;
fn focus_changed(&mut self, pane_focused: bool, window: &mut Window, cx: &mut App);
fn contribute_context(&self, context: &mut KeyContext, cx: &App);
}
#[derive(Copy, Clone, Debug, PartialEq)]
pub enum ToolbarItemLocation {
Hidden,
PrimaryLeft,
PrimaryRight,
Secondary,
}
pub struct Toolbar {
active_item: Option<Box<dyn ItemHandle>>,
hidden: bool,
can_navigate: bool,
items: Vec<(Box<dyn ToolbarItemViewHandle>, ToolbarItemLocation)>,
}
impl Toolbar {
fn has_any_visible_items(&self) -> bool {
self.items
.iter()
.any(|(_item, location)| *location != ToolbarItemLocation::Hidden)
}
fn left_items(&self) -> impl Iterator<Item = &dyn ToolbarItemViewHandle> {
self.items.iter().filter_map(|(item, location)| {
if *location == ToolbarItemLocation::PrimaryLeft {
Some(item.as_ref())
} else {
None
}
})
}
fn right_items(&self) -> impl Iterator<Item = &dyn ToolbarItemViewHandle> {
self.items.iter().filter_map(|(item, location)| {
if *location == ToolbarItemLocation::PrimaryRight {
Some(item.as_ref())
} else {
None
}
})
}
fn secondary_items(&self) -> impl Iterator<Item = &dyn ToolbarItemViewHandle> {
self.items.iter().rev().filter_map(|(item, location)| {
if *location == ToolbarItemLocation::Secondary {
Some(item.as_ref())
} else {
None
}
})
}
}
impl Render for Toolbar {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
if !self.has_any_visible_items() {
return div();
}
let secondary_items = self.secondary_items().map(|item| item.to_any());
let has_left_items = self.left_items().count() > 0;
let has_right_items = self.right_items().count() > 0;
v_flex()
.group("toolbar")
.relative()
.py(DynamicSpacing::Base06.rems(cx))
.px(DynamicSpacing::Base08.rems(cx))
.when(has_left_items || has_right_items, |this| {
this.gap(DynamicSpacing::Base06.rems(cx))
})
.border_b_1()
.border_color(cx.theme().colors().border_variant)
.bg(cx.theme().colors().toolbar_background)
.when(has_left_items || has_right_items, |this| {
this.child(
h_flex()
.items_start()
.justify_between()
.gap(DynamicSpacing::Base08.rems(cx))
.when(has_left_items, |this| {
this.child(
h_flex()
.min_h_8()
.flex_auto()
.justify_start()
.overflow_x_hidden()
.children(self.left_items().map(|item| item.to_any())),
)
})
.when(has_right_items, |this| {
this.child(
h_flex()
.h_8()
.flex_row_reverse()
.when(has_left_items, |this| this.flex_none())
.justify_end()
.children(self.right_items().map(|item| item.to_any())),
)
}),
)
})
.children(secondary_items)
}
}
impl Default for Toolbar {
fn default() -> Self {
Self::new()
}
}
impl Toolbar {
pub fn new() -> Self {
Self {
active_item: None,
items: Default::default(),
hidden: false,
can_navigate: true,
}
}
pub fn set_can_navigate(&mut self, can_navigate: bool, cx: &mut Context<Self>) {
self.can_navigate = can_navigate;
cx.notify();
}
pub fn add_item<T>(&mut self, item: Entity<T>, window: &mut Window, cx: &mut Context<Self>)
where
T: 'static + ToolbarItemView,
{
let location = item.set_active_pane_item(self.active_item.as_deref(), window, cx);
cx.subscribe(&item, |this, item, event, cx| {
if let Some((_, current_location)) = this
.items
.iter_mut()
.find(|(i, _)| i.id() == item.entity_id())
{
match event {
ToolbarItemEvent::ChangeLocation(new_location) => {
if new_location != current_location {
*current_location = *new_location;
cx.notify();
}
}
}
}
})
.detach();
self.items.push((Box::new(item), location));
cx.notify();
}
pub fn set_active_item(
&mut self,
item: Option<&dyn ItemHandle>,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.active_item = item.map(|item| item.boxed_clone());
self.hidden = self
.active_item
.as_ref()
.map(|item| !item.show_toolbar(cx))
.unwrap_or(false);
for (toolbar_item, current_location) in self.items.iter_mut() {
let new_location = toolbar_item.set_active_pane_item(item, window, cx);
if new_location != *current_location {
*current_location = new_location;
cx.notify();
}
}
}
pub fn focus_changed(&mut self, focused: bool, window: &mut Window, cx: &mut Context<Self>) {
for (toolbar_item, _) in self.items.iter_mut() {
toolbar_item.focus_changed(focused, window, cx);
}
}
pub fn item_of_type<T: ToolbarItemView>(&self) -> Option<Entity<T>> {
self.items
.iter()
.find_map(|(item, _)| item.to_any().downcast().ok())
}
pub fn hidden(&self) -> bool {
self.hidden
}
pub fn contribute_context(&self, context: &mut KeyContext, cx: &App) {
for (item, location) in &self.items {
if *location != ToolbarItemLocation::Hidden {
item.contribute_context(context, cx);
}
}
}
}
impl<T: ToolbarItemView> ToolbarItemViewHandle for Entity<T> {
fn id(&self) -> EntityId {
self.entity_id()
}
fn to_any(&self) -> AnyView {
self.clone().into()
}
fn set_active_pane_item(
&self,
active_pane_item: Option<&dyn ItemHandle>,
window: &mut Window,
cx: &mut App,
) -> ToolbarItemLocation {
self.update(cx, |this, cx| {
this.set_active_pane_item(active_pane_item, window, cx)
})
}
fn focus_changed(&mut self, pane_focused: bool, window: &mut Window, cx: &mut App) {
self.update(cx, |this, cx| {
this.pane_focus_update(pane_focused, window, cx);
cx.notify();
});
}
fn contribute_context(&self, context: &mut KeyContext, cx: &App) {
self.read(cx).contribute_context(context, cx)
}
}

View File

@@ -0,0 +1,695 @@
use crate::{
NewFile, Open, OpenMode, PathList, RecentWorkspace, SerializedWorkspaceLocation,
ToggleWorkspaceSidebar, Workspace,
item::{Item, ItemEvent},
persistence::WorkspaceDb,
};
use agent_settings::AgentSettings;
use git::Clone as GitClone;
use gpui::{
Action, App, Context, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement,
ParentElement, Render, Styled, Task, TaskExt, Window, actions,
};
use gpui::{WeakEntity, linear_color_stop, linear_gradient};
use menu::{SelectNext, SelectPrevious};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use settings::Settings;
use ui::{ButtonLike, Divider, DividerColor, KeyBinding, Vector, VectorName, prelude::*};
use util::ResultExt;
use zed_actions::{
Extensions, OpenKeymap, OpenOnboarding, OpenSettings, assistant::ToggleFocus, command_palette,
};
#[derive(PartialEq, Clone, Debug, Deserialize, Serialize, JsonSchema, Action)]
#[action(namespace = welcome)]
#[serde(transparent)]
pub struct OpenRecentProject {
pub index: usize,
}
actions!(
zed,
[
/// Show the Zed welcome screen
ShowWelcome
]
);
#[derive(IntoElement)]
struct SectionHeader {
title: SharedString,
}
impl SectionHeader {
fn new(title: impl Into<SharedString>) -> Self {
Self {
title: title.into(),
}
}
}
impl RenderOnce for SectionHeader {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
h_flex()
.px_1()
.mb_2()
.gap_2()
.child(
Label::new(self.title.to_ascii_uppercase())
.buffer_font(cx)
.color(Color::Muted)
.size(LabelSize::XSmall),
)
.child(Divider::horizontal().color(DividerColor::BorderVariant))
}
}
#[derive(IntoElement)]
struct SectionButton {
label: SharedString,
icon: IconName,
action: Box<dyn Action>,
tab_index: usize,
focus_handle: FocusHandle,
}
impl SectionButton {
fn new(
label: impl Into<SharedString>,
icon: IconName,
action: &dyn Action,
tab_index: usize,
focus_handle: FocusHandle,
) -> Self {
Self {
label: label.into(),
icon,
action: action.boxed_clone(),
tab_index,
focus_handle,
}
}
}
impl RenderOnce for SectionButton {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let id = format!("onb-button-{}-{}", self.label, self.tab_index);
let action_ref: &dyn Action = &*self.action;
ButtonLike::new(id)
.tab_index(self.tab_index as isize)
.full_width()
.size(ButtonSize::Medium)
.child(
h_flex()
.w_full()
.justify_between()
.child(
h_flex()
.gap_2()
.child(
Icon::new(self.icon)
.color(Color::Muted)
.size(IconSize::Small),
)
.child(Label::new(self.label)),
)
.child(
KeyBinding::for_action_in(action_ref, &self.focus_handle, cx)
.size(rems_from_px(12.)),
),
)
.on_click(move |_, window, cx| {
self.focus_handle.dispatch_action(&*self.action, window, cx)
})
}
}
enum SectionVisibility {
Always,
}
impl SectionVisibility {
fn is_visible(&self) -> bool {
match self {
SectionVisibility::Always => true,
}
}
}
struct SectionEntry {
icon: IconName,
title: &'static str,
action: &'static dyn Action,
visibility_guard: SectionVisibility,
}
impl SectionEntry {
fn render(&self, button_index: usize, focus: &FocusHandle) -> Option<impl IntoElement> {
self.visibility_guard.is_visible().then(|| {
SectionButton::new(
self.title,
self.icon,
self.action,
button_index,
focus.clone(),
)
})
}
}
const CONTENT: (Section<4>, Section<3>) = (
Section {
title: "Get Started",
entries: [
SectionEntry {
icon: IconName::Plus,
title: "New File",
action: &NewFile,
visibility_guard: SectionVisibility::Always,
},
SectionEntry {
icon: IconName::FolderOpen,
title: "Open Project",
action: &Open::DEFAULT,
visibility_guard: SectionVisibility::Always,
},
SectionEntry {
icon: IconName::CloudDownload,
title: "Clone Repository",
action: &GitClone,
visibility_guard: SectionVisibility::Always,
},
SectionEntry {
icon: IconName::ListCollapse,
title: "Open Command Palette",
action: &command_palette::Toggle,
visibility_guard: SectionVisibility::Always,
},
],
},
Section {
title: "Configure",
entries: [
SectionEntry {
icon: IconName::Settings,
title: "Open Settings",
action: &OpenSettings,
visibility_guard: SectionVisibility::Always,
},
SectionEntry {
icon: IconName::Keyboard,
title: "Customize Keymaps",
action: &OpenKeymap,
visibility_guard: SectionVisibility::Always,
},
SectionEntry {
icon: IconName::Blocks,
title: "Explore Extensions",
action: &Extensions {
category_filter: None,
id: None,
},
visibility_guard: SectionVisibility::Always,
},
],
},
);
struct Section<const COLS: usize> {
title: &'static str,
entries: [SectionEntry; COLS],
}
impl<const COLS: usize> Section<COLS> {
fn render(self, index_offset: usize, focus: &FocusHandle) -> impl IntoElement {
v_flex()
.min_w_full()
.child(SectionHeader::new(self.title))
.children(
self.entries
.iter()
.enumerate()
.filter_map(|(index, entry)| entry.render(index_offset + index, focus)),
)
}
}
pub struct WelcomePage {
workspace: WeakEntity<Workspace>,
focus_handle: FocusHandle,
fallback_to_recent_projects: bool,
recent_workspaces: Option<Vec<RecentWorkspace>>,
}
impl WelcomePage {
pub fn new(
workspace: WeakEntity<Workspace>,
fallback_to_recent_projects: bool,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let focus_handle = cx.focus_handle();
cx.on_focus(&focus_handle, window, |_, _, cx| cx.notify())
.detach();
if fallback_to_recent_projects {
let fs = workspace
.upgrade()
.map(|ws| ws.read(cx).app_state().fs.clone());
let db = WorkspaceDb::global(cx);
cx.spawn_in(window, async move |this: WeakEntity<Self>, cx| {
let Some(fs) = fs else { return };
let workspaces = db
.recent_project_workspaces(fs.as_ref())
.await
.log_err()
.unwrap_or_default();
this.update(cx, |this, cx| {
this.recent_workspaces = Some(workspaces);
cx.notify();
})
.ok();
})
.detach();
}
WelcomePage {
workspace,
focus_handle,
fallback_to_recent_projects,
recent_workspaces: None,
}
}
fn select_next(&mut self, _: &SelectNext, window: &mut Window, cx: &mut Context<Self>) {
window.focus_next(cx);
cx.notify();
}
fn select_previous(&mut self, _: &SelectPrevious, window: &mut Window, cx: &mut Context<Self>) {
window.focus_prev(cx);
cx.notify();
}
fn open_recent_project(
&mut self,
action: &OpenRecentProject,
window: &mut Window,
cx: &mut Context<Self>,
) {
if let Some(recent_workspaces) = &self.recent_workspaces {
if let Some(workspace) = recent_workspaces.get(action.index) {
let is_local = matches!(workspace.location, SerializedWorkspaceLocation::Local);
if is_local {
let paths = workspace.paths.paths().to_vec();
self.workspace
.update(cx, |workspace, cx| {
workspace
.open_workspace_for_paths(OpenMode::Activate, paths, window, cx)
.detach_and_log_err(cx);
})
.log_err();
} else {
use zed_actions::OpenRecent;
window.dispatch_action(OpenRecent::default().boxed_clone(), cx);
}
}
}
}
fn render_agent_card(&self, tab_index: usize, cx: &mut Context<Self>) -> impl IntoElement {
let focus = self.focus_handle.clone();
let color = cx.theme().colors();
let description = "Run multiple threads at once, mix and match any ACP-compatible agent, and keep work conflict-free with worktrees.";
v_flex()
.w_full()
.p_2()
.rounded_md()
.border_1()
.border_color(color.border_variant)
.bg(linear_gradient(
360.,
linear_color_stop(color.panel_background, 1.0),
linear_color_stop(color.editor_background, 0.45),
))
.child(
h_flex()
.gap_1p5()
.child(
Icon::new(IconName::ZedAssistant)
.color(Color::Muted)
.size(IconSize::Small),
)
.child(Label::new("Collaborate with Agents")),
)
.child(
Label::new(description)
.size(LabelSize::Small)
.color(Color::Muted)
.mb_2(),
)
.child(
Button::new("open-agent", "Open Agent Panel")
.full_width()
.tab_index(tab_index as isize)
.style(ButtonStyle::Outlined)
.key_binding(
KeyBinding::for_action_in(&ToggleFocus, &self.focus_handle, cx)
.size(rems_from_px(12.)),
)
.on_click(move |_, window, cx| {
focus.dispatch_action(&ToggleWorkspaceSidebar, window, cx);
focus.dispatch_action(&ToggleFocus, window, cx);
}),
)
}
fn render_recent_project_section(
&self,
recent_projects: Vec<impl IntoElement>,
) -> impl IntoElement {
v_flex()
.w_full()
.child(SectionHeader::new("Recent Projects"))
.children(recent_projects)
}
fn render_recent_project(
&self,
project_index: usize,
tab_index: usize,
location: &SerializedWorkspaceLocation,
paths: &PathList,
) -> impl IntoElement {
let name = project_name(paths);
let (icon, title) = match location {
SerializedWorkspaceLocation::Local => (IconName::Folder, name),
SerializedWorkspaceLocation::Remote(_) => (IconName::Server, name),
};
SectionButton::new(
title,
icon,
&OpenRecentProject {
index: project_index,
},
tab_index,
self.focus_handle.clone(),
)
}
}
impl Render for WelcomePage {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let (first_section, second_section) = CONTENT;
let first_section_entries = first_section.entries.len();
let mut next_tab_index = first_section_entries + second_section.entries.len();
let ai_enabled = AgentSettings::get_global(cx).enabled(cx);
let recent_projects = self
.recent_workspaces
.as_ref()
.into_iter()
.flatten()
.take(5)
.enumerate()
.map(|(index, workspace)| {
self.render_recent_project(
index,
first_section_entries + index,
&workspace.location,
&workspace.identity_paths,
)
})
.collect::<Vec<_>>();
let showing_recent_projects =
self.fallback_to_recent_projects && !recent_projects.is_empty();
let second_section = if showing_recent_projects {
self.render_recent_project_section(recent_projects)
.into_any_element()
} else {
second_section
.render(first_section_entries, &self.focus_handle)
.into_any_element()
};
let welcome_label = if self.fallback_to_recent_projects {
"Welcome back to Zed"
} else {
"Welcome to Zed"
};
h_flex()
.key_context("Welcome")
.track_focus(&self.focus_handle(cx))
.on_action(cx.listener(Self::select_previous))
.on_action(cx.listener(Self::select_next))
.on_action(cx.listener(Self::open_recent_project))
.size_full()
.bg(cx.theme().colors().editor_background)
.justify_center()
.child(
v_flex()
.id("welcome-content")
.p_8()
.max_w_128()
.size_full()
.gap_6()
.justify_center()
.overflow_y_scroll()
.child(
h_flex()
.w_full()
.justify_center()
.mb_4()
.gap_4()
.child(Vector::square(VectorName::ZedLogo, rems_from_px(45.)))
.child(
v_flex().child(Headline::new(welcome_label)).child(
Label::new("The editor for what's next")
.size(LabelSize::Small)
.color(Color::Muted)
.italic(),
),
),
)
.child(first_section.render(Default::default(), &self.focus_handle))
.child(second_section)
.when(ai_enabled && !showing_recent_projects, |this| {
let agent_tab_index = next_tab_index;
next_tab_index += 1;
this.child(self.render_agent_card(agent_tab_index, cx))
})
.when(!self.fallback_to_recent_projects, |this| {
this.child(
v_flex().gap_4().child(Divider::horizontal()).child(
Button::new("welcome-exit", "Return to Onboarding")
.tab_index(next_tab_index as isize)
.full_width()
.label_size(LabelSize::XSmall)
.on_click(|_, window, cx| {
window.dispatch_action(OpenOnboarding.boxed_clone(), cx);
}),
),
)
}),
)
}
}
impl EventEmitter<ItemEvent> for WelcomePage {}
impl Focusable for WelcomePage {
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
self.focus_handle.clone()
}
}
impl Item for WelcomePage {
type Event = ItemEvent;
fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
"Welcome".into()
}
fn telemetry_event_text(&self) -> Option<&'static str> {
Some("New Welcome Page Opened")
}
fn show_toolbar(&self) -> bool {
false
}
fn to_item_events(event: &Self::Event, f: &mut dyn FnMut(crate::item::ItemEvent)) {
f(*event)
}
}
impl crate::SerializableItem for WelcomePage {
fn serialized_item_kind() -> &'static str {
"WelcomePage"
}
fn cleanup(
workspace_id: crate::WorkspaceId,
alive_items: Vec<crate::ItemId>,
_window: &mut Window,
cx: &mut App,
) -> Task<gpui::Result<()>> {
crate::delete_unloaded_items(
alive_items,
workspace_id,
"welcome_pages",
&persistence::WelcomePagesDb::global(cx),
cx,
)
}
fn deserialize(
_project: Entity<project::Project>,
workspace: gpui::WeakEntity<Workspace>,
workspace_id: crate::WorkspaceId,
item_id: crate::ItemId,
window: &mut Window,
cx: &mut App,
) -> Task<gpui::Result<Entity<Self>>> {
if persistence::WelcomePagesDb::global(cx)
.get_welcome_page(item_id, workspace_id)
.ok()
.is_some_and(|is_open| is_open)
{
Task::ready(Ok(
cx.new(|cx| WelcomePage::new(workspace, false, window, cx))
))
} else {
Task::ready(Err(anyhow::anyhow!("No welcome page to deserialize")))
}
}
fn serialize(
&mut self,
workspace: &mut Workspace,
item_id: crate::ItemId,
_closing: bool,
_window: &mut Window,
cx: &mut Context<Self>,
) -> Option<Task<gpui::Result<()>>> {
let workspace_id = workspace.database_id()?;
let db = persistence::WelcomePagesDb::global(cx);
Some(cx.background_spawn(
async move { db.save_welcome_page(item_id, workspace_id, true).await },
))
}
fn should_serialize(&self, event: &Self::Event) -> bool {
event == &ItemEvent::UpdateTab
}
}
mod persistence {
use crate::WorkspaceDb;
use db::{
query,
sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection},
sqlez_macros::sql,
};
pub struct WelcomePagesDb(ThreadSafeConnection);
impl Domain for WelcomePagesDb {
const NAME: &str = stringify!(WelcomePagesDb);
const MIGRATIONS: &[&str] = (&[sql!(
CREATE TABLE welcome_pages (
workspace_id INTEGER,
item_id INTEGER UNIQUE,
is_open INTEGER DEFAULT FALSE,
PRIMARY KEY(workspace_id, item_id),
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
ON DELETE CASCADE
) STRICT;
)]);
}
db::static_connection!(WelcomePagesDb, [WorkspaceDb]);
impl WelcomePagesDb {
query! {
pub async fn save_welcome_page(
item_id: crate::ItemId,
workspace_id: crate::WorkspaceId,
is_open: bool
) -> Result<()> {
INSERT OR REPLACE INTO welcome_pages(item_id, workspace_id, is_open)
VALUES (?, ?, ?)
}
}
query! {
pub fn get_welcome_page(
item_id: crate::ItemId,
workspace_id: crate::WorkspaceId
) -> Result<bool> {
SELECT is_open
FROM welcome_pages
WHERE item_id = ? AND workspace_id = ?
}
}
}
}
fn project_name(paths: &PathList) -> String {
let joined = paths
.paths()
.iter()
.filter_map(|p| p.file_name().map(|n| n.to_string_lossy().to_string()))
.collect::<Vec<_>>()
.join(", ");
if joined.is_empty() {
"Untitled".to_string()
} else {
joined
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_project_name_empty() {
let paths = PathList::new::<&str>(&[]);
assert_eq!(project_name(&paths), "Untitled");
}
#[test]
fn test_project_name_single() {
let paths = PathList::new(&["/home/user/my-project"]);
assert_eq!(project_name(&paths), "my-project");
}
#[test]
fn test_project_name_multiple() {
// PathList sorts lexicographically, so filenames appear in alpha order
let paths = PathList::new(&["/home/user/zed", "/home/user/api"]);
assert_eq!(project_name(&paths), "api, zed");
}
#[test]
fn test_project_name_root_path_filtered() {
// A bare root "/" has no file_name(), falls back to "Untitled"
let paths = PathList::new(&["/"]);
assert_eq!(project_name(&paths), "Untitled");
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,177 @@
use std::{num::NonZeroUsize, time::Duration};
use crate::DockPosition;
use collections::HashMap;
use serde::Deserialize;
pub use settings::{
ActionName, AutosaveSetting, BottomDockLayout, EncodingDisplayOptions, InactiveOpacity,
PaneSplitDirectionHorizontal, PaneSplitDirectionVertical, RegisterSetting,
RestoreOnStartupBehavior, Settings,
};
#[derive(RegisterSetting)]
pub struct WorkspaceSettings {
pub active_pane_modifiers: ActivePanelModifiers,
pub bottom_dock_layout: settings::BottomDockLayout,
pub pane_split_direction_horizontal: settings::PaneSplitDirectionHorizontal,
pub pane_split_direction_vertical: settings::PaneSplitDirectionVertical,
pub centered_layout: settings::CenteredLayoutSettings,
pub confirm_quit: bool,
pub show_call_status_icon: bool,
pub autosave: AutosaveSetting,
pub restore_on_startup: settings::RestoreOnStartupBehavior,
pub cli_default_open_behavior: settings::CliDefaultOpenBehavior,
pub restore_on_file_reopen: bool,
pub drop_target_size: f32,
pub use_system_path_prompts: bool,
pub use_system_prompts: bool,
pub command_aliases: HashMap<String, ActionName>,
pub max_tabs: Option<NonZeroUsize>,
pub when_closing_with_no_tabs: settings::CloseWindowWhenNoItems,
pub on_last_window_closed: settings::OnLastWindowClosed,
pub text_rendering_mode: settings::TextRenderingMode,
pub resize_all_panels_in_dock: Vec<DockPosition>,
pub close_on_file_delete: bool,
pub close_panel_on_toggle: bool,
pub use_system_window_tabs: bool,
pub zoomed_padding: bool,
pub window_decorations: settings::WindowDecorations,
pub focus_follows_mouse: FocusFollowsMouse,
}
#[derive(Copy, Clone, Deserialize)]
pub struct FocusFollowsMouse {
pub enabled: bool,
pub debounce: Duration,
}
#[derive(Copy, Clone, PartialEq, Debug, Default)]
pub struct ActivePanelModifiers {
/// Size of the border surrounding the active pane.
/// When set to 0, the active pane doesn't have any border.
/// The border is drawn inset.
///
/// Default: `0.0`
// TODO: make this not an option, it is never None
pub border_size: Option<f32>,
/// Opacity of inactive panels.
/// When set to 1.0, the inactive panes have the same opacity as the active one.
/// If set to 0, the inactive panes content will not be visible at all.
/// Values are clamped to the [0.0, 1.0] range.
///
/// Default: `1.0`
// TODO: make this not an option, it is never None
pub inactive_opacity: Option<InactiveOpacity>,
}
#[derive(Deserialize, RegisterSetting)]
pub struct TabBarSettings {
pub show: bool,
pub show_nav_history_buttons: bool,
pub show_tab_bar_buttons: bool,
pub show_pinned_tabs_in_separate_row: bool,
}
impl Settings for WorkspaceSettings {
fn from_settings(content: &settings::SettingsContent) -> Self {
let workspace = &content.workspace;
Self {
active_pane_modifiers: ActivePanelModifiers {
border_size: Some(
workspace
.active_pane_modifiers
.unwrap()
.border_size
.unwrap(),
),
inactive_opacity: Some(
workspace
.active_pane_modifiers
.unwrap()
.inactive_opacity
.unwrap(),
),
},
bottom_dock_layout: workspace.bottom_dock_layout.unwrap(),
pane_split_direction_horizontal: workspace.pane_split_direction_horizontal.unwrap(),
pane_split_direction_vertical: workspace.pane_split_direction_vertical.unwrap(),
centered_layout: workspace.centered_layout.unwrap(),
confirm_quit: workspace.confirm_quit.unwrap(),
show_call_status_icon: workspace.show_call_status_icon.unwrap(),
autosave: workspace.autosave.unwrap(),
restore_on_startup: workspace.restore_on_startup.unwrap(),
cli_default_open_behavior: workspace.cli_default_open_behavior.unwrap(),
restore_on_file_reopen: workspace.restore_on_file_reopen.unwrap(),
drop_target_size: workspace.drop_target_size.unwrap(),
use_system_path_prompts: workspace.use_system_path_prompts.unwrap(),
use_system_prompts: workspace.use_system_prompts.unwrap(),
command_aliases: workspace.command_aliases.clone(),
max_tabs: workspace.max_tabs,
when_closing_with_no_tabs: workspace.when_closing_with_no_tabs.unwrap(),
on_last_window_closed: workspace.on_last_window_closed.unwrap(),
text_rendering_mode: workspace.text_rendering_mode.unwrap(),
resize_all_panels_in_dock: workspace
.resize_all_panels_in_dock
.clone()
.unwrap()
.into_iter()
.map(Into::into)
.collect(),
close_on_file_delete: workspace.close_on_file_delete.unwrap(),
close_panel_on_toggle: workspace.close_panel_on_toggle.unwrap(),
use_system_window_tabs: workspace.use_system_window_tabs.unwrap(),
zoomed_padding: workspace.zoomed_padding.unwrap(),
window_decorations: workspace.window_decorations.unwrap(),
focus_follows_mouse: FocusFollowsMouse {
enabled: workspace
.focus_follows_mouse
.unwrap()
.enabled
.unwrap_or(false),
debounce: Duration::from_millis(
workspace
.focus_follows_mouse
.unwrap()
.debounce_ms
.unwrap_or(250),
),
},
}
}
}
impl Settings for TabBarSettings {
fn from_settings(content: &settings::SettingsContent) -> Self {
let tab_bar = content.tab_bar.clone().unwrap();
TabBarSettings {
show: tab_bar.show.unwrap(),
show_nav_history_buttons: tab_bar.show_nav_history_buttons.unwrap(),
show_tab_bar_buttons: tab_bar.show_tab_bar_buttons.unwrap(),
show_pinned_tabs_in_separate_row: tab_bar.show_pinned_tabs_in_separate_row.unwrap(),
}
}
}
#[derive(Deserialize, RegisterSetting)]
pub struct StatusBarSettings {
pub show: bool,
pub show_active_file: bool,
pub active_language_button: bool,
pub cursor_position_button: bool,
pub line_endings_button: bool,
pub active_encoding_button: EncodingDisplayOptions,
}
impl Settings for StatusBarSettings {
fn from_settings(content: &settings::SettingsContent) -> Self {
let status_bar = content.status_bar.clone().unwrap();
StatusBarSettings {
show: status_bar.show.unwrap(),
show_active_file: status_bar.show_active_file.unwrap(),
active_language_button: status_bar.active_language_button.unwrap(),
cursor_position_button: status_bar.cursor_position_button.unwrap(),
line_endings_button: status_bar.line_endings_button.unwrap(),
active_encoding_button: status_bar.active_encoding_button.unwrap(),
}
}
}