zed/crates/zed/src/zed.rs
Sathwik Chirivelli 5d3b9e467e
git_ui: Open file diffs from git panel (#56152)
Self-Review Checklist:
- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content is consistent with the [UI/UX
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

Addresses https://github.com/zed-industries/zed/discussions/33773.

This changes git panel file activation so double-clicking or
secondary-opening a changed file opens a dedicated full-file diff tab
backed by a `SplittableEditor`.

The per-file diff reuses the project diff staging and restore controls,
respects the configured diff view style, and focuses an existing
per-file diff tab when one is already open instead of creating
duplicates.

Verified with `cargo run`.

Release Notes:

- Improved git panel file diff opening.

---------

Co-authored-by: Christopher Biscardi <chris@christopherbiscardi.com>
2026-05-29 19:43:26 +00:00

6670 lines
236 KiB
Rust

mod app_menus;
pub mod edit_prediction_registry;
#[cfg(target_os = "macos")]
pub(crate) mod mac_only_instance;
mod migrate;
#[cfg(target_os = "macos")]
pub(crate) mod move_to_applications;
mod open_listener;
mod open_url_modal;
mod quick_action_bar;
pub mod remote_debug;
pub mod telemetry_log;
#[cfg(all(target_os = "macos", feature = "visual-tests"))]
pub mod visual_tests;
#[cfg(target_os = "windows")]
pub(crate) mod windows_only_instance;
use agent_settings::{UserAgentsMdState, init_user_agents_md};
use agent_ui::AgentDiffToolbar;
use anyhow::Context as _;
pub use app_menus::*;
use assets::Assets;
use breadcrumbs::Breadcrumbs;
use client::zed_urls;
use collections::VecDeque;
use debugger_ui::debugger_panel::DebugPanel;
use editor::{Editor, MultiBuffer};
use extension_host::ExtensionStore;
use feature_flags::{FeatureFlagAppExt as _, PanicFeatureFlag};
use fs::Fs;
use futures::FutureExt as _;
use futures::{StreamExt, channel::mpsc, select_biased};
use git_ui::commit_view::CommitViewToolbar;
use git_ui::git_panel::GitPanel;
use git_ui::project_diff::{BranchDiffToolbar, ProjectDiffToolbar};
use git_ui::solo_diff_view::{SoloDiffGitToolbar, SoloDiffStyleToolbar};
use gpui::{
Action, App, AppContext as _, AsyncWindowContext, ClipboardItem, Context, DismissEvent,
Element, Entity, FocusHandle, Focusable, Image, ImageFormat, KeyBinding, ParentElement,
PathPromptOptions, PromptLevel, ReadGlobal, SharedString, Size, Task, TaskExt, TitlebarOptions,
UpdateGlobal, WeakEntity, Window, WindowBounds, WindowHandle, WindowKind, WindowOptions,
actions, image_cache, img, point, px, retain_all,
};
use image_viewer::ImageInfo;
use language::Capability;
use language_onboarding::BasedPyrightBanner;
use language_tools::lsp_button::{self, LspButton};
use language_tools::lsp_log_view::LspLogToolbarItemView;
use markdown::{Markdown, MarkdownElement, MarkdownFont, MarkdownStyle};
use migrate::{MigrationBanner, MigrationEvent, MigrationNotification, MigrationType};
use migrator::migrate_keymap;
use onboarding::multibuffer_hint::MultibufferHint;
pub use open_listener::*;
use outline_panel::OutlinePanel;
use paths::{
local_debug_file_relative_path, local_settings_file_relative_path,
local_tasks_file_relative_path,
};
use project::{DirectoryLister, DisableAiSettings, ProjectItem};
use project_panel::ProjectPanel;
use quick_action_bar::QuickActionBar;
use recent_projects::open_remote_project;
use release_channel::{AppCommitSha, AppVersion, ReleaseChannel};
use rope::Rope;
use search::project_search::ProjectSearchBar;
use settings::{
BaseKeymap, DEFAULT_KEYMAP_PATH, InvalidSettingsError, KeybindSource, KeymapFile,
KeymapFileLoadResult, MigrationStatus, Settings, SettingsFile, SettingsStore, VIM_KEYMAP_PATH,
initial_local_debug_tasks_content, initial_project_settings_content, initial_tasks_content,
update_settings_file,
};
use sidebar::Sidebar;
use std::{
borrow::Cow,
path::{Path, PathBuf},
sync::Arc,
sync::atomic::{self, AtomicBool},
};
use terminal_view::terminal_panel::{self, TerminalPanel};
use theme::{ActiveTheme, SystemAppearance, ThemeRegistry, deserialize_icon_theme};
use theme_settings::{ThemeSettings, load_user_theme};
use ui::{Navigable, NavigableEntry, PopoverMenuHandle, TintColor, prelude::*};
use util::markdown::MarkdownString;
use util::rel_path::RelPath;
use util::{ResultExt, asset_str, maybe};
use uuid::Uuid;
use vim_mode_setting::VimModeSetting;
use workspace::notifications::{NotificationId, dismiss_app_notification, show_app_notification};
use workspace::{
AppState, MultiWorkspace, NewFile, NewWindow, OpenLog, Panel, Toast, Workspace,
WorkspaceSettings, create_and_open_local_file,
notifications::simple_message_notification::MessageNotification, open_new,
};
use workspace::{
CloseIntent, CloseProject, CloseWindow, RestoreBanner, with_active_or_new_workspace,
};
use workspace::{Pane, notifications::DetachAndPromptErr};
use zed_actions::{
About, OpenAccountSettings, OpenBrowser, OpenDocs, OpenServerSettings, OpenSettingsFile,
OpenStatusPage, OpenZedUrl, Quit,
};
const DOCS_URL: &str = "https://zed.dev/docs/";
const STATUS_URL: &str = "https://status.zed.dev";
pub struct CrashHandler(pub Arc<crashes::Client>);
impl gpui::Global for CrashHandler {}
actions!(
zed,
[
/// Opens the element inspector for debugging UI.
DebugElements,
/// Hides the application window.
Hide,
/// Hides all other application windows.
HideOthers,
/// Minimizes the current window.
Minimize,
/// Opens the default settings file.
OpenDefaultSettings,
/// Opens project-specific settings file.
OpenProjectSettingsFile,
/// Opens the project tasks configuration.
OpenProjectTasks,
/// Opens the tasks panel.
OpenTasks,
/// Opens debug tasks configuration.
OpenDebugTasks,
/// Shows the default semantic token rules (read-only).
ShowDefaultSemanticTokenRules,
/// Resets the application database.
ResetDatabase,
/// Shows all hidden windows.
ShowAll,
/// Toggles fullscreen mode.
ToggleFullScreen,
/// Zooms the window.
Zoom,
/// Triggers a test panic for debugging.
TestPanic,
/// Triggers a hard crash for debugging.
TestCrash,
]
);
actions!(
dev,
[
/// Opens a prompt to enter a URL to open.
OpenUrlPrompt,
]
);
pub fn init(cx: &mut App) {
#[cfg(target_os = "macos")]
cx.on_action(|_: &Hide, cx| cx.hide());
#[cfg(target_os = "macos")]
cx.on_action(|_: &HideOthers, cx| cx.hide_other_apps());
#[cfg(target_os = "macos")]
cx.on_action(|_: &ShowAll, cx| cx.unhide_other_apps());
cx.on_action(quit);
cx.on_action(|_: &RestoreBanner, cx| title_bar::restore_banner(cx));
cx.observe_flag::<PanicFeatureFlag, _>({
let mut added = false;
move |flag, cx| {
if added || !*flag {
return;
}
added = true;
cx.on_action(|_: &TestPanic, _| panic!("Ran the TestPanic action"))
.on_action(|_: &TestCrash, _| {
unsafe extern "C" {
fn puts(s: *const i8);
}
unsafe {
puts(0xabad1d3a as *const i8);
}
});
}
})
.detach();
cx.on_action(|_: &OpenLog, cx| {
with_active_or_new_workspace(cx, |workspace, window, cx| {
open_log_file(workspace, window, cx);
});
})
.on_action(|_: &workspace::RevealLogInFileManager, cx| {
cx.reveal_path(paths::log_file().as_path());
})
.on_action(|_: &zed_actions::OpenLicenses, cx| {
with_active_or_new_workspace(cx, |workspace, window, cx| {
open_bundled_file(
workspace,
asset_str::<Assets>("licenses.md"),
"Open Source License Attribution",
"Markdown",
window,
cx,
);
});
})
.on_action(|&zed_actions::OpenKeymapFile, cx| {
with_active_or_new_workspace(cx, |_, window, cx| {
open_settings_file(
paths::keymap_file(),
|| settings::initial_keymap_content().as_ref().into(),
window,
cx,
);
});
})
.on_action(|_: &OpenSettingsFile, cx| {
with_active_or_new_workspace(cx, |_, window, cx| {
open_settings_file(
paths::settings_file(),
|| settings::initial_user_settings_content().as_ref().into(),
window,
cx,
);
});
})
.on_action(|_: &OpenAccountSettings, cx| {
with_active_or_new_workspace(cx, |_, _, cx| {
cx.open_url(&zed_urls::account_url(cx));
});
})
.on_action(|_: &OpenTasks, cx| {
with_active_or_new_workspace(cx, |_, window, cx| {
open_settings_file(
paths::tasks_file(),
|| settings::initial_tasks_content().as_ref().into(),
window,
cx,
);
});
})
.on_action(|_: &OpenDebugTasks, cx| {
with_active_or_new_workspace(cx, |_, window, cx| {
open_settings_file(
paths::debug_scenarios_file(),
|| settings::initial_debug_tasks_content().as_ref().into(),
window,
cx,
);
});
})
.on_action(|_: &ShowDefaultSemanticTokenRules, cx| {
with_active_or_new_workspace(cx, |workspace, window, cx| {
open_bundled_file(
workspace,
settings::default_semantic_token_rules(),
"Default Semantic Token Rules",
"JSONC",
window,
cx,
);
});
})
.on_action(|_: &OpenDefaultSettings, cx| {
with_active_or_new_workspace(cx, |workspace, window, cx| {
open_bundled_file(
workspace,
settings::default_settings(),
"Default Settings",
"JSON",
window,
cx,
);
});
})
.on_action(|_: &zed_actions::OpenDefaultKeymap, cx| {
with_active_or_new_workspace(cx, |workspace, window, cx| {
open_bundled_file(
workspace,
settings::default_keymap(),
"Default Key Bindings",
"JSON",
window,
cx,
);
});
})
.on_action(|_: &About, cx| {
open_about_window(cx);
});
}
fn bind_on_window_closed(cx: &mut App) -> Option<gpui::Subscription> {
#[cfg(target_os = "macos")]
{
WorkspaceSettings::get_global(cx)
.on_last_window_closed
.is_quit_app()
.then(|| {
cx.on_window_closed(|cx, _window_id| {
if cx.windows().is_empty() {
cx.quit();
}
})
})
}
#[cfg(not(target_os = "macos"))]
{
Some(cx.on_window_closed(|cx, _window_id| {
if cx.windows().is_empty() {
cx.quit();
}
}))
}
}
pub fn build_window_options(display_uuid: Option<Uuid>, cx: &mut App) -> WindowOptions {
let display = display_uuid.and_then(|uuid| {
cx.displays()
.into_iter()
.find(|display| display.uuid().ok() == Some(uuid))
});
let app_id = ReleaseChannel::global(cx).app_id();
let window_decorations = match std::env::var("ZED_WINDOW_DECORATIONS") {
Ok(val) if val == "server" => gpui::WindowDecorations::Server,
Ok(val) if val == "client" => gpui::WindowDecorations::Client,
_ => match WorkspaceSettings::get_global(cx).window_decorations {
settings::WindowDecorations::Server => gpui::WindowDecorations::Server,
settings::WindowDecorations::Client => gpui::WindowDecorations::Client,
},
};
let use_system_window_tabs = WorkspaceSettings::get_global(cx).use_system_window_tabs;
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
static APP_ICON: std::sync::LazyLock<Option<std::sync::Arc<image::RgbaImage>>> =
std::sync::LazyLock::new(|| {
// this shouldn't fail since decode is checked in build.rs
const BYTES: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/app_icon.png"));
util::maybe!({
let image = image::ImageReader::new(std::io::Cursor::new(BYTES))
.with_guessed_format()?
.decode()?
.into();
anyhow::Ok(Arc::new(image))
})
.log_err()
});
WindowOptions {
titlebar: Some(TitlebarOptions {
title: None,
appears_transparent: true,
traffic_light_position: Some(point(px(9.0), px(9.0))),
}),
window_bounds: None,
focus: false,
show: false,
kind: WindowKind::Normal,
is_movable: true,
display_id: display.map(|display| display.id()),
window_background: cx.theme().window_background_appearance(),
app_id: Some(app_id.to_owned()),
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
icon: APP_ICON.as_ref().cloned(),
window_decorations: Some(window_decorations),
window_min_size: Some(gpui::Size {
width: px(360.0),
height: px(240.0),
}),
tabbing_identifier: if use_system_window_tabs {
Some(String::from("zed"))
} else {
None
},
..Default::default()
}
}
pub fn initialize_workspace(app_state: Arc<AppState>, cx: &mut App) {
let mut _on_close_subscription = bind_on_window_closed(cx);
cx.observe_global::<SettingsStore>(move |cx| {
// A 1.92 regression causes unused-assignment to trigger on this variable.
_ = _on_close_subscription.is_some();
_on_close_subscription = bind_on_window_closed(cx);
})
.detach();
init_cursor_hide_mode(cx);
cx.observe_new(|_multi_workspace: &mut MultiWorkspace, window, cx| {
let Some(window) = window else {
return;
};
#[cfg(feature = "track-project-leak")]
{
let multi_workspace_handle = cx.weak_entity();
let workspace_handle = _multi_workspace.workspace().downgrade();
let project_handle = _multi_workspace.workspace().read(cx).project().downgrade();
let window_id_2 = window.window_handle().window_id();
cx.on_window_closed(move |cx, window_id| {
let multi_workspace_handle = multi_workspace_handle.clone();
let workspace_handle = workspace_handle.clone();
let project_handle = project_handle.clone();
if window_id != window_id_2 {
return;
}
cx.spawn(async move |cx| {
cx.background_executor()
.timer(std::time::Duration::from_millis(1500))
.await;
multi_workspace_handle.assert_released();
workspace_handle.assert_released();
project_handle.assert_released();
})
.detach();
})
.detach();
}
cx.spawn_in(window, async move |_this, cx| {
const TELEMETRY_INTERVAL: std::time::Duration = std::time::Duration::from_secs(5 * 60);
loop {
cx.background_executor().timer(TELEMETRY_INTERVAL).await;
if cx
.update(|window, cx| {
input_latency_ui::report_input_latency_telemetry(window, cx);
})
.is_err()
{
break;
}
}
})
.detach();
let multi_workspace_handle = cx.entity().downgrade();
window.on_window_should_close(cx, move |window, cx| {
multi_workspace_handle
.update(cx, |multi_workspace, cx| {
// We'll handle closing asynchronously
multi_workspace.close_window(&CloseWindow, window, cx);
false
})
.unwrap_or(true)
});
let window_handle = window.window_handle();
let multi_workspace_handle = cx.entity();
cx.subscribe_in(
&multi_workspace_handle,
window,
|this, _multi_workspace, event: &workspace::MultiWorkspaceEvent, window, cx| {
let workspace::MultiWorkspaceEvent::ActiveWorkspaceChanged { source_workspace } =
event
else {
return;
};
let active_workspace = this.workspace().clone();
let source_workspace = source_workspace.clone();
active_workspace.update(cx, |workspace, cx| {
if let Some(ref source) = source_workspace {
if let Some(panel) = workspace.panel::<agent_ui::AgentPanel>(cx) {
panel.update(cx, |panel, cx| {
panel.initialize_from_source_workspace_if_needed(
source.clone(),
window,
cx,
);
});
}
}
ensure_agent_panel_for_workspace(workspace, source_workspace, window, cx)
.detach_and_log_err(cx);
});
},
)
.detach();
cx.defer(move |cx| {
window_handle
.update(cx, |_, window, cx| {
let sidebar =
cx.new(|cx| Sidebar::new(multi_workspace_handle.clone(), window, cx));
multi_workspace_handle.update(cx, |multi_workspace, cx| {
multi_workspace.register_sidebar(sidebar, cx);
});
})
.ok();
});
})
.detach();
cx.observe_new(move |workspace: &mut Workspace, window, cx| {
let Some(window) = window else {
return;
};
let workspace_handle = cx.entity();
let center_pane = workspace.active_pane().clone();
initialize_pane(workspace, &center_pane, window, cx);
cx.subscribe_in(&workspace_handle, window, {
move |workspace, _, event, window, cx| match event {
workspace::Event::PaneAdded(pane) => {
initialize_pane(workspace, pane, window, cx);
}
workspace::Event::OpenBundledFile {
text,
title,
language,
} => open_bundled_file(workspace, text.clone(), title, language, window, cx),
_ => {}
}
})
.detach();
#[cfg(not(any(test, target_os = "macos")))]
initialize_file_watcher(window, cx);
if let Some(specs) = window.gpu_specs() {
log::info!("Using GPU: {:?}", specs);
show_software_emulation_warning_if_needed(specs.clone(), window, cx);
if let Some(crash_client) = cx.try_global::<CrashHandler>() {
crashes::set_gpu_info(&crash_client.0, specs);
}
}
let edit_prediction_menu_handle = PopoverMenuHandle::default();
let edit_prediction_ui = cx.new(|cx| {
edit_prediction_ui::EditPredictionButton::new(
app_state.fs.clone(),
app_state.user_store.clone(),
edit_prediction_menu_handle.clone(),
workspace.project().clone(),
cx,
)
});
workspace.register_action({
move |_, _: &edit_prediction_ui::ToggleMenu, window, cx| {
edit_prediction_menu_handle.toggle(window, cx);
}
});
let search_button = cx.new(|_| search::search_status_button::SearchButton::new());
let diagnostic_summary =
cx.new(|cx| diagnostics::items::DiagnosticIndicator::new(workspace, cx));
let active_file_name = cx.new(|_| workspace::active_file_name::ActiveFileName::new());
let activity_indicator = activity_indicator::ActivityIndicator::new(
workspace,
workspace.project().read(cx).languages().clone(),
window,
cx,
);
let active_buffer_encoding =
cx.new(|_| encoding_selector::ActiveBufferEncoding::new(workspace));
let active_buffer_language =
cx.new(|_| language_selector::ActiveBufferLanguage::new(workspace));
let active_toolchain_language =
cx.new(|cx| toolchain_selector::ActiveToolchain::new(workspace, window, cx));
let vim_mode_indicator = cx.new(|cx| vim::ModeIndicator::new(window, cx));
let image_info = cx.new(|_cx| ImageInfo::new(workspace));
let lsp_button_menu_handle = PopoverMenuHandle::default();
let lsp_button =
cx.new(|cx| LspButton::new(workspace, lsp_button_menu_handle.clone(), window, cx));
workspace.register_action({
move |_, _: &lsp_button::ToggleMenu, window, cx| {
lsp_button_menu_handle.toggle(window, cx);
}
});
let cursor_position =
cx.new(|_| go_to_line::cursor_position::CursorPosition::new(workspace));
let line_ending_indicator =
cx.new(|_| line_ending_selector::LineEndingIndicator::default());
let merge_conflict_indicator =
cx.new(|cx| git_ui::MergeConflictIndicator::new(workspace, cx));
workspace.status_bar().update(cx, |status_bar, cx| {
status_bar.add_left_item(search_button, window, cx);
status_bar.add_left_item(lsp_button, window, cx);
status_bar.add_left_item(diagnostic_summary, window, cx);
status_bar.add_left_item(active_file_name, window, cx);
status_bar.add_left_item(merge_conflict_indicator, window, cx);
status_bar.add_left_item(activity_indicator, window, cx);
status_bar.add_right_item(edit_prediction_ui, window, cx);
status_bar.add_right_item(active_buffer_encoding, window, cx);
status_bar.add_right_item(active_buffer_language, window, cx);
status_bar.add_right_item(active_toolchain_language, window, cx);
status_bar.add_right_item(line_ending_indicator, window, cx);
status_bar.add_right_item(vim_mode_indicator, window, cx);
status_bar.add_right_item(cursor_position, window, cx);
status_bar.add_right_item(image_info, window, cx);
});
let panels_task = initialize_panels(window, cx);
workspace.set_panels_task(panels_task);
register_actions(app_state.clone(), workspace, window, cx);
if !workspace.has_active_modal(window, cx) {
workspace.focus_handle(cx).focus(window, cx);
}
})
.detach();
}
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
#[allow(unused)]
fn initialize_file_watcher(window: &mut Window, cx: &mut Context<Workspace>) {
if let Err(e) = fs::fs_watcher::global(|_| {}) {
let message = format!(
db::indoc! {r#"
inotify_init returned {}
This may be due to system-wide limits on inotify instances. For troubleshooting see: https://zed.dev/docs/linux
"#},
e
);
let prompt = window.prompt(
PromptLevel::Critical,
"Could not start inotify",
Some(&message),
&["Troubleshoot and Quit"],
cx,
);
cx.spawn(async move |_, cx| {
if prompt.await == Ok(0) {
cx.update(|cx| {
cx.open_url("https://zed.dev/docs/linux#could-not-start-inotify");
cx.quit();
});
}
})
.detach()
}
}
#[cfg(target_os = "windows")]
#[allow(unused)]
fn initialize_file_watcher(window: &mut Window, cx: &mut Context<Workspace>) {
if let Err(e) = fs::fs_watcher::global(|_| {}) {
let message = format!(
db::indoc! {r#"
ReadDirectoryChangesW initialization failed: {}
This may occur on network filesystems and WSL paths. For troubleshooting see: https://zed.dev/docs/windows
"#},
e
);
let prompt = window.prompt(
PromptLevel::Critical,
"Could not start ReadDirectoryChangesW",
Some(&message),
&["Troubleshoot and Quit"],
cx,
);
cx.spawn(async move |_, cx| {
if prompt.await == Ok(0) {
cx.update(|cx| {
cx.open_url("https://zed.dev/docs/windows");
cx.quit()
});
}
})
.detach()
}
}
fn show_software_emulation_warning_if_needed(
specs: gpui::GpuSpecs,
window: &mut Window,
cx: &mut Context<Workspace>,
) {
if specs.is_software_emulated && std::env::var("ZED_ALLOW_EMULATED_GPU").is_err() {
let (graphics_api, docs_url, open_url) = if cfg!(target_os = "windows") {
(
"DirectX",
"https://zed.dev/docs/windows",
"https://zed.dev/docs/windows",
)
} else {
(
"Vulkan",
"https://zed.dev/docs/linux",
"https://zed.dev/docs/linux#zed-fails-to-open-windows",
)
};
let message = format!(
db::indoc! {r#"
Zed uses {} for rendering and requires a compatible GPU.
Currently you are using a software emulated GPU ({}) which
will result in awful performance.
For troubleshooting see: {}
Set ZED_ALLOW_EMULATED_GPU=1 env var to permanently override.
"#},
graphics_api, specs.device_name, docs_url
);
let prompt = window.prompt(
PromptLevel::Critical,
"Unsupported GPU",
Some(&message),
&["Skip", "Troubleshoot and Quit"],
cx,
);
cx.spawn(async move |_, cx| {
if prompt.await == Ok(1) {
cx.update(|cx| {
cx.open_url(open_url);
cx.quit();
});
}
})
.detach()
}
}
fn initialize_panels(window: &mut Window, cx: &mut Context<Workspace>) -> Task<anyhow::Result<()>> {
cx.spawn_in(window, async move |workspace_handle, cx| {
let project_panel = ProjectPanel::load(workspace_handle.clone(), cx.clone());
let outline_panel = OutlinePanel::load(workspace_handle.clone(), cx.clone());
let terminal_panel = TerminalPanel::load(workspace_handle.clone(), cx.clone());
let git_panel = GitPanel::load(workspace_handle.clone(), cx.clone());
let channels_panel =
collab_ui::collab_panel::CollabPanel::load(workspace_handle.clone(), cx.clone());
let debug_panel = DebugPanel::load(workspace_handle.clone(), cx);
async fn add_panel_when_ready(
panel_task: impl Future<Output = anyhow::Result<Entity<impl workspace::Panel>>> + 'static,
workspace_handle: WeakEntity<Workspace>,
mut cx: gpui::AsyncWindowContext,
) {
if let Some(panel) = panel_task.await.context("failed to load panel").log_err()
{
workspace_handle
.update_in(&mut cx, |workspace, window, cx| {
workspace.add_panel(panel, window, cx);
})
.log_err();
}
}
futures::join!(
add_panel_when_ready(project_panel, workspace_handle.clone(), cx.clone()),
add_panel_when_ready(outline_panel, workspace_handle.clone(), cx.clone()),
add_panel_when_ready(terminal_panel, workspace_handle.clone(), cx.clone()),
add_panel_when_ready(git_panel, workspace_handle.clone(), cx.clone()),
add_panel_when_ready(channels_panel, workspace_handle.clone(), cx.clone()),
add_panel_when_ready(debug_panel, workspace_handle.clone(), cx.clone()),
initialize_agent_panel(workspace_handle, cx.clone()).map(|r| r.log_err()),
);
anyhow::Ok(())
})
}
fn setup_or_teardown_ai_panel<P: Panel>(
workspace: &mut Workspace,
window: &mut Window,
cx: &mut Context<Workspace>,
load_panel: impl FnOnce(
WeakEntity<Workspace>,
AsyncWindowContext,
) -> Task<anyhow::Result<Entity<P>>>
+ 'static,
) -> Task<anyhow::Result<()>> {
let disable_ai = SettingsStore::global(cx)
.get::<DisableAiSettings>(None)
.disable_ai
|| cfg!(test);
let existing_panel = workspace.panel::<P>(cx);
match (disable_ai, existing_panel) {
(false, None) => cx.spawn_in(window, async move |workspace, cx| {
let panel = load_panel(workspace.clone(), cx.clone()).await?;
workspace.update_in(cx, |workspace, window, cx| {
let disable_ai = SettingsStore::global(cx)
.get::<DisableAiSettings>(None)
.disable_ai;
let have_panel = workspace.panel::<P>(cx).is_some();
if !disable_ai && !have_panel {
workspace.add_panel(panel, window, cx);
}
})
}),
(true, Some(existing_panel)) => {
workspace.remove_panel::<P>(&existing_panel, window, cx);
Task::ready(Ok(()))
}
_ => Task::ready(Ok(())),
}
}
fn ensure_agent_panel_for_workspace(
workspace: &mut Workspace,
source_workspace: Option<WeakEntity<Workspace>>,
window: &mut Window,
cx: &mut Context<Workspace>,
) -> Task<anyhow::Result<()>> {
let task = setup_or_teardown_ai_panel(workspace, window, cx, move |workspace, cx| {
agent_ui::AgentPanel::load(workspace, cx)
});
cx.spawn_in(window, async move |workspace, cx| {
task.await?;
workspace.update_in(cx, |workspace, window, cx| {
if let Some(source_workspace) = source_workspace.clone()
&& let Some(panel) = workspace.panel::<agent_ui::AgentPanel>(cx)
{
panel.update(cx, |panel, cx| {
panel.initialize_from_source_workspace_if_needed(source_workspace, window, cx);
});
}
})
})
}
async fn initialize_agent_panel(
workspace_handle: WeakEntity<Workspace>,
mut cx: AsyncWindowContext,
) -> anyhow::Result<()> {
workspace_handle
.update_in(&mut cx, |workspace, window, cx| {
ensure_agent_panel_for_workspace(workspace, None, window, cx)
})?
.await?;
workspace_handle.update_in(&mut cx, |workspace, window, cx| {
cx.observe_global_in::<SettingsStore>(window, move |workspace, window, cx| {
ensure_agent_panel_for_workspace(workspace, None, window, cx).detach_and_log_err(cx);
})
.detach();
// Register the actions that are shared between `assistant` and `assistant2`.
//
// We need to do this here instead of within the individual `init`
// functions so that we only register the actions once.
//
// Once we ship `assistant2` we can push this back down into `agent::agent_panel::init`.
if !cfg!(test) {
workspace
.register_action(agent_ui::AgentPanel::toggle_focus)
.register_action(agent_ui::AgentPanel::focus)
.register_action(agent_ui::AgentPanel::toggle)
.register_action(agent_ui::InlineAssistant::inline_assist);
}
})?;
anyhow::Ok(())
}
fn register_actions(
app_state: Arc<AppState>,
workspace: &mut Workspace,
_: &mut Window,
cx: &mut Context<Workspace>,
) {
workspace
.register_action(|_, _: &OpenDocs, _, cx| cx.open_url(DOCS_URL))
.register_action(|_, _: &OpenStatusPage, _, cx| cx.open_url(STATUS_URL))
.register_action(
|workspace: &mut Workspace,
_: &input_latency_ui::DumpInputLatencyHistogram,
window: &mut Window,
cx: &mut Context<Workspace>| {
let report =
input_latency_ui::format_input_latency_report(window, cx);
let project = workspace.project().clone();
let buffer = project.update(cx, |project, cx| {
project.create_local_buffer(&report, None, true, cx)
});
let editor =
cx.new(|cx| Editor::for_buffer(buffer, Some(project), window, cx));
workspace.add_item_to_active_pane(Box::new(editor), None, true, window, cx);
},
)
.register_action(|_, _: &Minimize, window, _| {
window.minimize_window();
})
.register_action(|_, _: &Zoom, window, _| {
window.zoom_window();
})
.register_action(|_, _: &ToggleFullScreen, window, _| {
window.toggle_fullscreen();
})
.register_action(|_, action: &OpenZedUrl, _, cx| {
OpenListener::global(cx).open(RawOpenRequest {
urls: vec![action.url.clone()],
..Default::default()
})
})
.register_action(|workspace, _: &OpenUrlPrompt, window, cx| {
workspace.toggle_modal(window, cx, |window, cx| {
open_url_modal::OpenUrlModal::new(window, cx)
});
})
.register_action(|workspace, action: &OpenBrowser, _window, cx| {
// Parse and validate the URL to ensure it's properly formatted
match url::Url::parse(&action.url) {
Ok(parsed_url) => {
// Use the parsed URL's string representation which is properly escaped
cx.open_url(parsed_url.as_str());
}
Err(e) => {
workspace.show_error(
&anyhow::anyhow!(
"Opening this URL in a browser failed because the URL is invalid: {}\n\nError was: {e}",
action.url
),
cx,
);
}
}
})
.register_action(|workspace, action: &workspace::Open, window, cx| {
telemetry::event!("Project Opened");
workspace::prompt_for_open_path_and_open(
workspace,
workspace.app_state().clone(),
PathPromptOptions {
files: true,
directories: true,
multiple: true,
prompt: None,
},
action.create_new_window,
window,
cx,
);
})
.register_action(|workspace, _: &workspace::OpenFiles, window, cx| {
let directories = cx.can_select_mixed_files_and_dirs();
workspace::prompt_for_open_path_and_open(
workspace,
workspace.app_state().clone(),
PathPromptOptions {
files: true,
directories,
multiple: true,
prompt: None,
},
true,
window,
cx,
);
})
.register_action(|workspace, action: &zed_actions::OpenRemote, window, cx| {
if !action.from_existing_connection {
cx.propagate();
return;
}
// You need existing remote connection to open it this way
if workspace.project().read(cx).is_local() {
return;
}
telemetry::event!("Project Opened");
let paths = workspace.prompt_for_open_path(
PathPromptOptions {
files: true,
directories: true,
multiple: true,
prompt: None,
},
DirectoryLister::Project(workspace.project().clone()),
window,
cx,
);
cx.spawn_in(window, async move |this, cx| {
let Some(paths) = paths.await.log_err().flatten() else {
return;
};
if let Some(task) = this
.update_in(cx, |this, window, cx| {
open_new_ssh_project_from_project(this, paths, window, cx)
})
.log_err()
{
task.await.log_err();
}
})
.detach()
})
.register_action({
let fs = app_state.fs.clone();
move |_, action: &zed_actions::IncreaseUiFontSize, _window, cx| {
if action.persist {
update_settings_file(fs.clone(), cx, move |settings, cx| {
let ui_font_size = ThemeSettings::get_global(cx).ui_font_size(cx) + px(1.0);
let _ = settings
.theme
.ui_font_size
.insert(f32::from(theme_settings::clamp_font_size(ui_font_size)).into());
});
} else {
theme_settings::adjust_ui_font_size(cx, |size| size + px(1.0));
}
}
})
.register_action({
let fs = app_state.fs.clone();
move |_, action: &zed_actions::DecreaseUiFontSize, _window, cx| {
if action.persist {
update_settings_file(fs.clone(), cx, move |settings, cx| {
let ui_font_size = ThemeSettings::get_global(cx).ui_font_size(cx) - px(1.0);
let _ = settings
.theme
.ui_font_size
.insert(f32::from(theme_settings::clamp_font_size(ui_font_size)).into());
});
} else {
theme_settings::adjust_ui_font_size(cx, |size| size - px(1.0));
}
}
})
.register_action({
let fs = app_state.fs.clone();
move |_, action: &zed_actions::ResetUiFontSize, _window, cx| {
if action.persist {
update_settings_file(fs.clone(), cx, move |settings, _| {
settings.theme.ui_font_size = None;
});
} else {
theme_settings::reset_ui_font_size(cx);
}
}
})
.register_action({
let fs = app_state.fs.clone();
move |_, action: &zed_actions::IncreaseBufferFontSize, _window, cx| {
if action.persist {
update_settings_file(fs.clone(), cx, move |settings, cx| {
let buffer_font_size =
ThemeSettings::get_global(cx).buffer_font_size(cx) + px(1.0);
let _ = settings
.theme
.buffer_font_size
.insert(f32::from(theme_settings::clamp_font_size(buffer_font_size)).into());
});
} else {
theme_settings::increase_buffer_font_size(cx);
}
}
})
.register_action({
let fs = app_state.fs.clone();
move |_, action: &zed_actions::DecreaseBufferFontSize, _window, cx| {
if action.persist {
update_settings_file(fs.clone(), cx, move |settings, cx| {
let buffer_font_size =
ThemeSettings::get_global(cx).buffer_font_size(cx) - px(1.0);
let _ = settings
.theme
.buffer_font_size
.insert(f32::from(theme_settings::clamp_font_size(buffer_font_size)).into());
});
} else {
theme_settings::decrease_buffer_font_size(cx);
}
}
})
.register_action({
let fs = app_state.fs.clone();
move |_, action: &zed_actions::ResetBufferFontSize, _window, cx| {
if action.persist {
update_settings_file(fs.clone(), cx, move |settings, _| {
settings.theme.buffer_font_size = None;
});
} else {
theme_settings::reset_buffer_font_size(cx);
}
}
})
.register_action({
let fs = app_state.fs.clone();
move |_, action: &zed_actions::ResetAllZoom, _window, cx| {
if action.persist {
update_settings_file(fs.clone(), cx, move |settings, _| {
settings.theme.ui_font_size = None;
settings.theme.buffer_font_size = None;
settings.theme.agent_ui_font_size = None;
settings.theme.agent_buffer_font_size = None;
});
} else {
theme_settings::reset_ui_font_size(cx);
theme_settings::reset_buffer_font_size(cx);
theme_settings::reset_agent_ui_font_size(cx);
theme_settings::reset_agent_buffer_font_size(cx);
}
}
})
.register_action(|_, _: &install_cli::RegisterZedScheme, window, cx| {
cx.spawn_in(window, async move |workspace, cx| {
install_cli::register_zed_scheme(cx).await?;
workspace.update_in(cx, |workspace, _, cx| {
struct RegisterZedScheme;
workspace.show_toast(
Toast::new(
NotificationId::unique::<RegisterZedScheme>(),
format!(
"zed:// links will now open in {}.",
ReleaseChannel::global(cx).display_name()
),
),
cx,
)
})?;
Ok(())
})
.detach_and_prompt_err(
"Error registering zed:// scheme",
window,
cx,
|_, _, _| None,
);
})
.register_action(open_project_settings_file)
.register_action(open_project_tasks_file)
.register_action(open_project_debug_tasks_file)
.register_action(
|workspace: &mut Workspace,
_: &zed_actions::project_panel::ToggleFocus,
window: &mut Window,
cx: &mut Context<Workspace>| {
workspace.toggle_panel_focus::<ProjectPanel>(window, cx);
},
)
.register_action(
|workspace: &mut Workspace,
_: &outline_panel::ToggleFocus,
window: &mut Window,
cx: &mut Context<Workspace>| {
workspace.toggle_panel_focus::<OutlinePanel>(window, cx);
},
)
.register_action(
|workspace: &mut Workspace,
_: &collab_ui::collab_panel::ToggleFocus,
window: &mut Window,
cx: &mut Context<Workspace>| {
workspace.toggle_panel_focus::<collab_ui::collab_panel::CollabPanel>(window, cx);
},
)
.register_action(
|workspace: &mut Workspace,
_: &terminal_panel::ToggleFocus,
window: &mut Window,
cx: &mut Context<Workspace>| {
workspace.toggle_panel_focus::<TerminalPanel>(window, cx);
},
)
.register_action({
let app_state = app_state.clone();
move |_, _: &NewWindow, _, cx| {
open_new(
Default::default(),
app_state.clone(),
cx,
|workspace, window, cx| {
cx.activate(true);
// Create buffer synchronously to avoid flicker
let project = workspace.project().clone();
let buffer = project.update(cx, |project, cx| {
project.create_local_buffer("", None, true, cx)
});
let editor = cx.new(|cx| {
Editor::for_buffer(buffer, Some(project), window, cx)
});
workspace.add_item_to_active_pane(
Box::new(editor),
None,
true,
window,
cx,
);
},
)
.detach();
}
})
.register_action({
let app_state = app_state.clone();
move |workspace, _: &CloseProject, window, cx| {
let Some(window_handle) = window.window_handle().downcast::<MultiWorkspace>() else {
return;
};
let app_state = app_state.clone();
let old_group_key = workspace.project_group_key(cx);
cx.spawn_in(window, async move |this, cx| {
let should_continue = this
.update_in(cx, |workspace, window, cx| {
workspace.prepare_to_close(
CloseIntent::ReplaceWindow,
window,
cx,
)
})?
.await?;
if should_continue {
let task = cx.update(|_window, cx| {
open_new(
workspace::OpenOptions {
requesting_window: Some(window_handle),
..Default::default()
},
app_state,
cx,
|workspace, window, cx| {
cx.activate(true);
let project = workspace.project().clone();
let buffer = project.update(cx, |project, cx| {
project.create_local_buffer("", None, true, cx)
});
let editor = cx.new(|cx| {
Editor::for_buffer(buffer, Some(project), window, cx)
});
workspace.add_item_to_active_pane(
Box::new(editor),
None,
true,
window,
cx,
);
},
)
})?;
task.await?;
window_handle.update(cx, |mw, window, cx| {
mw.remove_project_group(&old_group_key, window, cx)
})?.await.log_err();
Ok::<(), anyhow::Error>(())
} else {
Ok(())
}
})
.detach_and_log_err(cx);
}
})
.register_action({
let app_state = app_state.clone();
move |_, _: &NewFile, _, cx| {
open_new(
Default::default(),
app_state.clone(),
cx,
|workspace, window, cx| {
Editor::new_file(workspace, &Default::default(), window, cx)
},
)
.detach_and_log_err(cx);
}
});
#[cfg(not(target_os = "windows"))]
workspace.register_action(install_cli);
if workspace.project().read(cx).is_via_remote_server() {
workspace.register_action({
move |workspace, _: &OpenServerSettings, window, cx| {
let open_server_settings = workspace
.project()
.update(cx, |project, cx| project.open_server_settings(cx));
cx.spawn_in(window, async move |workspace, cx| {
let buffer = open_server_settings.await?;
workspace
.update_in(cx, |workspace, window, cx| {
workspace.open_path(
buffer
.read(cx)
.project_path(cx)
.expect("Settings file must have a location"),
None,
true,
window,
cx,
)
})?
.await?;
anyhow::Ok(())
})
.detach_and_log_err(cx);
}
});
}
workspace.register_action(sidebar::dump_workspace_info);
}
fn initialize_pane(
workspace: &Workspace,
pane: &Entity<Pane>,
window: &mut Window,
cx: &mut Context<Workspace>,
) {
let workspace_handle = cx.weak_entity();
pane.update(cx, |pane, cx| {
pane.toolbar().update(cx, |toolbar, cx| {
let multibuffer_hint = cx.new(|_| MultibufferHint::new());
toolbar.add_item(multibuffer_hint, window, cx);
let solo_diff_style_toolbar = cx.new(SoloDiffStyleToolbar::new);
toolbar.add_item(solo_diff_style_toolbar, window, cx);
let breadcrumbs = cx.new(|_| Breadcrumbs::new());
toolbar.add_item(breadcrumbs, window, cx);
let buffer_search_bar = cx.new(|cx| {
search::BufferSearchBar::new(
Some(workspace.project().read(cx).languages().clone()),
window,
cx,
)
});
toolbar.add_item(buffer_search_bar.clone(), window, cx);
let quick_action_bar =
cx.new(|cx| QuickActionBar::new(buffer_search_bar, workspace, cx));
toolbar.add_item(quick_action_bar, window, cx);
let diagnostic_editor_controls = cx.new(|_| diagnostics::ToolbarControls::new());
toolbar.add_item(diagnostic_editor_controls, window, cx);
let project_search_bar = cx.new(|_| ProjectSearchBar::new());
toolbar.add_item(project_search_bar, window, cx);
let lsp_log_item = cx.new(|_| LspLogToolbarItemView::new());
toolbar.add_item(lsp_log_item, window, cx);
let dap_log_item = cx.new(|_| debugger_tools::DapLogToolbarItemView::new());
toolbar.add_item(dap_log_item, window, cx);
let acp_tools_item = cx.new(|_| acp_tools::AcpToolsToolbarItemView::new());
toolbar.add_item(acp_tools_item, window, cx);
let telemetry_log_item =
cx.new(|cx| telemetry_log::TelemetryLogToolbarItemView::new(window, cx));
toolbar.add_item(telemetry_log_item, window, cx);
let syntax_tree_item = cx.new(|_| language_tools::SyntaxTreeToolbarItemView::new());
toolbar.add_item(syntax_tree_item, window, cx);
let migration_banner =
cx.new(|inner_cx| MigrationBanner::new(workspace_handle.clone(), inner_cx));
toolbar.add_item(migration_banner, window, cx);
let highlights_tree_item =
cx.new(|_| language_tools::HighlightsTreeToolbarItemView::new());
toolbar.add_item(highlights_tree_item, window, cx);
let project_diff_toolbar = cx.new(|cx| ProjectDiffToolbar::new(workspace, cx));
toolbar.add_item(project_diff_toolbar, window, cx);
let branch_diff_toolbar = cx.new(BranchDiffToolbar::new);
toolbar.add_item(branch_diff_toolbar, window, cx);
let solo_diff_git_toolbar = cx.new(SoloDiffGitToolbar::new);
toolbar.add_item(solo_diff_git_toolbar, window, cx);
let commit_view_toolbar = cx.new(|_| CommitViewToolbar::new());
toolbar.add_item(commit_view_toolbar, window, cx);
let agent_diff_toolbar = cx.new(AgentDiffToolbar::new);
toolbar.add_item(agent_diff_toolbar, window, cx);
let basedpyright_banner = cx.new(|cx| BasedPyrightBanner::new(workspace, cx));
toolbar.add_item(basedpyright_banner, window, cx);
let image_view_toolbar = cx.new(|_| image_viewer::ImageViewToolbarControls::new());
toolbar.add_item(image_view_toolbar, window, cx);
})
});
}
fn open_about_window(cx: &mut App) {
fn about_window_icon(release_channel: ReleaseChannel) -> Arc<Image> {
let bytes = match release_channel {
ReleaseChannel::Dev => include_bytes!("../resources/app-icon-dev.png").as_slice(),
ReleaseChannel::Nightly => {
include_bytes!("../resources/app-icon-nightly.png").as_slice()
}
ReleaseChannel::Preview => {
include_bytes!("../resources/app-icon-preview.png").as_slice()
}
ReleaseChannel::Stable => include_bytes!("../resources/app-icon.png").as_slice(),
};
Arc::new(Image::from_bytes(ImageFormat::Png, bytes.to_vec()))
}
struct AboutWindow {
focus_handle: FocusHandle,
ok_entry: NavigableEntry,
copy_entry: NavigableEntry,
app_icon: Arc<Image>,
message: SharedString,
commit: Option<SharedString>,
full_version: SharedString,
}
impl AboutWindow {
fn new(cx: &mut Context<Self>) -> Self {
let release_channel = ReleaseChannel::global(cx);
let release_channel_name = release_channel.display_name();
let full_version: SharedString = AppVersion::global(cx).to_string().into();
let version = env!("CARGO_PKG_VERSION");
let debug = if cfg!(debug_assertions) {
"(debug)"
} else {
""
};
let message: SharedString = format!("{release_channel_name} {version} {debug}").into();
let commit = AppCommitSha::try_global(cx)
.map(|sha| sha.full())
.filter(|commit| !commit.is_empty())
.map(SharedString::from);
Self {
focus_handle: cx.focus_handle(),
ok_entry: NavigableEntry::focusable(cx),
copy_entry: NavigableEntry::focusable(cx),
app_icon: about_window_icon(release_channel),
message,
commit,
full_version,
}
}
fn copy_details(&self, window: &mut Window, cx: &mut Context<Self>) {
let content = match self.commit.as_ref() {
Some(commit) => {
format!(
"{}\nCommit: {}\nVersion: {}",
self.message, commit, self.full_version
)
}
None => format!("{}\nVersion: {}", self.message, self.full_version),
};
cx.write_to_clipboard(ClipboardItem::new_string(content));
window.remove_window();
}
}
impl Render for AboutWindow {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let ok_is_focused = self.ok_entry.focus_handle.contains_focused(window, cx);
let copy_is_focused = self.copy_entry.focus_handle.contains_focused(window, cx);
Navigable::new(
v_flex()
.id("about-window")
.track_focus(&self.focus_handle)
.on_action(cx.listener(|_, _: &menu::Cancel, window, _cx| {
window.remove_window();
}))
.min_w_0()
.size_full()
.bg(cx.theme().colors().editor_background)
.text_color(cx.theme().colors().text)
.p_4()
.when(cfg!(target_os = "macos"), |this| this.pt_10())
.gap_4()
.text_center()
.justify_between()
.child(
v_flex()
.w_full()
.gap_2()
.items_center()
.child(img(self.app_icon.clone()).size_16().flex_none())
.child(Headline::new(self.message.clone()))
.when_some(self.commit.clone(), |this, commit| {
this.child(
Label::new("Commit")
.color(Color::Muted)
.size(LabelSize::XSmall),
)
.child(Label::new(commit).size(LabelSize::Small))
})
.child(
Label::new("Version")
.color(Color::Muted)
.size(LabelSize::XSmall),
)
.child(Label::new(self.full_version.clone()).size(LabelSize::Small)),
)
.child(
h_flex()
.w_full()
.gap_1()
.child(
div()
.flex_1()
.track_focus(&self.ok_entry.focus_handle)
.on_action(cx.listener(|_, _: &menu::Confirm, window, _cx| {
window.remove_window();
}))
.child(
Button::new("ok", "Ok")
.full_width()
.style(ButtonStyle::OutlinedGhost)
.toggle_state(ok_is_focused)
.selected_style(ButtonStyle::Tinted(TintColor::Accent))
.on_click(cx.listener(|_, _, window, _cx| {
window.remove_window();
})),
),
)
.child(
div()
.flex_1()
.track_focus(&self.copy_entry.focus_handle)
.on_action(cx.listener(
|this, _: &menu::Confirm, window, cx| {
this.copy_details(window, cx);
},
))
.child(
Button::new("copy", "Copy")
.full_width()
.style(ButtonStyle::Tinted(TintColor::Accent))
.toggle_state(copy_is_focused)
.selected_style(ButtonStyle::Tinted(TintColor::Accent))
.on_click(cx.listener(|this, _event, window, cx| {
this.copy_details(window, cx);
})),
),
),
)
.into_any_element(),
)
.entry(self.ok_entry.clone())
.entry(self.copy_entry.clone())
}
}
impl Focusable for AboutWindow {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.ok_entry.focus_handle.clone()
}
}
// Don't open about window twice
if let Some(existing) = cx
.windows()
.into_iter()
.find_map(|w| w.downcast::<AboutWindow>())
{
existing
.update(cx, |about_window, window, cx| {
window.activate_window();
about_window.ok_entry.focus_handle.focus(window, cx);
})
.log_err();
return;
}
let window_size = Size {
width: px(440.),
height: px(300.),
};
cx.open_window(
WindowOptions {
titlebar: Some(TitlebarOptions {
title: Some("About Zed".into()),
appears_transparent: true,
traffic_light_position: Some(point(px(12.), px(12.))),
}),
window_bounds: Some(WindowBounds::centered(window_size, cx)),
is_resizable: false,
is_minimizable: false,
kind: WindowKind::Floating,
app_id: Some(ReleaseChannel::global(cx).app_id().to_owned()),
..Default::default()
},
|window, cx| {
let about_window = cx.new(AboutWindow::new);
let focus_handle = about_window.read(cx).ok_entry.focus_handle.clone();
window.activate_window();
focus_handle.focus(window, cx);
about_window
},
)
.log_err();
}
#[cfg(not(target_os = "windows"))]
fn install_cli(
_: &mut Workspace,
_: &install_cli::InstallCliBinary,
window: &mut Window,
cx: &mut Context<Workspace>,
) {
install_cli::install_cli_binary(window, cx)
}
static WAITING_QUIT_CONFIRMATION: AtomicBool = AtomicBool::new(false);
fn quit(_: &Quit, cx: &mut App) {
if WAITING_QUIT_CONFIRMATION.load(atomic::Ordering::Acquire) {
return;
}
let should_confirm = WorkspaceSettings::get_global(cx).confirm_quit;
cx.spawn(async move |cx| {
let mut workspace_windows: Vec<WindowHandle<MultiWorkspace>> = cx.update(|cx| {
cx.windows()
.into_iter()
.filter_map(|window| window.downcast::<MultiWorkspace>())
.collect::<Vec<_>>()
});
// If multiple windows have unsaved changes, and need a save prompt,
// prompt in the active window before switching to a different window.
cx.update(|cx| {
workspace_windows.sort_by_key(|window| window.is_active(cx) == Some(false));
});
if should_confirm && let Some(multi_workspace) = workspace_windows.first() {
let answer = multi_workspace
.update(cx, |_, window, cx| {
window.prompt(
PromptLevel::Info,
"Are you sure you want to quit?",
None,
&["Quit", "Cancel"],
cx,
)
})
.log_err();
if let Some(answer) = answer {
WAITING_QUIT_CONFIRMATION.store(true, atomic::Ordering::Release);
let answer = answer.await.ok();
WAITING_QUIT_CONFIRMATION.store(false, atomic::Ordering::Release);
if answer != Some(0) {
return Ok(());
}
}
}
// If the user cancels any save prompt, then keep the app open.
for window in &workspace_windows {
let window = *window;
let workspaces = window
.update(cx, |multi_workspace, _, _cx| {
multi_workspace.workspaces().cloned().collect::<Vec<_>>()
})
.log_err();
let Some(workspaces) = workspaces else {
continue;
};
for workspace in workspaces {
if let Some(should_close) = window
.update(cx, |multi_workspace, window, cx| {
multi_workspace.activate(workspace.clone(), None, window, cx);
window.activate_window();
workspace.update(cx, |workspace, cx| {
workspace.prepare_to_close(CloseIntent::Quit, window, cx)
})
})
.log_err()
{
if !should_close.await? {
return Ok(());
}
}
}
}
// Flush all pending workspace serialization before quitting so that
// session_id/window_id are up-to-date in the database.
let mut flush_tasks = Vec::new();
for window in &workspace_windows {
window
.update(cx, |multi_workspace, window, cx| {
for workspace in multi_workspace.workspaces() {
flush_tasks.push(workspace.update(cx, |workspace, cx| {
workspace.flush_serialization(window, cx)
}));
}
flush_tasks.append(&mut multi_workspace.take_pending_removal_tasks());
flush_tasks.push(multi_workspace.flush_serialization());
})
.log_err();
}
futures::future::join_all(flush_tasks).await;
cx.update(|cx| cx.quit());
anyhow::Ok(())
})
.detach_and_log_err(cx);
}
fn open_log_file(workspace: &mut Workspace, window: &mut Window, cx: &mut Context<Workspace>) {
const MAX_LINES: usize = 1000;
let app_state = workspace.app_state();
let languages = app_state.languages.clone();
let fs = app_state.fs.clone();
cx.spawn_in(window, async move |workspace, cx| {
let log = {
let result = futures::join!(
fs.load(&paths::old_log_file()),
fs.load(&paths::log_file()),
languages.language_for_name("log")
);
match result {
(Err(_), Err(e), _) => Err(e),
(old_log, new_log, lang) => {
let mut lines = VecDeque::with_capacity(MAX_LINES);
for line in old_log
.iter()
.flat_map(|log| log.lines())
.chain(new_log.iter().flat_map(|log| log.lines()))
{
if lines.len() == MAX_LINES {
lines.pop_front();
}
lines.push_back(line);
}
Ok((
lines
.into_iter()
.flat_map(|line| [line, "\n"])
.collect::<String>(),
lang.ok(),
))
}
}
};
let (log, log_language) = match log {
Ok((log, log_language)) => (log, log_language),
Err(e) => {
struct OpenLogError;
workspace
.update(cx, |workspace, cx| {
workspace.show_notification(
NotificationId::unique::<OpenLogError>(),
cx,
|cx| {
cx.new(|cx| {
MessageNotification::new(
format!(
"Unable to access/open log file at path \
{}: {e:#}",
paths::log_file().display()
),
cx,
)
})
},
);
})
.ok();
return;
}
};
maybe!(async move {
let project = workspace
.read_with(cx, |workspace, _| workspace.project().clone())
.ok()?;
let buffer = project
.update(cx, |project, cx| {
project.create_buffer(log_language, false, cx)
})
.await
.ok()?;
buffer.update(cx, |buffer, cx| {
buffer.set_capability(Capability::ReadOnly, cx);
buffer.set_text(log, cx);
});
let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx).with_title("Log".into()));
let editor = cx
.new_window_entity(|window, cx| {
let mut editor = Editor::for_multibuffer(buffer, Some(project), window, cx);
editor.set_read_only(true);
editor.set_breadcrumb_header(format!(
"Last {} lines in {}",
MAX_LINES,
paths::log_file().display()
));
let last_multi_buffer_offset = editor.buffer().read(cx).len(cx);
editor.change_selections(Default::default(), window, cx, |s| {
s.select_ranges(Some(last_multi_buffer_offset..last_multi_buffer_offset));
});
editor
})
.ok()?;
workspace
.update_in(cx, |workspace, window, cx| {
workspace.add_item_to_active_pane(Box::new(editor), None, true, window, cx);
})
.ok()
})
.await;
})
.detach();
}
fn notify_settings_errors(result: settings::SettingsParseResult, is_user: bool, cx: &mut App) {
if let settings::ParseStatus::Failed { error: err } = &result.parse_status {
let settings_type = if is_user { "user" } else { "global" };
log::error!("Failed to load {} settings: {err}", settings_type);
}
let error = match result.parse_status {
settings::ParseStatus::Failed { error } => Some(anyhow::format_err!(error)),
settings::ParseStatus::Success => None,
settings::ParseStatus::Unchanged => return,
};
let id = NotificationId::Named(format!("failed-to-parse-settings-{is_user}").into());
let showed_parse_error = match error {
Some(error) => {
if let Some(InvalidSettingsError::LocalSettings { .. }) =
error.downcast_ref::<InvalidSettingsError>()
{
false
// Local settings errors are displayed by the projects
} else {
show_app_notification(id, cx, move |cx| {
cx.new(|cx| {
MessageNotification::new(format!("Invalid user settings file\n{error}"), cx)
.primary_message("Open Settings File")
.primary_icon(IconName::Settings)
.primary_on_click(|window, cx| {
window.dispatch_action(
zed_actions::OpenSettingsFile.boxed_clone(),
cx,
);
cx.emit(DismissEvent);
})
})
});
true
}
}
None => {
dismiss_app_notification(&id, cx);
false
}
};
let id = NotificationId::Named(format!("failed-to-migrate-settings-{is_user}").into());
match result.migration_status {
settings::MigrationStatus::Succeeded | settings::MigrationStatus::NotNeeded => {
dismiss_app_notification(&id, cx);
}
settings::MigrationStatus::Failed { error: err } => {
if !showed_parse_error {
show_app_notification(id, cx, move |cx| {
cx.new(|cx| {
MessageNotification::new(
format!(
"Failed to migrate settings\n\
{err}"
),
cx,
)
.primary_message("Open Settings File")
.primary_icon(IconName::Settings)
.primary_on_click(|window, cx| {
window.dispatch_action(zed_actions::OpenSettingsFile.boxed_clone(), cx);
cx.emit(DismissEvent);
})
})
});
}
}
};
}
#[derive(Copy, Clone, Debug, settings::RegisterSetting)]
struct CursorHideModeSetting(gpui::CursorHideMode);
impl Settings for CursorHideModeSetting {
fn from_settings(content: &settings::SettingsContent) -> Self {
Self(match content.hide_mouse.unwrap_or_default() {
settings::HideMouseMode::Never => gpui::CursorHideMode::Never,
settings::HideMouseMode::OnTyping => gpui::CursorHideMode::OnTyping,
settings::HideMouseMode::OnTypingAndAction => gpui::CursorHideMode::OnTypingAndAction,
})
}
}
fn init_cursor_hide_mode(cx: &mut App) {
let apply = |cx: &mut App| cx.set_cursor_hide_mode(CursorHideModeSetting::get_global(cx).0);
apply(cx);
cx.observe_global::<SettingsStore>(apply).detach();
}
/// Starts watching `~/.config/zed/AGENTS.md` (or the platform equivalent) and
/// surfaces any read errors using the same notification UI as settings errors.
///
/// The file itself is loaded into [`agent_settings::UserAgentsMd`] for inclusion
/// in prompts.
pub fn watch_user_agents_md(fs: Arc<dyn fs::Fs>, cx: &mut App) {
struct UserAgentsMdParseError;
let notification_id = NotificationId::unique::<UserAgentsMdParseError>();
init_user_agents_md(fs, cx, move |state, cx| match state {
UserAgentsMdState::Loaded(_) | UserAgentsMdState::Empty => {
dismiss_app_notification(&notification_id, cx);
}
UserAgentsMdState::Error(message) => {
let path = paths::agents_file().display().to_string();
log::error!("Failed to load user AGENTS.md from {path}: {message}");
let body = format!("Failed to load {path}\n{message}");
let notification_id = notification_id.clone();
show_app_notification(notification_id, cx, move |cx| {
let body = body.clone();
cx.new(|cx| MessageNotification::new(body, cx))
});
}
});
}
pub fn watch_settings_files(fs: Arc<dyn fs::Fs>, cx: &mut App) {
MigrationNotification::set_global(cx.new(|_| MigrationNotification), cx);
SettingsStore::update_global(cx, move |store, cx| {
store.watch_settings_files(fs, cx, |settings_file, result, cx| {
let is_user = matches!(settings_file, SettingsFile::User);
let migrating_in_memory =
matches!(&result.migration_status, MigrationStatus::Succeeded);
notify_settings_errors(result, is_user, cx);
if let Some(notifier) = MigrationNotification::try_global(cx) {
notifier.update(cx, |_, cx| {
cx.emit(MigrationEvent::ContentChanged {
migration_type: MigrationType::Settings,
migrating_in_memory,
});
});
}
});
});
}
pub fn handle_keymap_file_changes(
mut user_keymap_file_rx: mpsc::UnboundedReceiver<String>,
user_keymap_watcher: gpui::Task<()>,
cx: &mut App,
) {
let (base_keymap_tx, mut base_keymap_rx) = mpsc::unbounded();
let (keyboard_layout_tx, mut keyboard_layout_rx) = mpsc::unbounded();
let mut old_base_keymap = *BaseKeymap::get_global(cx);
let mut old_vim_enabled = VimModeSetting::get_global(cx).0;
let mut old_helix_enabled = vim_mode_setting::HelixModeSetting::get_global(cx).0;
cx.observe_global::<SettingsStore>(move |cx| {
let new_base_keymap = *BaseKeymap::get_global(cx);
let new_vim_enabled = VimModeSetting::get_global(cx).0;
let new_helix_enabled = vim_mode_setting::HelixModeSetting::get_global(cx).0;
if new_base_keymap != old_base_keymap
|| new_vim_enabled != old_vim_enabled
|| new_helix_enabled != old_helix_enabled
{
old_base_keymap = new_base_keymap;
old_vim_enabled = new_vim_enabled;
old_helix_enabled = new_helix_enabled;
base_keymap_tx.unbounded_send(()).unwrap();
}
})
.detach();
#[cfg(target_os = "windows")]
{
let mut current_layout_id = cx.keyboard_layout().id().to_string();
cx.on_keyboard_layout_change(move |cx| {
let next_layout_id = cx.keyboard_layout().id();
if next_layout_id != current_layout_id {
current_layout_id = next_layout_id.to_string();
keyboard_layout_tx.unbounded_send(()).ok();
}
})
.detach();
}
#[cfg(not(target_os = "windows"))]
{
let mut current_mapping = cx.keyboard_mapper().get_key_equivalents().cloned();
cx.on_keyboard_layout_change(move |cx| {
let next_mapping = cx.keyboard_mapper().get_key_equivalents();
if current_mapping.as_ref() != next_mapping {
current_mapping = next_mapping.cloned();
keyboard_layout_tx.unbounded_send(()).ok();
}
})
.detach();
}
load_default_keymap(cx);
struct KeymapParseErrorNotification;
let notification_id = NotificationId::unique::<KeymapParseErrorNotification>();
cx.spawn(async move |cx| {
let _user_keymap_watcher = user_keymap_watcher;
let mut user_keymap_content = String::new();
let mut migrating_in_memory = false;
loop {
select_biased! {
_ = base_keymap_rx.next() => {},
_ = keyboard_layout_rx.next() => {},
content = user_keymap_file_rx.next() => {
if let Some(content) = content {
if let Ok(Some(migrated_content)) = migrate_keymap(&content) {
user_keymap_content = migrated_content;
migrating_in_memory = true;
} else {
user_keymap_content = content;
migrating_in_memory = false;
}
}
}
};
cx.update(|cx| {
if let Some(notifier) = MigrationNotification::try_global(cx) {
notifier.update(cx, |_, cx| {
cx.emit(MigrationEvent::ContentChanged {
migration_type: MigrationType::Keymap,
migrating_in_memory,
});
});
}
let load_result = KeymapFile::load(&user_keymap_content, cx);
match load_result {
KeymapFileLoadResult::Success { key_bindings } => {
reload_keymaps(cx, key_bindings);
dismiss_app_notification(&notification_id.clone(), cx);
}
KeymapFileLoadResult::SomeFailedToLoad {
key_bindings,
error_message,
} => {
if !key_bindings.is_empty() {
reload_keymaps(cx, key_bindings);
}
show_keymap_file_load_error(notification_id.clone(), error_message, cx);
}
KeymapFileLoadResult::JsonParseFailure { error } => {
show_keymap_file_json_error(notification_id.clone(), &error, cx)
}
}
});
}
})
.detach();
}
fn show_keymap_file_json_error(
notification_id: NotificationId,
error: &anyhow::Error,
cx: &mut App,
) {
let message: SharedString =
format!("JSON parse error in keymap file. Bindings not reloaded.\n\n{error}").into();
show_app_notification(notification_id, cx, move |cx| {
cx.new(|cx| {
MessageNotification::new(message.clone(), cx)
.primary_message("Open Keymap File")
.primary_icon(IconName::Settings)
.primary_on_click(|window, cx| {
window.dispatch_action(zed_actions::OpenKeymapFile.boxed_clone(), cx);
cx.emit(DismissEvent);
})
})
});
}
fn show_keymap_file_load_error(
notification_id: NotificationId,
error_message: MarkdownString,
cx: &mut App,
) {
show_markdown_app_notification(
notification_id,
error_message,
"Open Keymap File".into(),
|window, cx| {
window.dispatch_action(zed_actions::OpenKeymapFile.boxed_clone(), cx);
cx.emit(DismissEvent);
},
cx,
)
}
fn show_markdown_app_notification<F>(
notification_id: NotificationId,
message: MarkdownString,
primary_button_message: SharedString,
primary_button_on_click: F,
cx: &mut App,
) where
F: 'static + Send + Sync + Fn(&mut Window, &mut Context<MessageNotification>),
{
let markdown = cx.new(|cx| Markdown::new(message.0.into(), None, None, cx));
let primary_button_on_click = Arc::new(primary_button_on_click);
show_app_notification(notification_id, cx, move |cx| {
let markdown = markdown.clone();
let primary_button_message = primary_button_message.clone();
let primary_button_on_click = primary_button_on_click.clone();
cx.new(move |cx| {
MessageNotification::new_from_builder(cx, move |window, cx| {
image_cache(retain_all("notification-cache"))
.child(div().text_ui(cx).child(MarkdownElement::new(
markdown.clone(),
MarkdownStyle::themed(MarkdownFont::Editor, window, cx),
)))
.into_any()
})
.primary_message(primary_button_message)
.primary_icon(IconName::Settings)
.primary_on_click_arc(primary_button_on_click)
})
})
}
fn reload_keymaps(cx: &mut App, mut user_key_bindings: Vec<KeyBinding>) {
cx.clear_key_bindings();
load_default_keymap(cx);
for key_binding in &mut user_key_bindings {
key_binding.set_meta(KeybindSource::User.meta());
}
cx.bind_keys(user_key_bindings);
let menus = app_menus(cx);
cx.set_menus(menus);
// On Windows, this is set in the `update_jump_list` method of the `HistoryManager`.
#[cfg(not(target_os = "windows"))]
cx.set_dock_menu(vec![gpui::MenuItem::action(
"New Window",
workspace::NewWindow,
)]);
// todo: nicer api here?
keymap_editor::KeymapEventChannel::trigger_keymap_changed(cx);
}
pub fn load_default_keymap(cx: &mut App) {
let base_keymap = *BaseKeymap::get_global(cx);
if base_keymap == BaseKeymap::None {
return;
}
cx.bind_keys(
KeymapFile::load_asset(DEFAULT_KEYMAP_PATH, Some(KeybindSource::Default), cx).unwrap(),
);
if let Some(asset_path) = base_keymap.asset_path() {
cx.bind_keys(KeymapFile::load_asset(asset_path, Some(KeybindSource::Base), cx).unwrap());
}
if VimModeSetting::get_global(cx).0 || vim_mode_setting::HelixModeSetting::get_global(cx).0 {
cx.bind_keys(
KeymapFile::load_asset(VIM_KEYMAP_PATH, Some(KeybindSource::Vim), cx).unwrap(),
);
}
}
pub fn open_new_ssh_project_from_project(
workspace: &mut Workspace,
paths: Vec<PathBuf>,
window: &mut Window,
cx: &mut Context<Workspace>,
) -> Task<anyhow::Result<()>> {
let app_state = workspace.app_state().clone();
let Some(ssh_client) = workspace.project().read(cx).remote_client() else {
return Task::ready(Err(anyhow::anyhow!("Not an ssh project")));
};
let connection_options = ssh_client.read(cx).connection_options();
cx.spawn_in(window, async move |_, cx| {
open_remote_project(
connection_options,
paths,
app_state,
workspace::OpenOptions {
workspace_matching: workspace::WorkspaceMatching::None,
..Default::default()
},
cx,
)
.await
.map(|_| ())
})
}
fn open_project_settings_file(
workspace: &mut Workspace,
_: &OpenProjectSettingsFile,
window: &mut Window,
cx: &mut Context<Workspace>,
) {
open_local_file(
workspace,
local_settings_file_relative_path(),
initial_project_settings_content(),
window,
cx,
)
}
fn open_project_tasks_file(
workspace: &mut Workspace,
_: &OpenProjectTasks,
window: &mut Window,
cx: &mut Context<Workspace>,
) {
open_local_file(
workspace,
local_tasks_file_relative_path(),
initial_tasks_content(),
window,
cx,
)
}
fn open_project_debug_tasks_file(
workspace: &mut Workspace,
_: &zed_actions::OpenProjectDebugTasks,
window: &mut Window,
cx: &mut Context<Workspace>,
) {
open_local_file(
workspace,
local_debug_file_relative_path(),
initial_local_debug_tasks_content(),
window,
cx,
)
}
fn open_local_file(
workspace: &mut Workspace,
settings_relative_path: &'static RelPath,
initial_contents: Cow<'static, str>,
window: &mut Window,
cx: &mut Context<Workspace>,
) {
let project = workspace.project().clone();
let worktree = project
.read(cx)
.visible_worktrees(cx)
.find_map(|tree| tree.read(cx).root_entry()?.is_dir().then_some(tree));
if let Some(worktree) = worktree {
let tree_id = worktree.read(cx).id();
cx.spawn_in(window, async move |workspace, cx| {
// Check if the file actually exists on disk (even if it's excluded from worktree)
let file_exists = {
let full_path = worktree.read_with(cx, |tree, _| {
tree.abs_path().join(settings_relative_path.as_std_path())
});
let fs = project.read_with(cx, |project, _| project.fs().clone());
fs.metadata(&full_path)
.await
.ok()
.flatten()
.is_some_and(|metadata| !metadata.is_dir && !metadata.is_fifo)
};
if !file_exists {
if let Some(dir_path) = settings_relative_path.parent()
&& worktree.read_with(cx, |tree, _| tree.entry_for_path(dir_path).is_none())
{
project
.update(cx, |project, cx| {
project.create_entry((tree_id, dir_path), true, cx)
})
.await
.context("worktree was removed")?;
}
if worktree.read_with(cx, |tree, _| {
tree.entry_for_path(settings_relative_path).is_none()
}) {
project
.update(cx, |project, cx| {
project.create_entry((tree_id, settings_relative_path), false, cx)
})
.await
.context("worktree was removed")?;
}
}
let editor = workspace
.update_in(cx, |workspace, window, cx| {
workspace.open_path((tree_id, settings_relative_path), None, true, window, cx)
})?
.await?
.downcast::<Editor>()
.context("unexpected item type: expected editor item")?;
editor
.downgrade()
.update(cx, |editor, cx| {
if let Some(buffer) = editor.buffer().read(cx).as_singleton()
&& buffer.read(cx).is_empty()
{
buffer.update(cx, |buffer, cx| {
buffer.edit([(0..0, initial_contents)], None, cx)
});
}
})
.ok();
anyhow::Ok(())
})
.detach();
} else {
struct NoOpenFolders;
workspace.show_notification(NotificationId::unique::<NoOpenFolders>(), cx, |cx| {
cx.new(|cx| MessageNotification::new("This project has no folders open.", cx))
})
}
}
fn open_bundled_file(
workspace: &mut Workspace,
text: Cow<'static, str>,
title: &'static str,
language: &'static str,
window: &mut Window,
cx: &mut Context<Workspace>,
) {
let existing = workspace.items_of_type::<Editor>(cx).find(|editor| {
editor.read_with(cx, |editor, cx| {
editor.read_only(cx)
&& editor.title(cx).as_ref() == title
&& editor
.buffer()
.read(cx)
.as_singleton()
.is_some_and(|buffer| buffer.read(cx).file().is_none())
})
});
if let Some(existing) = existing {
workspace.activate_item(&existing, true, true, window, cx);
return;
}
let language = workspace.app_state().languages.language_for_name(language);
cx.spawn_in(window, async move |workspace, cx| {
let language = language.await.log_err();
workspace
.update_in(cx, move |workspace, window, cx| {
let project = workspace.project().clone();
let buffer = project.update(cx, move |project, cx| {
project.create_buffer(language, false, cx)
});
cx.spawn_in(window, async move |workspace, cx| {
let buffer = buffer.await?;
buffer.update(cx, |buffer, cx| {
buffer.set_text(text.into_owned(), cx);
buffer.set_capability(Capability::ReadOnly, cx);
});
let buffer =
cx.new(|cx| MultiBuffer::singleton(buffer, cx).with_title(title.into()));
workspace.update_in(cx, |workspace, window, cx| {
workspace.add_item_to_active_pane(
Box::new(cx.new(|cx| {
let mut editor = Editor::for_multibuffer(
buffer,
Some(project.clone()),
window,
cx,
);
editor.set_read_only(true);
editor.set_should_serialize(false, cx);
editor.set_breadcrumb_header(title.into());
editor
})),
None,
true,
window,
cx,
)
})
})
})?
.await
})
.detach_and_log_err(cx);
}
fn open_settings_file(
abs_path: &'static Path,
default_content: impl FnOnce() -> Rope + Send + 'static,
window: &mut Window,
cx: &mut Context<Workspace>,
) {
cx.spawn_in(window, async move |workspace, cx| {
workspace
.update_in(cx, |workspace, window, cx| {
workspace.with_local_or_wsl_workspace(window, cx, move |workspace, window, cx| {
let project = workspace.project().clone();
cx.spawn_in(window, async move |workspace, cx| {
let config_dir = project
.update(cx, |project, cx| {
project.try_windows_path_to_wsl(paths::config_dir().as_path(), cx)
})
.await?;
// Set up a dedicated worktree for settings, since
// otherwise we're dropping and re-starting LSP servers
// for each file inside on every settings file
// close/open
// TODO: Do note that all other external files (e.g.
// drag and drop from OS) still have their worktrees
// released on file close, causing LSP servers'
// restarts.
let (_worktree, _) = project
.update(cx, |project, cx| {
project.find_or_create_worktree(&config_dir, false, cx)
})
.await?;
workspace
.update_in(cx, |_, window, cx| {
create_and_open_local_file(abs_path, window, cx, default_content)
})?
.await?;
anyhow::Ok(())
})
})
})?
.await?
.await?;
anyhow::Ok(())
})
.detach_and_log_err(cx);
}
/// Eagerly loads the active theme and icon theme based on the selections in the
/// theme settings.
///
/// This fast path exists to load these themes as soon as possible so the user
/// doesn't see the default themes while waiting on extensions to load.
pub(crate) fn eager_load_active_theme_and_icon_theme(fs: Arc<dyn Fs>, cx: &mut App) {
let extension_store = ExtensionStore::global(cx);
let theme_registry = ThemeRegistry::global(cx);
let theme_settings = ThemeSettings::get_global(cx);
let appearance = SystemAppearance::global(cx).0;
enum LoadTarget {
Theme(PathBuf),
IconTheme((PathBuf, PathBuf)),
}
let theme_name = theme_settings.theme.name(appearance);
let icon_theme_name = theme_settings.icon_theme.name(appearance);
let themes_to_load = [
theme_registry
.get(&theme_name.0)
.is_err()
.then(|| {
extension_store
.read(cx)
.path_to_extension_theme(&theme_name.0)
})
.flatten()
.map(LoadTarget::Theme),
theme_registry
.get_icon_theme(&icon_theme_name.0)
.is_err()
.then(|| {
extension_store
.read(cx)
.path_to_extension_icon_theme(&icon_theme_name.0)
})
.flatten()
.map(LoadTarget::IconTheme),
];
enum ReloadTarget {
Theme,
IconTheme,
}
let executor = cx.background_executor();
let reload_tasks = parking_lot::Mutex::new(Vec::with_capacity(themes_to_load.len()));
let mut themes_to_load = themes_to_load.into_iter().flatten().peekable();
if themes_to_load.peek().is_none() {
return;
}
cx.foreground_executor().block_on(executor.scoped(|scope| {
for load_target in themes_to_load {
let theme_registry = &theme_registry;
let reload_tasks = &reload_tasks;
let fs = fs.clone();
scope.spawn(async move {
match load_target {
LoadTarget::Theme(theme_path) => {
if let Some(bytes) = fs.load_bytes(&theme_path).await.log_err()
&& load_user_theme(theme_registry, &bytes).log_err().is_some()
{
reload_tasks.lock().push(ReloadTarget::Theme);
}
}
LoadTarget::IconTheme((icon_theme_path, icons_root_path)) => {
if let Some(bytes) = fs.load_bytes(&icon_theme_path).await.log_err()
&& let Some(icon_theme_family) =
deserialize_icon_theme(&bytes).log_err()
&& theme_registry
.load_icon_theme(icon_theme_family, &icons_root_path)
.log_err()
.is_some()
{
reload_tasks.lock().push(ReloadTarget::IconTheme);
}
}
}
});
}
}));
for reload_target in reload_tasks.into_inner() {
match reload_target {
ReloadTarget::Theme => theme_settings::reload_theme(cx),
ReloadTarget::IconTheme => theme_settings::reload_icon_theme(cx),
};
}
}
#[cfg(test)]
mod tests {
use super::*;
use assets::Assets;
use collections::HashSet;
use editor::{
DisplayPoint, Editor, MultiBufferOffset, SelectionEffects, display_map::DisplayRow,
};
use gpui::{
Action, AnyWindowHandle, App, AssetSource, BorrowAppContext, Modifiers, TestAppContext,
UpdateGlobal, VisualTestContext, WindowHandle, actions, point, px,
};
use language::LanguageRegistry;
use languages::{markdown_lang, rust_lang};
use pretty_assertions::{assert_eq, assert_ne};
use project::{Project, ProjectPath};
use prompt_store::PromptBuilder;
use semver::Version;
use serde_json::json;
use settings::{SaturatingBool, SettingsStore, watch_config_file};
use std::{
path::{Path, PathBuf},
sync::Arc,
time::Duration,
};
use theme::ThemeRegistry;
use util::{
path,
rel_path::{RelPath, rel_path},
};
use workspace::MultiWorkspace;
use workspace::{
NewFile, OpenOptions, OpenVisible, SERIALIZATION_THROTTLE_TIME, SaveIntent, SplitDirection,
WorkspaceHandle,
item::SaveOptions,
item::{Item, ItemHandle},
open_new, open_paths, pane,
};
async fn flush_workspace_serialization(
window: &WindowHandle<MultiWorkspace>,
cx: &mut TestAppContext,
) {
let all_tasks = window
.update(cx, |multi_workspace, window, cx| {
let mut tasks = multi_workspace
.workspaces()
.map(|workspace| {
workspace.update(cx, |workspace, cx| {
workspace.flush_serialization(window, cx)
})
})
.collect::<Vec<_>>();
tasks.push(multi_workspace.flush_serialization());
tasks
})
.unwrap();
futures::future::join_all(all_tasks).await;
}
#[gpui::test]
async fn test_open_non_existing_file(cx: &mut TestAppContext) {
let app_state = init_test(cx);
app_state
.fs
.as_fake()
.insert_tree(
path!("/root"),
json!({
"a": {
},
}),
)
.await;
cx.update(|cx| {
open_paths(
&[PathBuf::from(path!("/root/a/new"))],
app_state.clone(),
workspace::OpenOptions::default(),
cx,
)
})
.await
.unwrap();
assert_eq!(cx.read(|cx| cx.windows().len()), 1);
let multi_workspace = cx.windows()[0].downcast::<MultiWorkspace>().unwrap();
multi_workspace
.update(cx, |multi_workspace, _, cx| {
multi_workspace.workspace().update(cx, |workspace, cx| {
assert!(workspace.active_item_as::<Editor>(cx).is_some())
});
})
.unwrap();
}
#[gpui::test]
async fn test_open_paths_action(cx: &mut TestAppContext) {
let app_state = init_test(cx);
app_state
.fs
.as_fake()
.insert_tree(
path!("/root"),
json!({
"a": {
"aa": null,
"ab": null,
},
"b": {
"ba": null,
"bb": null,
},
"c": {
"ca": null,
"cb": null,
},
"d": {
"da": null,
"db": null,
},
"e": {
"ea": null,
"eb": null,
}
}),
)
.await;
cx.update(|cx| {
open_paths(
&[
PathBuf::from(path!("/root/a")),
PathBuf::from(path!("/root/b")),
],
app_state.clone(),
workspace::OpenOptions::default(),
cx,
)
})
.await
.unwrap();
assert_eq!(cx.read(|cx| cx.windows().len()), 1);
cx.update(|cx| {
open_paths(
&[PathBuf::from(path!("/root/a"))],
app_state.clone(),
workspace::OpenOptions::default(),
cx,
)
})
.await
.unwrap();
assert_eq!(cx.read(|cx| cx.windows().len()), 1);
let multi_workspace_1 = cx
.read(|cx| cx.windows()[0].downcast::<MultiWorkspace>())
.unwrap();
cx.run_until_parked();
multi_workspace_1
.update(cx, |multi_workspace, window, cx| {
multi_workspace.workspace().update(cx, |workspace, cx| {
assert_eq!(workspace.worktrees(cx).count(), 2);
assert!(workspace.right_dock().read(cx).is_open());
assert!(
workspace
.active_pane()
.read(cx)
.focus_handle(cx)
.is_focused(window)
);
});
})
.unwrap();
cx.update(|cx| {
open_paths(
&[
PathBuf::from(path!("/root/c")),
PathBuf::from(path!("/root/d")),
],
app_state.clone(),
workspace::OpenOptions::default(),
cx,
)
})
.await
.unwrap();
assert_eq!(cx.read(|cx| cx.windows().len()), 1);
cx.run_until_parked();
multi_workspace_1
.update(cx, |multi_workspace, _window, cx| {
assert_eq!(multi_workspace.workspaces().count(), 2);
assert!(multi_workspace.sidebar_open());
let workspace = multi_workspace.workspace().read(cx);
assert_eq!(
workspace
.worktrees(cx)
.map(|w| w.read(cx).abs_path())
.collect::<Vec<_>>(),
&[
Path::new(path!("/root/c")).into(),
Path::new(path!("/root/d")).into(),
]
);
})
.unwrap();
// Opening with -n (reuse_worktrees: false) still creates a new window.
cx.update(|cx| {
open_paths(
&[PathBuf::from(path!("/root/e"))],
app_state,
workspace::OpenOptions {
workspace_matching: workspace::WorkspaceMatching::None,
..Default::default()
},
cx,
)
})
.await
.unwrap();
cx.background_executor.run_until_parked();
assert_eq!(cx.read(|cx| cx.windows().len()), 2);
}
#[gpui::test]
async fn test_open_add_new(cx: &mut TestAppContext) {
let app_state = init_test(cx);
app_state
.fs
.as_fake()
.insert_tree(
path!("/root"),
json!({"a": "hey", "b": "", "dir": {"c": "f"}}),
)
.await;
cx.update(|cx| {
open_paths(
&[PathBuf::from(path!("/root/dir"))],
app_state.clone(),
workspace::OpenOptions::default(),
cx,
)
})
.await
.unwrap();
assert_eq!(cx.update(|cx| cx.windows().len()), 1);
cx.update(|cx| {
open_paths(
&[PathBuf::from(path!("/root/a"))],
app_state.clone(),
workspace::OpenOptions {
workspace_matching: workspace::WorkspaceMatching::MatchSubdirectory,
..Default::default()
},
cx,
)
})
.await
.unwrap();
assert_eq!(cx.update(|cx| cx.windows().len()), 1);
// Opening a file inside the existing worktree with -n creates a new window.
cx.update(|cx| {
open_paths(
&[PathBuf::from(path!("/root/dir/c"))],
app_state.clone(),
workspace::OpenOptions {
workspace_matching: workspace::WorkspaceMatching::None,
..Default::default()
},
cx,
)
})
.await
.unwrap();
assert_eq!(cx.update(|cx| cx.windows().len()), 2);
// Opening a path NOT in any existing worktree with -n creates a new window.
cx.update(|cx| {
open_paths(
&[PathBuf::from(path!("/root/b"))],
app_state.clone(),
workspace::OpenOptions {
workspace_matching: workspace::WorkspaceMatching::None,
..Default::default()
},
cx,
)
})
.await
.unwrap();
assert_eq!(cx.update(|cx| cx.windows().len()), 3);
}
#[gpui::test]
async fn test_open_file_in_many_spaces(cx: &mut TestAppContext) {
let app_state = init_test(cx);
app_state
.fs
.as_fake()
.insert_tree(
path!("/root"),
json!({"dir1": {"a": "b"}, "dir2": {"c": "d"}}),
)
.await;
cx.update(|cx| {
open_paths(
&[PathBuf::from(path!("/root/dir1/a"))],
app_state.clone(),
workspace::OpenOptions::default(),
cx,
)
})
.await
.unwrap();
assert_eq!(cx.update(|cx| cx.windows().len()), 1);
cx.update(|cx| {
open_paths(
&[PathBuf::from(path!("/root/dir2/c"))],
app_state.clone(),
workspace::OpenOptions::default(),
cx,
)
})
.await
.unwrap();
assert_eq!(cx.update(|cx| cx.windows().len()), 1);
// Opening a directory with default options adds to the existing window
// rather than creating a new one.
cx.update(|cx| {
open_paths(
&[PathBuf::from(path!("/root/dir2"))],
app_state.clone(),
workspace::OpenOptions::default(),
cx,
)
})
.await
.unwrap();
assert_eq!(cx.update(|cx| cx.windows().len()), 1);
// Opening a directory already in a worktree with -n creates a new window.
cx.update(|cx| {
open_paths(
&[PathBuf::from(path!("/root/dir2"))],
app_state.clone(),
workspace::OpenOptions {
workspace_matching: workspace::WorkspaceMatching::None,
..Default::default()
},
cx,
)
})
.await
.unwrap();
assert_eq!(cx.update(|cx| cx.windows().len()), 2);
// Opening a directory NOT in any worktree with -n creates a new window.
cx.update(|cx| {
open_paths(
&[PathBuf::from(path!("/root"))],
app_state.clone(),
workspace::OpenOptions {
workspace_matching: workspace::WorkspaceMatching::None,
..Default::default()
},
cx,
)
})
.await
.unwrap();
assert_eq!(cx.update(|cx| cx.windows().len()), 3);
}
#[gpui::test]
async fn test_window_edit_state_restoring_disabled(cx: &mut TestAppContext) {
let executor = cx.executor();
let app_state = init_test(cx);
cx.update(|cx| {
SettingsStore::update_global(cx, |store, cx| {
store.update_user_settings(cx, |settings| {
settings
.session
.get_or_insert_default()
.restore_unsaved_buffers = Some(false)
});
});
});
app_state
.fs
.as_fake()
.insert_tree(path!("/root"), json!({"a": "hey"}))
.await;
cx.update(|cx| {
open_paths(
&[PathBuf::from(path!("/root/a"))],
app_state.clone(),
workspace::OpenOptions::default(),
cx,
)
})
.await
.unwrap();
assert_eq!(cx.update(|cx| cx.windows().len()), 1);
// When opening the workspace, the window is not in a edited state.
let window = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
let window_is_edited = |window: WindowHandle<MultiWorkspace>, cx: &mut TestAppContext| {
cx.update(|cx| window.read(cx).unwrap().workspace().read(cx).is_edited())
};
let pane = window
.read_with(cx, |multi_workspace, cx| {
multi_workspace.workspace().read(cx).active_pane().clone()
})
.unwrap();
let editor = window
.read_with(cx, |multi_workspace, cx| {
multi_workspace
.workspace()
.read(cx)
.active_item(cx)
.unwrap()
.downcast::<Editor>()
.unwrap()
})
.unwrap();
assert!(!window_is_edited(window, cx));
// Editing a buffer marks the window as edited.
window
.update(cx, |_, window, cx| {
editor.update(cx, |editor, cx| editor.insert("EDIT", window, cx));
})
.unwrap();
assert!(window_is_edited(window, cx));
// Undoing the edit restores the window's edited state.
window
.update(cx, |_, window, cx| {
editor.update(cx, |editor, cx| {
editor.undo(&Default::default(), window, cx)
});
})
.unwrap();
assert!(!window_is_edited(window, cx));
// Redoing the edit marks the window as edited again.
window
.update(cx, |_, window, cx| {
editor.update(cx, |editor, cx| {
editor.redo(&Default::default(), window, cx)
});
})
.unwrap();
assert!(window_is_edited(window, cx));
let weak = editor.downgrade();
// Closing the item restores the window's edited state.
let close = window
.update(cx, |_, window, cx| {
pane.update(cx, |pane, cx| {
drop(editor);
pane.close_active_item(&Default::default(), window, cx)
})
})
.unwrap();
executor.run_until_parked();
cx.simulate_prompt_answer("Don't Save");
close.await.unwrap();
// Advance the clock to ensure that the item has been serialized and dropped from the queue
cx.executor().advance_clock(Duration::from_secs(1));
weak.assert_released();
assert!(!window_is_edited(window, cx));
// Opening the buffer again doesn't impact the window's edited state.
cx.update(|cx| {
open_paths(
&[PathBuf::from(path!("/root/a"))],
app_state,
workspace::OpenOptions::default(),
cx,
)
})
.await
.unwrap();
executor.run_until_parked();
window
.update(cx, |multi_workspace, _, cx| {
multi_workspace.workspace().update(cx, |workspace, cx| {
let editor = workspace
.active_item(cx)
.unwrap()
.downcast::<Editor>()
.unwrap();
editor.update(cx, |editor, cx| {
assert_eq!(editor.text(cx), "hey");
});
});
})
.unwrap();
let editor = window
.read_with(cx, |multi_workspace, cx| {
multi_workspace
.workspace()
.read(cx)
.active_item(cx)
.unwrap()
.downcast::<Editor>()
.unwrap()
})
.unwrap();
assert!(!window_is_edited(window, cx));
// Editing the buffer marks the window as edited.
window
.update(cx, |_, window, cx| {
editor.update(cx, |editor, cx| editor.insert("EDIT", window, cx));
})
.unwrap();
executor.run_until_parked();
assert!(window_is_edited(window, cx));
// Ensure closing the window via the mouse gets preempted due to the
// buffer having unsaved changes.
assert!(!VisualTestContext::from_window(window.into(), cx).simulate_close());
executor.run_until_parked();
assert_eq!(cx.update(|cx| cx.windows().len()), 1);
// The window is successfully closed after the user dismisses the prompt.
cx.simulate_prompt_answer("Don't Save");
executor.run_until_parked();
assert_eq!(cx.update(|cx| cx.windows().len()), 0);
}
#[ignore = "This test has timing issues across platforms."]
#[gpui::test]
async fn test_window_edit_state_restoring_enabled(cx: &mut TestAppContext) {
let app_state = init_test(cx);
app_state
.fs
.as_fake()
.insert_tree(path!("/root"), json!({"a": "hey"}))
.await;
cx.update(|cx| {
open_paths(
&[PathBuf::from(path!("/root/a"))],
app_state.clone(),
workspace::OpenOptions::default(),
cx,
)
})
.await
.unwrap();
assert_eq!(cx.update(|cx| cx.windows().len()), 1);
// When opening the workspace, the window is not in a edited state.
let window = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
let window_is_edited = |window: WindowHandle<MultiWorkspace>, cx: &mut TestAppContext| {
cx.update(|cx| window.read(cx).unwrap().workspace().read(cx).is_edited())
};
let workspace_database_id = |window: WindowHandle<MultiWorkspace>,
cx: &mut TestAppContext| {
cx.update(|cx| window.read(cx).unwrap().workspace().read(cx).database_id())
};
let editor = window
.read_with(cx, |multi_workspace, cx| {
multi_workspace
.workspace()
.read(cx)
.active_item(cx)
.unwrap()
.downcast::<Editor>()
.unwrap()
})
.unwrap();
assert!(!window_is_edited(window, cx));
let initial_database_id = workspace_database_id(window, cx);
assert!(
initial_database_id.is_some(),
"a restored workspace must have a stable database id"
);
// Editing a buffer marks the window as edited.
window
.update(cx, |_, window, cx| {
editor.update(cx, |editor, cx| editor.insert("EDIT", window, cx));
})
.unwrap();
cx.run_until_parked();
assert!(window_is_edited(window, cx));
// Advance the clock to make sure the workspace is serialized
cx.executor().advance_clock(Duration::from_secs(1));
// When closing the window, no prompt shows up and the window is closed.
// buffer having unsaved changes.
assert!(!VisualTestContext::from_window(window.into(), cx).simulate_close());
cx.run_until_parked();
assert_eq!(cx.update(|cx| cx.windows().len()), 0);
// When we now reopen the window, the edited state and the edited buffer are back
cx.update(|cx| {
open_paths(
&[PathBuf::from(path!("/root/a"))],
app_state.clone(),
workspace::OpenOptions::default(),
cx,
)
})
.await
.unwrap();
assert_eq!(cx.update(|cx| cx.windows().len()), 1);
assert!(cx.update(|cx| cx.active_window().is_some()));
cx.run_until_parked();
// When opening the workspace, the window is not in a edited state.
let window = cx.update(|cx| {
cx.active_window()
.unwrap()
.downcast::<MultiWorkspace>()
.unwrap()
});
assert!(window_is_edited(window, cx));
assert_eq!(
workspace_database_id(window, cx),
initial_database_id,
"the workspace must keep the same database id across a close/reopen cycle"
);
window
.update(cx, |multi_workspace, _, cx| {
multi_workspace.workspace().update(cx, |workspace, cx| {
let editor = workspace
.active_item(cx)
.unwrap()
.downcast::<editor::Editor>()
.unwrap();
editor.update(cx, |editor, cx| {
assert_eq!(editor.text(cx), "EDIThey");
assert!(editor.is_dirty(cx));
});
});
})
.unwrap();
}
#[gpui::test]
async fn test_new_empty_workspace(cx: &mut TestAppContext) {
let app_state = init_test(cx);
cx.update(|cx| {
open_new(
Default::default(),
app_state.clone(),
cx,
|workspace, window, cx| {
Editor::new_file(workspace, &Default::default(), window, cx)
},
)
})
.await
.unwrap();
cx.run_until_parked();
let multi_workspace = cx
.update(|cx| cx.windows().first().unwrap().downcast::<MultiWorkspace>())
.unwrap();
let editor = multi_workspace
.update(cx, |multi_workspace, _, cx| {
multi_workspace.workspace().update(cx, |workspace, cx| {
let editor = workspace
.active_item(cx)
.unwrap()
.downcast::<editor::Editor>()
.unwrap();
editor.update(cx, |editor, cx| {
assert!(editor.text(cx).is_empty());
assert!(!editor.is_dirty(cx));
});
editor
})
})
.unwrap();
let save_task = multi_workspace
.update(cx, |multi_workspace, window, cx| {
multi_workspace.workspace().update(cx, |workspace, cx| {
workspace.save_active_item(SaveIntent::Save, window, cx)
})
})
.unwrap();
app_state.fs.create_dir(Path::new("/root")).await.unwrap();
cx.background_executor.run_until_parked();
cx.simulate_new_path_selection(|_| Some(PathBuf::from("/root/the-new-name")));
save_task.await.unwrap();
multi_workspace
.update(cx, |_, _, cx| {
editor.update(cx, |editor, cx| {
assert!(!editor.is_dirty(cx));
assert_eq!(editor.title(cx), "the-new-name");
});
})
.unwrap();
}
#[gpui::test]
async fn test_open_entry(cx: &mut TestAppContext) {
let app_state = init_test(cx);
app_state
.fs
.as_fake()
.insert_tree(
path!("/root"),
json!({
"a": {
"file1": "contents 1",
"file2": "contents 2",
"file3": "contents 3",
},
}),
)
.await;
let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
project.update(cx, |project, _cx| project.languages().add(markdown_lang()));
let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx));
let workspace = window
.read_with(cx, |mw, _| mw.workspace().clone())
.unwrap();
let entries = cx.read(|cx| workspace.file_project_paths(cx));
let file1 = entries[0].clone();
let file2 = entries[1].clone();
let file3 = entries[2].clone();
// Open the first entry
let entry_1 = window
.update(cx, |_, window, cx| {
workspace.update(cx, |w, cx| {
w.open_path(file1.clone(), None, true, window, cx)
})
})
.unwrap()
.await
.unwrap();
cx.read(|cx| {
let pane = workspace.read(cx).active_pane().read(cx);
assert_eq!(
pane.active_item().unwrap().project_path(cx),
Some(file1.clone())
);
assert_eq!(pane.items_len(), 1);
});
// Open the second entry
window
.update(cx, |_, window, cx| {
workspace.update(cx, |w, cx| {
w.open_path(file2.clone(), None, true, window, cx)
})
})
.unwrap()
.await
.unwrap();
cx.read(|cx| {
let pane = workspace.read(cx).active_pane().read(cx);
assert_eq!(
pane.active_item().unwrap().project_path(cx),
Some(file2.clone())
);
assert_eq!(pane.items_len(), 2);
});
// Open the first entry again. The existing pane item is activated.
let entry_1b = window
.update(cx, |_, window, cx| {
workspace.update(cx, |w, cx| {
w.open_path(file1.clone(), None, true, window, cx)
})
})
.unwrap()
.await
.unwrap();
assert_eq!(entry_1.item_id(), entry_1b.item_id());
cx.read(|cx| {
let pane = workspace.read(cx).active_pane().read(cx);
assert_eq!(
pane.active_item().unwrap().project_path(cx),
Some(file1.clone())
);
assert_eq!(pane.items_len(), 2);
});
// Split the pane with the first entry, then open the second entry again.
window
.update(cx, |_, window, cx| {
workspace.update(cx, |w, cx| {
w.split_and_clone(w.active_pane().clone(), SplitDirection::Right, window, cx)
})
})
.unwrap()
.await
.unwrap();
window
.update(cx, |_, window, cx| {
workspace.update(cx, |w, cx| {
w.open_path(file2.clone(), None, true, window, cx)
})
})
.unwrap()
.await
.unwrap();
cx.read(|cx| {
assert_eq!(
workspace
.read(cx)
.active_pane()
.read(cx)
.active_item()
.unwrap()
.project_path(cx),
Some(file2.clone())
);
});
// Open the third entry twice concurrently. Only one pane item is added.
let (t1, t2) = window
.update(cx, |_, window, cx| {
workspace.update(cx, |w, cx| {
(
w.open_path(file3.clone(), None, true, window, cx),
w.open_path(file3.clone(), None, true, window, cx),
)
})
})
.unwrap();
t1.await.unwrap();
t2.await.unwrap();
cx.read(|cx| {
let pane = workspace.read(cx).active_pane().read(cx);
assert_eq!(
pane.active_item().unwrap().project_path(cx),
Some(file3.clone())
);
let pane_entries = pane
.items()
.map(|i| i.project_path(cx).unwrap())
.collect::<Vec<_>>();
assert_eq!(pane_entries, &[file1, file2, file3]);
});
}
#[gpui::test]
async fn test_open_paths(cx: &mut TestAppContext) {
let app_state = init_test(cx);
app_state
.fs
.as_fake()
.insert_tree(
path!("/"),
json!({
"dir1": {
"a.txt": ""
},
"dir2": {
"b.txt": ""
},
"dir3": {
"c.txt": ""
},
"d.txt": ""
}),
)
.await;
cx.update(|cx| {
open_paths(
&[PathBuf::from(path!("/dir1/"))],
app_state,
workspace::OpenOptions::default(),
cx,
)
})
.await
.unwrap();
cx.run_until_parked();
assert_eq!(cx.update(|cx| cx.windows().len()), 1);
let window = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
let workspace = window
.read_with(cx, |mw, _| mw.workspace().clone())
.unwrap();
#[track_caller]
fn assert_project_panel_selection(
workspace: &Workspace,
expected_worktree_path: &Path,
expected_entry_path: &RelPath,
cx: &App,
) {
let project_panel = [
workspace.left_dock().read(cx).panel::<ProjectPanel>(),
workspace.right_dock().read(cx).panel::<ProjectPanel>(),
workspace.bottom_dock().read(cx).panel::<ProjectPanel>(),
]
.into_iter()
.find_map(std::convert::identity)
.expect("found no project panels")
.read(cx);
let (selected_worktree, selected_entry) = project_panel
.selected_entry(cx)
.expect("project panel should have a selected entry");
assert_eq!(
selected_worktree.abs_path().as_ref(),
expected_worktree_path,
"Unexpected project panel selected worktree path"
);
assert_eq!(
selected_entry.path.as_ref(),
expected_entry_path,
"Unexpected project panel selected entry path"
);
}
// Open a file within an existing worktree.
window
.update(cx, |multi_workspace, window, cx| {
multi_workspace.workspace().update(cx, |workspace, cx| {
workspace.open_paths(
vec![path!("/dir1/a.txt").into()],
OpenOptions {
visible: Some(OpenVisible::All),
..Default::default()
},
None,
window,
cx,
)
})
})
.unwrap()
.await;
cx.run_until_parked();
cx.read(|cx| {
let workspace = workspace.read(cx);
assert_project_panel_selection(
workspace,
Path::new(path!("/dir1")),
rel_path("a.txt"),
cx,
);
assert_eq!(
workspace
.active_pane()
.read(cx)
.active_item()
.unwrap()
.act_as::<Editor>(cx)
.unwrap()
.read(cx)
.title(cx),
"a.txt"
);
});
// Open a file outside of any existing worktree.
window
.update(cx, |multi_workspace, window, cx| {
multi_workspace.workspace().update(cx, |workspace, cx| {
workspace.open_paths(
vec![path!("/dir2/b.txt").into()],
OpenOptions {
visible: Some(OpenVisible::All),
..Default::default()
},
None,
window,
cx,
)
})
})
.unwrap()
.await;
cx.run_until_parked();
cx.read(|cx| {
let workspace = workspace.read(cx);
assert_project_panel_selection(
workspace,
Path::new(path!("/dir2/b.txt")),
rel_path(""),
cx,
);
let worktree_roots = workspace
.worktrees(cx)
.map(|w| w.read(cx).as_local().unwrap().abs_path().as_ref())
.collect::<HashSet<_>>();
assert_eq!(
worktree_roots,
vec![path!("/dir1"), path!("/dir2/b.txt")]
.into_iter()
.map(Path::new)
.collect(),
);
assert_eq!(
workspace
.active_pane()
.read(cx)
.active_item()
.unwrap()
.act_as::<Editor>(cx)
.unwrap()
.read(cx)
.title(cx),
"b.txt"
);
});
// Ensure opening a directory and one of its children only adds one worktree.
window
.update(cx, |multi_workspace, window, cx| {
multi_workspace.workspace().update(cx, |workspace, cx| {
workspace.open_paths(
vec![path!("/dir3").into(), path!("/dir3/c.txt").into()],
OpenOptions {
visible: Some(OpenVisible::All),
..Default::default()
},
None,
window,
cx,
)
})
})
.unwrap()
.await;
cx.run_until_parked();
cx.read(|cx| {
let workspace = workspace.read(cx);
assert_project_panel_selection(
workspace,
Path::new(path!("/dir3")),
rel_path("c.txt"),
cx,
);
let worktree_roots = workspace
.worktrees(cx)
.map(|w| w.read(cx).as_local().unwrap().abs_path().as_ref())
.collect::<HashSet<_>>();
assert_eq!(
worktree_roots,
vec![path!("/dir1"), path!("/dir2/b.txt"), path!("/dir3")]
.into_iter()
.map(Path::new)
.collect(),
);
assert_eq!(
workspace
.active_pane()
.read(cx)
.active_item()
.unwrap()
.act_as::<Editor>(cx)
.unwrap()
.read(cx)
.title(cx),
"c.txt"
);
});
// Ensure opening invisibly a file outside an existing worktree adds a new, invisible worktree.
window
.update(cx, |multi_workspace, window, cx| {
multi_workspace.workspace().update(cx, |workspace, cx| {
workspace.open_paths(
vec![path!("/d.txt").into()],
OpenOptions {
visible: Some(OpenVisible::None),
..Default::default()
},
None,
window,
cx,
)
})
})
.unwrap()
.await;
cx.run_until_parked();
cx.read(|cx| {
let workspace = workspace.read(cx);
assert_project_panel_selection(workspace, Path::new(path!("/d.txt")), rel_path(""), cx);
let worktree_roots = workspace
.worktrees(cx)
.map(|w| w.read(cx).as_local().unwrap().abs_path().as_ref())
.collect::<HashSet<_>>();
assert_eq!(
worktree_roots,
vec![
path!("/dir1"),
path!("/dir2/b.txt"),
path!("/dir3"),
path!("/d.txt")
]
.into_iter()
.map(Path::new)
.collect(),
);
let visible_worktree_roots = workspace
.visible_worktrees(cx)
.map(|w| w.read(cx).as_local().unwrap().abs_path().as_ref())
.collect::<HashSet<_>>();
assert_eq!(
visible_worktree_roots,
vec![path!("/dir1"), path!("/dir2/b.txt"), path!("/dir3")]
.into_iter()
.map(Path::new)
.collect(),
);
assert_eq!(
workspace
.active_pane()
.read(cx)
.active_item()
.unwrap()
.act_as::<Editor>(cx)
.unwrap()
.read(cx)
.title(cx),
"d.txt"
);
});
}
#[gpui::test]
async fn test_opening_excluded_paths(cx: &mut TestAppContext) {
let app_state = init_test(cx);
cx.update(|cx| {
cx.update_global::<SettingsStore, _>(|store, cx| {
store.update_user_settings(cx, |project_settings| {
project_settings.project.worktree.file_scan_exclusions =
Some(vec!["excluded_dir".to_string(), "**/.git".to_string()]);
});
});
});
app_state
.fs
.as_fake()
.insert_tree(
path!("/root"),
json!({
".gitignore": "ignored_dir\n",
".git": {
"HEAD": "ref: refs/heads/main",
},
"regular_dir": {
"file": "regular file contents",
},
"ignored_dir": {
"ignored_subdir": {
"file": "ignored subfile contents",
},
"file": "ignored file contents",
},
"excluded_dir": {
"file": "excluded file contents",
"ignored_subdir": {
"file": "ignored subfile contents",
},
},
}),
)
.await;
let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
project.update(cx, |project, _cx| project.languages().add(markdown_lang()));
let window = cx.add_window({
let project = project.clone();
|window, cx| MultiWorkspace::test_new(project, window, cx)
});
let workspace = window
.read_with(cx, |mw, _| mw.workspace().clone())
.unwrap();
let initial_entries = cx.read(|cx| workspace.file_project_paths(cx));
let paths_to_open = [
PathBuf::from(path!("/root/excluded_dir/file")),
PathBuf::from(path!("/root/.git/HEAD")),
PathBuf::from(path!("/root/excluded_dir/ignored_subdir")),
];
let workspace::OpenResult {
window: opened_workspace,
opened_items: new_items,
..
} = cx
.update(|cx| {
workspace::open_paths(
&paths_to_open,
app_state,
workspace::OpenOptions::default(),
cx,
)
})
.await
.unwrap();
assert_eq!(
opened_workspace
.read_with(cx, |mw, _| mw.workspace().entity_id())
.unwrap(),
workspace.entity_id(),
"Excluded files in subfolders of a workspace root should be opened in the workspace"
);
let mut opened_paths = cx.read(|cx| {
assert_eq!(
new_items.len(),
paths_to_open.len(),
"Expect to get the same number of opened items as submitted paths to open"
);
new_items
.iter()
.zip(paths_to_open.iter())
.map(|(i, path)| {
match i {
Some(Ok(i)) => Some(i.project_path(cx).map(|p| p.path)),
Some(Err(e)) => panic!("Excluded file {path:?} failed to open: {e:?}"),
None => None,
}
.flatten()
})
.collect::<Vec<_>>()
});
opened_paths.sort();
assert_eq!(
opened_paths,
vec![
None,
Some(rel_path(".git/HEAD").into()),
Some(rel_path("excluded_dir/file").into()),
],
"Excluded files should get opened, excluded dir should not get opened"
);
let entries = cx.read(|cx| workspace.file_project_paths(cx));
assert_eq!(
initial_entries, entries,
"Workspace entries should not change after opening excluded files and directories paths"
);
cx.read(|cx| {
let pane = workspace.read(cx).active_pane().read(cx);
let mut opened_buffer_paths = pane
.items()
.map(|i| {
i.project_path(cx)
.expect("all excluded files that got open should have a path")
.path
})
.collect::<Vec<_>>();
opened_buffer_paths.sort();
assert_eq!(
opened_buffer_paths,
vec![rel_path(".git/HEAD").into(), rel_path("excluded_dir/file").into()],
"Despite not being present in the worktrees, buffers for excluded files are opened and added to the pane"
);
});
}
#[gpui::test]
async fn test_save_conflicting_item(cx: &mut TestAppContext) {
let app_state = init_test(cx);
app_state
.fs
.as_fake()
.insert_tree(path!("/root"), json!({ "a.txt": "" }))
.await;
let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
project.update(cx, |project, _cx| project.languages().add(markdown_lang()));
let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx));
let workspace = window
.read_with(cx, |mw, _| mw.workspace().clone())
.unwrap();
// Open a file within an existing worktree.
window
.update(cx, |_, window, cx| {
workspace.update(cx, |workspace, cx| {
workspace.open_paths(
vec![PathBuf::from(path!("/root/a.txt"))],
OpenOptions {
visible: Some(OpenVisible::All),
..Default::default()
},
None,
window,
cx,
)
})
})
.unwrap()
.await;
let editor = cx.read(|cx| {
let pane = workspace.read(cx).active_pane().read(cx);
let item = pane.active_item().unwrap();
item.downcast::<Editor>().unwrap()
});
window
.update(cx, |_, window, cx| {
editor.update(cx, |editor, cx| editor.handle_input("x", window, cx));
})
.unwrap();
app_state
.fs
.as_fake()
.insert_file(path!("/root/a.txt"), b"changed".to_vec())
.await;
cx.run_until_parked();
cx.read(|cx| assert!(editor.is_dirty(cx)));
cx.read(|cx| assert!(editor.has_conflict(cx)));
let save_task = window
.update(cx, |_, window, cx| {
workspace.update(cx, |workspace, cx| {
workspace.save_active_item(SaveIntent::Save, window, cx)
})
})
.unwrap();
cx.background_executor.run_until_parked();
cx.simulate_prompt_answer("Overwrite");
save_task.await.unwrap();
window
.update(cx, |_, _, cx| {
editor.update(cx, |editor, cx| {
assert!(!editor.is_dirty(cx));
assert!(!editor.has_conflict(cx));
});
})
.unwrap();
}
#[gpui::test]
async fn test_open_and_save_new_file(cx: &mut TestAppContext) {
let app_state = init_test(cx);
app_state
.fs
.create_dir(Path::new(path!("/root")))
.await
.unwrap();
let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
project.update(cx, |project, _| {
project.languages().add(markdown_lang());
project.languages().add(rust_lang());
});
let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx));
let workspace = window
.read_with(cx, |mw, _| mw.workspace().clone())
.unwrap();
let worktree = cx.read(|cx| workspace.read(cx).worktrees(cx).next().unwrap());
// Create a new untitled buffer
cx.dispatch_action(window.into(), NewFile);
let editor = cx.read(|cx| {
workspace
.read(cx)
.active_item(cx)
.unwrap()
.downcast::<Editor>()
.unwrap()
});
window
.update(cx, |_, window, cx| {
editor.update(cx, |editor, cx| {
assert!(!editor.is_dirty(cx));
assert_eq!(editor.title(cx), "untitled");
assert!(Arc::ptr_eq(
&editor
.buffer()
.read(cx)
.language_at(MultiBufferOffset(0), cx)
.unwrap(),
&languages::PLAIN_TEXT
));
editor.handle_input("hi", window, cx);
assert!(editor.is_dirty(cx));
});
})
.unwrap();
// Save the buffer. This prompts for a filename.
let save_task = window
.update(cx, |_, window, cx| {
workspace.update(cx, |workspace, cx| {
workspace.save_active_item(SaveIntent::Save, window, cx)
})
})
.unwrap();
cx.background_executor.run_until_parked();
cx.simulate_new_path_selection(|parent_dir| {
assert_eq!(parent_dir, Path::new(path!("/root")));
Some(parent_dir.join("the-new-name.rs"))
});
cx.read(|cx| {
assert!(editor.is_dirty(cx));
assert_eq!(editor.read(cx).title(cx), "hi");
});
// When the save completes, the buffer's title is updated and the language is assigned based
// on the path.
save_task.await.unwrap();
window
.update(cx, |_, _, cx| {
editor.update(cx, |editor, cx| {
assert!(!editor.is_dirty(cx));
assert_eq!(editor.title(cx), "the-new-name.rs");
assert_eq!(
editor
.buffer()
.read(cx)
.language_at(MultiBufferOffset(0), cx)
.unwrap()
.name(),
"Rust"
);
});
})
.unwrap();
// Edit the file and save it again. This time, there is no filename prompt.
window
.update(cx, |_, window, cx| {
editor.update(cx, |editor, cx| {
editor.handle_input(" there", window, cx);
assert!(editor.is_dirty(cx));
});
})
.unwrap();
let save_task = window
.update(cx, |_, window, cx| {
workspace.update(cx, |workspace, cx| {
workspace.save_active_item(SaveIntent::Save, window, cx)
})
})
.unwrap();
save_task.await.unwrap();
assert!(!cx.did_prompt_for_new_path());
window
.update(cx, |_, _, cx| {
editor.update(cx, |editor, cx| {
assert!(!editor.is_dirty(cx));
assert_eq!(editor.title(cx), "the-new-name.rs")
});
})
.unwrap();
// Open the same newly-created file in another pane item. The new editor should reuse
// the same buffer.
cx.dispatch_action(window.into(), NewFile);
window
.update(cx, |_, window, cx| {
workspace.update(cx, |workspace, cx| {
workspace.split_and_clone(
workspace.active_pane().clone(),
SplitDirection::Right,
window,
cx,
)
})
})
.unwrap()
.await
.unwrap();
window
.update(cx, |_, window, cx| {
workspace.update(cx, |workspace, cx| {
workspace.open_path(
(worktree.read(cx).id(), rel_path("the-new-name.rs")),
None,
true,
window,
cx,
)
})
})
.unwrap()
.await
.unwrap();
let editor2 = cx.read(|cx| {
workspace
.read(cx)
.active_item(cx)
.unwrap()
.downcast::<Editor>()
.unwrap()
});
cx.read(|cx| {
assert_eq!(
editor2.read(cx).buffer().read(cx).as_singleton().unwrap(),
editor.read(cx).buffer().read(cx).as_singleton().unwrap()
);
})
}
#[gpui::test]
async fn test_setting_language_when_saving_as_single_file_worktree(cx: &mut TestAppContext) {
let app_state = init_test(cx);
app_state.fs.create_dir(Path::new("/root")).await.unwrap();
let project = Project::test(app_state.fs.clone(), [], cx).await;
project.update(cx, |project, _| {
project.languages().add(language::rust_lang());
project.languages().add(language::markdown_lang());
});
let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx));
let workspace = window
.read_with(cx, |mw, _| mw.workspace().clone())
.unwrap();
// Create a new untitled buffer
cx.dispatch_action(window.into(), NewFile);
let editor = cx.read(|cx| {
workspace
.read(cx)
.active_item(cx)
.unwrap()
.downcast::<Editor>()
.unwrap()
});
window
.update(cx, |_, window, cx| {
editor.update(cx, |editor, cx| {
assert!(Arc::ptr_eq(
&editor
.buffer()
.read(cx)
.language_at(MultiBufferOffset(0), cx)
.unwrap(),
&languages::PLAIN_TEXT
));
editor.handle_input("hi", window, cx);
assert!(editor.is_dirty(cx));
});
})
.unwrap();
// Save the buffer. This prompts for a filename.
let save_task = window
.update(cx, |_, window, cx| {
workspace.update(cx, |workspace, cx| {
workspace.save_active_item(SaveIntent::Save, window, cx)
})
})
.unwrap();
cx.background_executor.run_until_parked();
cx.simulate_new_path_selection(|_| Some(PathBuf::from("/root/the-new-name.rs")));
save_task.await.unwrap();
// The buffer is not dirty anymore and the language is assigned based on the path.
window
.update(cx, |_, _, cx| {
editor.update(cx, |editor, cx| {
assert!(!editor.is_dirty(cx));
assert_eq!(
editor
.buffer()
.read(cx)
.language_at(MultiBufferOffset(0), cx)
.unwrap()
.name(),
"Rust"
)
});
})
.unwrap();
}
#[gpui::test]
async fn test_pane_actions(cx: &mut TestAppContext) {
let app_state = init_test(cx);
app_state
.fs
.as_fake()
.insert_tree(
path!("/root"),
json!({
"a": {
"file1": "contents 1",
"file2": "contents 2",
"file3": "contents 3",
},
}),
)
.await;
let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
project.update(cx, |project, _cx| project.languages().add(markdown_lang()));
let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx));
let workspace = window
.read_with(cx, |mw, _| mw.workspace().clone())
.unwrap();
let cx = &mut VisualTestContext::from_window(*window, cx);
let entries = cx.read(|cx| workspace.file_project_paths(cx));
let file1 = entries[0].clone();
let pane_1 = cx.read(|cx| workspace.read(cx).active_pane().clone());
workspace
.update_in(cx, |w, window, cx| {
w.open_path(file1.clone(), None, true, window, cx)
})
.await
.unwrap();
let (editor_1, buffer) = workspace.update_in(cx, |_, window, cx| {
pane_1.update(cx, |pane_1, cx| {
let editor = pane_1.active_item().unwrap().downcast::<Editor>().unwrap();
assert_eq!(editor.read(cx).active_project_path(cx), Some(file1.clone()));
let buffer = editor.update(cx, |editor, cx| {
editor.insert("dirt", window, cx);
editor.buffer().downgrade()
});
(editor.downgrade(), buffer)
})
});
cx.dispatch_action(pane::SplitRight::default());
let editor_2 = cx.update(|_, cx| {
let pane_2 = workspace.read(cx).active_pane().clone();
assert_ne!(pane_1, pane_2);
let pane2_item = pane_2.read(cx).active_item().unwrap();
assert_eq!(pane2_item.project_path(cx), Some(file1.clone()));
pane2_item.downcast::<Editor>().unwrap().downgrade()
});
cx.dispatch_action(workspace::CloseActiveItem {
save_intent: None,
close_pinned: false,
});
cx.background_executor.run_until_parked();
workspace.read_with(cx, |workspace, _| {
assert_eq!(workspace.panes().len(), 1);
assert_eq!(workspace.active_pane(), &pane_1);
});
cx.dispatch_action(workspace::CloseActiveItem {
save_intent: None,
close_pinned: false,
});
cx.background_executor.run_until_parked();
cx.simulate_prompt_answer("Don't Save");
cx.background_executor.run_until_parked();
workspace.read_with(cx, |workspace, cx| {
assert_eq!(workspace.panes().len(), 1);
assert!(workspace.active_item(cx).is_none());
});
cx.background_executor
.advance_clock(SERIALIZATION_THROTTLE_TIME);
cx.update(|_, _| {});
editor_1.assert_released();
editor_2.assert_released();
buffer.assert_released();
}
#[gpui::test]
async fn test_editor_zoom_with_scroll_wheel(cx: &mut TestAppContext) {
let app_state = init_test(cx);
app_state
.fs
.as_fake()
.insert_tree(path!("/root"), json!({ "file.txt": "hello\nworld\n" }))
.await;
let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
let window =
cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let workspace = window
.read_with(cx, |mw, _| mw.workspace().clone())
.unwrap();
let cx = &mut VisualTestContext::from_window(*window, cx);
let mouse_position = point(px(250.), px(250.));
let event_modifiers = {
#[cfg(target_os = "macos")]
{
Modifiers {
platform: true,
..Modifiers::default()
}
}
#[cfg(not(target_os = "macos"))]
{
Modifiers {
control: true,
..Modifiers::default()
}
}
};
workspace
.update_in(cx, |workspace, window, cx| {
workspace.open_abs_path(
PathBuf::from(path!("/root/file.txt")),
OpenOptions::default(),
window,
cx,
)
})
.await
.unwrap()
.downcast::<Editor>()
.unwrap();
cx.update(|window, cx| {
window.draw(cx).clear();
});
// mouse_wheel_zoom is disabled by default — zoom should not work.
let initial_font_size =
cx.update(|_, cx| ThemeSettings::get_global(cx).buffer_font_size(cx).as_f32());
cx.simulate_event(gpui::ScrollWheelEvent {
position: mouse_position,
delta: gpui::ScrollDelta::Pixels(point(px(0.), px(1.))),
modifiers: event_modifiers,
..Default::default()
});
let font_size_after_disabled_zoom =
cx.update(|_, cx| ThemeSettings::get_global(cx).buffer_font_size(cx).as_f32());
assert_eq!(
initial_font_size, font_size_after_disabled_zoom,
"Editor buffer font-size should not change when mouse_wheel_zoom is disabled"
);
// Enable mouse_wheel_zoom and verify zoom works.
cx.update(|_, cx| {
SettingsStore::update_global(cx, |store, cx| {
store.update_user_settings(cx, |settings| {
settings.editor.mouse_wheel_zoom = Some(true);
});
});
});
cx.update(|window, cx| {
window.draw(cx).clear();
});
cx.simulate_event(gpui::ScrollWheelEvent {
position: mouse_position,
delta: gpui::ScrollDelta::Pixels(point(px(0.), px(1.))),
modifiers: event_modifiers,
..Default::default()
});
let increased_font_size =
cx.update(|_, cx| ThemeSettings::get_global(cx).buffer_font_size(cx).as_f32());
assert!(
increased_font_size > initial_font_size,
"Editor buffer font-size should have increased from scroll-zoom"
);
cx.update(|window, cx| {
window.draw(cx).clear();
});
cx.simulate_event(gpui::ScrollWheelEvent {
position: mouse_position,
delta: gpui::ScrollDelta::Pixels(point(px(0.), px(-1.))),
modifiers: event_modifiers,
..Default::default()
});
let decreased_font_size =
cx.update(|_, cx| ThemeSettings::get_global(cx).buffer_font_size(cx).as_f32());
assert!(
decreased_font_size < increased_font_size,
"Editor buffer font-size should have decreased from scroll-zoom"
);
// Disable mouse_wheel_zoom again and verify zoom stops working.
cx.update(|_, cx| {
SettingsStore::update_global(cx, |store, cx| {
store.update_user_settings(cx, |settings| {
settings.editor.mouse_wheel_zoom = Some(false);
});
});
});
let font_size_before =
cx.update(|_, cx| ThemeSettings::get_global(cx).buffer_font_size(cx).as_f32());
cx.update(|window, cx| {
window.draw(cx).clear();
});
cx.simulate_event(gpui::ScrollWheelEvent {
position: mouse_position,
delta: gpui::ScrollDelta::Pixels(point(px(0.), px(1.))),
modifiers: event_modifiers,
..Default::default()
});
let font_size_after =
cx.update(|_, cx| ThemeSettings::get_global(cx).buffer_font_size(cx).as_f32());
assert_eq!(
font_size_before, font_size_after,
"Editor buffer font-size should not change when mouse_wheel_zoom is re-disabled"
);
}
#[gpui::test]
async fn test_navigation(cx: &mut TestAppContext) {
let app_state = init_test(cx);
app_state
.fs
.as_fake()
.insert_tree(
path!("/root"),
json!({
"a": {
"file1": "contents 1\n".repeat(20),
"file2": "contents 2\n".repeat(20),
"file3": "contents 3\n".repeat(20),
},
}),
)
.await;
let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
project.update(cx, |project, _cx| project.languages().add(markdown_lang()));
let window =
cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let workspace = window
.read_with(cx, |mw, _| mw.workspace().clone())
.unwrap();
let cx = &mut VisualTestContext::from_window(*window, cx);
let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone());
let entries = cx.read(|cx| workspace.file_project_paths(cx));
let file1 = entries[0].clone();
let file2 = entries[1].clone();
let file3 = entries[2].clone();
let editor1 = workspace
.update_in(cx, |w, window, cx| {
w.open_path(file1.clone(), None, true, window, cx)
})
.await
.unwrap()
.downcast::<Editor>()
.unwrap();
workspace.update_in(cx, |_, window, cx| {
editor1.update(cx, |editor, cx| {
editor.change_selections(Default::default(), window, cx, |s| {
s.select_display_ranges([
DisplayPoint::new(DisplayRow(10), 0)..DisplayPoint::new(DisplayRow(10), 0)
])
});
});
});
let editor2 = workspace
.update_in(cx, |w, window, cx| {
w.open_path(file2.clone(), None, true, window, cx)
})
.await
.unwrap()
.downcast::<Editor>()
.unwrap();
let editor3 = workspace
.update_in(cx, |w, window, cx| {
w.open_path(file3.clone(), None, true, window, cx)
})
.await
.unwrap()
.downcast::<Editor>()
.unwrap();
workspace
.update_in(cx, |_, window, cx| {
editor3.update(cx, |editor, cx| {
editor.change_selections(Default::default(), window, cx, |s| {
s.select_display_ranges([DisplayPoint::new(DisplayRow(12), 0)
..DisplayPoint::new(DisplayRow(12), 0)])
});
editor.newline(&Default::default(), window, cx);
editor.newline(&Default::default(), window, cx);
editor.move_down(&Default::default(), window, cx);
editor.move_down(&Default::default(), window, cx);
editor.save(
SaveOptions {
format: true,
force_format: false,
autosave: false,
},
project.clone(),
window,
cx,
)
})
})
.await
.unwrap();
workspace.update_in(cx, |_, window, cx| {
editor3.update(cx, |editor, cx| {
editor.set_scroll_position(point(0., 12.5), window, cx)
});
});
assert_eq!(
active_location(&workspace, cx),
(file3.clone(), DisplayPoint::new(DisplayRow(16), 0), 12.5)
);
workspace
.update_in(cx, |w, window, cx| {
w.go_back(w.active_pane().downgrade(), window, cx)
})
.await
.unwrap();
assert_eq!(
active_location(&workspace, cx),
(file3.clone(), DisplayPoint::new(DisplayRow(0), 0), 0.)
);
workspace
.update_in(cx, |w, window, cx| {
w.go_back(w.active_pane().downgrade(), window, cx)
})
.await
.unwrap();
assert_eq!(
active_location(&workspace, cx),
(file2.clone(), DisplayPoint::new(DisplayRow(0), 0), 0.)
);
workspace
.update_in(cx, |w, window, cx| {
w.go_back(w.active_pane().downgrade(), window, cx)
})
.await
.unwrap();
assert_eq!(
active_location(&workspace, cx),
(file1.clone(), DisplayPoint::new(DisplayRow(10), 0), 0.)
);
workspace
.update_in(cx, |w, window, cx| {
w.go_back(w.active_pane().downgrade(), window, cx)
})
.await
.unwrap();
assert_eq!(
active_location(&workspace, cx),
(file1.clone(), DisplayPoint::new(DisplayRow(0), 0), 0.)
);
// Go back one more time and ensure we don't navigate past the first item in the history.
workspace
.update_in(cx, |w, window, cx| {
w.go_back(w.active_pane().downgrade(), window, cx)
})
.await
.unwrap();
assert_eq!(
active_location(&workspace, cx),
(file1.clone(), DisplayPoint::new(DisplayRow(0), 0), 0.)
);
workspace
.update_in(cx, |w, window, cx| {
w.go_forward(w.active_pane().downgrade(), window, cx)
})
.await
.unwrap();
assert_eq!(
active_location(&workspace, cx),
(file1.clone(), DisplayPoint::new(DisplayRow(10), 0), 0.)
);
workspace
.update_in(cx, |w, window, cx| {
w.go_forward(w.active_pane().downgrade(), window, cx)
})
.await
.unwrap();
assert_eq!(
active_location(&workspace, cx),
(file2.clone(), DisplayPoint::new(DisplayRow(0), 0), 0.)
);
// Go forward to an item that has been closed, ensuring it gets re-opened at the same
// location.
workspace
.update_in(cx, |_, window, cx| {
pane.update(cx, |pane, cx| {
let editor3_id = editor3.entity_id();
drop(editor3);
pane.close_item_by_id(editor3_id, SaveIntent::Close, window, cx)
})
})
.await
.unwrap();
workspace
.update_in(cx, |w, window, cx| {
w.go_forward(w.active_pane().downgrade(), window, cx)
})
.await
.unwrap();
assert_eq!(
active_location(&workspace, cx),
(file3.clone(), DisplayPoint::new(DisplayRow(0), 0), 0.)
);
workspace
.update_in(cx, |w, window, cx| {
w.go_forward(w.active_pane().downgrade(), window, cx)
})
.await
.unwrap();
assert_eq!(
active_location(&workspace, cx),
(file3.clone(), DisplayPoint::new(DisplayRow(16), 0), 12.5)
);
workspace
.update_in(cx, |w, window, cx| {
w.go_back(w.active_pane().downgrade(), window, cx)
})
.await
.unwrap();
assert_eq!(
active_location(&workspace, cx),
(file3.clone(), DisplayPoint::new(DisplayRow(0), 0), 0.)
);
// Go back to an item that has been closed and removed from disk
workspace
.update_in(cx, |_, window, cx| {
pane.update(cx, |pane, cx| {
let editor2_id = editor2.entity_id();
drop(editor2);
pane.close_item_by_id(editor2_id, SaveIntent::Close, window, cx)
})
})
.await
.unwrap();
app_state
.fs
.remove_file(Path::new(path!("/root/a/file2")), Default::default())
.await
.unwrap();
cx.background_executor.run_until_parked();
workspace
.update_in(cx, |w, window, cx| {
w.go_back(w.active_pane().downgrade(), window, cx)
})
.await
.unwrap();
assert_eq!(
active_location(&workspace, cx),
(file2.clone(), DisplayPoint::new(DisplayRow(0), 0), 0.)
);
workspace
.update_in(cx, |w, window, cx| {
w.go_forward(w.active_pane().downgrade(), window, cx)
})
.await
.unwrap();
assert_eq!(
active_location(&workspace, cx),
(file3.clone(), DisplayPoint::new(DisplayRow(0), 0), 0.)
);
// Modify file to collapse multiple nav history entries into the same location.
// Ensure we don't visit the same location twice when navigating.
workspace.update_in(cx, |_, window, cx| {
editor1.update(cx, |editor, cx| {
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
s.select_display_ranges([
DisplayPoint::new(DisplayRow(15), 0)..DisplayPoint::new(DisplayRow(15), 0)
])
})
});
});
for _ in 0..5 {
workspace.update_in(cx, |_, window, cx| {
editor1.update(cx, |editor, cx| {
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
s.select_display_ranges([DisplayPoint::new(DisplayRow(3), 0)
..DisplayPoint::new(DisplayRow(3), 0)])
});
});
});
workspace.update_in(cx, |_, window, cx| {
editor1.update(cx, |editor, cx| {
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
s.select_display_ranges([DisplayPoint::new(DisplayRow(13), 0)
..DisplayPoint::new(DisplayRow(13), 0)])
});
});
});
}
workspace.update_in(cx, |_, window, cx| {
editor1.update(cx, |editor, cx| {
editor.transact(window, cx, |editor, window, cx| {
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
s.select_display_ranges([DisplayPoint::new(DisplayRow(2), 0)
..DisplayPoint::new(DisplayRow(14), 0)])
});
editor.insert("", window, cx);
})
});
});
workspace.update_in(cx, |_, window, cx| {
editor1.update(cx, |editor, cx| {
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
s.select_display_ranges([
DisplayPoint::new(DisplayRow(1), 0)..DisplayPoint::new(DisplayRow(1), 0)
])
})
});
});
workspace
.update_in(cx, |w, window, cx| {
w.go_back(w.active_pane().downgrade(), window, cx)
})
.await
.unwrap();
assert_eq!(
active_location(&workspace, cx),
(file1.clone(), DisplayPoint::new(DisplayRow(2), 0), 0.)
);
workspace
.update_in(cx, |w, window, cx| {
w.go_back(w.active_pane().downgrade(), window, cx)
})
.await
.unwrap();
assert_eq!(
active_location(&workspace, cx),
(file1.clone(), DisplayPoint::new(DisplayRow(3), 0), 0.)
);
fn active_location(
workspace: &Entity<Workspace>,
cx: &mut VisualTestContext,
) -> (ProjectPath, DisplayPoint, f64) {
workspace.update(cx, |workspace, cx| {
let item = workspace.active_item(cx).unwrap();
let editor = item.downcast::<Editor>().unwrap();
editor.update(cx, |editor_ref, cx| {
let selections = editor_ref
.selections
.display_ranges(&editor_ref.display_snapshot(cx));
let scroll_position = editor_ref.scroll_position(cx);
(
editor_ref.active_project_path(cx).unwrap(),
selections[0].start,
scroll_position.y,
)
})
})
}
}
#[gpui::test]
async fn test_reopening_closed_items(cx: &mut TestAppContext) {
let app_state = init_test(cx);
app_state
.fs
.as_fake()
.insert_tree(
path!("/root"),
json!({
"a": {
"file1": "",
"file2": "",
"file3": "",
"file4": "",
},
}),
)
.await;
let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
project.update(cx, |project, _cx| project.languages().add(markdown_lang()));
let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx));
let workspace = window
.read_with(cx, |mw, _| mw.workspace().clone())
.unwrap();
let cx = &mut VisualTestContext::from_window(*window, cx);
let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone());
let entries = cx.read(|cx| workspace.file_project_paths(cx));
let file1 = entries[0].clone();
let file2 = entries[1].clone();
let file3 = entries[2].clone();
let file4 = entries[3].clone();
let file1_item_id = workspace
.update_in(cx, |w, window, cx| {
w.open_path(file1.clone(), None, true, window, cx)
})
.await
.unwrap()
.item_id();
let file2_item_id = workspace
.update_in(cx, |w, window, cx| {
w.open_path(file2.clone(), None, true, window, cx)
})
.await
.unwrap()
.item_id();
let file3_item_id = workspace
.update_in(cx, |w, window, cx| {
w.open_path(file3.clone(), None, true, window, cx)
})
.await
.unwrap()
.item_id();
let file4_item_id = workspace
.update_in(cx, |w, window, cx| {
w.open_path(file4.clone(), None, true, window, cx)
})
.await
.unwrap()
.item_id();
assert_eq!(active_path(&workspace, cx), Some(file4.clone()));
// Close all the pane items in some arbitrary order.
workspace
.update_in(cx, |_, window, cx| {
pane.update(cx, |pane, cx| {
pane.close_item_by_id(file1_item_id, SaveIntent::Close, window, cx)
})
})
.await
.unwrap();
assert_eq!(active_path(&workspace, cx), Some(file4.clone()));
workspace
.update_in(cx, |_, window, cx| {
pane.update(cx, |pane, cx| {
pane.close_item_by_id(file4_item_id, SaveIntent::Close, window, cx)
})
})
.await
.unwrap();
assert_eq!(active_path(&workspace, cx), Some(file3.clone()));
workspace
.update_in(cx, |_, window, cx| {
pane.update(cx, |pane, cx| {
pane.close_item_by_id(file2_item_id, SaveIntent::Close, window, cx)
})
})
.await
.unwrap();
assert_eq!(active_path(&workspace, cx), Some(file3.clone()));
workspace
.update_in(cx, |_, window, cx| {
pane.update(cx, |pane, cx| {
pane.close_item_by_id(file3_item_id, SaveIntent::Close, window, cx)
})
})
.await
.unwrap();
assert_eq!(active_path(&workspace, cx), None);
// Reopen all the closed items, ensuring they are reopened in the same order
// in which they were closed.
workspace
.update_in(cx, Workspace::reopen_closed_item)
.await
.unwrap();
assert_eq!(active_path(&workspace, cx), Some(file3.clone()));
workspace
.update_in(cx, Workspace::reopen_closed_item)
.await
.unwrap();
assert_eq!(active_path(&workspace, cx), Some(file2.clone()));
workspace
.update_in(cx, Workspace::reopen_closed_item)
.await
.unwrap();
assert_eq!(active_path(&workspace, cx), Some(file4.clone()));
workspace
.update_in(cx, Workspace::reopen_closed_item)
.await
.unwrap();
assert_eq!(active_path(&workspace, cx), Some(file1.clone()));
// Reopening past the last closed item is a no-op.
workspace
.update_in(cx, Workspace::reopen_closed_item)
.await
.unwrap();
assert_eq!(active_path(&workspace, cx), Some(file1.clone()));
// Reopening closed items doesn't interfere with navigation history.
// Verify we can navigate back through the history after reopening items.
workspace
.update_in(cx, |workspace, window, cx| {
workspace.go_back(workspace.active_pane().downgrade(), window, cx)
})
.await
.unwrap();
// After go_back, we should be at a different file than file1
let after_go_back = active_path(&workspace, cx);
assert!(
after_go_back.is_some() && after_go_back != Some(file1.clone()),
"After go_back from file1, should be at a different file"
);
pane.read_with(cx, |pane, _| {
assert!(pane.can_navigate_forward(), "Should be able to go forward");
});
fn active_path(
workspace: &Entity<Workspace>,
cx: &VisualTestContext,
) -> Option<ProjectPath> {
workspace.read_with(cx, |workspace, cx| {
let item = workspace.active_item(cx)?;
item.project_path(cx)
})
}
}
fn init_keymap_test(cx: &mut TestAppContext) -> Arc<AppState> {
cx.update(|cx| {
let app_state = AppState::test(cx);
theme_settings::init(theme::LoadThemes::JustBase, cx);
client::init(&app_state.client, cx);
workspace::init(app_state.clone(), cx);
onboarding::init(cx);
app_state
})
}
actions!(test_only, [ActionA, ActionB]);
#[gpui::test]
async fn test_base_keymap(cx: &mut gpui::TestAppContext) {
let executor = cx.executor();
let app_state = init_keymap_test(cx);
let project = Project::test(app_state.fs.clone(), [], cx).await;
let window =
cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let workspace = window
.read_with(cx, |mw, _| mw.workspace().clone())
.unwrap();
// From the Atom keymap
use workspace::ActivatePreviousPane;
// From the JetBrains keymap
use workspace::ActivatePreviousItem;
app_state
.fs
.save(
paths::settings_file(),
&r#"{"base_keymap": "Atom"}"#.into(),
Default::default(),
)
.await
.unwrap();
app_state
.fs
.save(
"/keymap.json".as_ref(),
&r#"[{"bindings": {"backspace": "test_only::ActionA"}}]"#.into(),
Default::default(),
)
.await
.unwrap();
executor.run_until_parked();
cx.update(|cx| {
let (keymap_rx, keymap_watcher) = watch_config_file(
&executor,
app_state.fs.clone(),
PathBuf::from("/keymap.json"),
);
watch_settings_files(app_state.fs.clone(), cx);
handle_keymap_file_changes(keymap_rx, keymap_watcher, cx);
});
window
.update(cx, |_, _, cx| {
workspace.update(cx, |workspace, cx| {
workspace.register_action(|_, _: &ActionA, _window, _cx| {});
workspace.register_action(|_, _: &ActionB, _window, _cx| {});
workspace.register_action(|_, _: &ActivatePreviousPane, _window, _cx| {});
workspace.register_action(|_, _: &ActivatePreviousItem, _window, _cx| {});
cx.notify();
});
})
.unwrap();
executor.run_until_parked();
// Test loading the keymap base at all
assert_key_bindings_for(
window.into(),
cx,
vec![("backspace", &ActionA), ("k", &ActivatePreviousPane)],
line!(),
);
// Test modifying the users keymap, while retaining the base keymap
app_state
.fs
.save(
"/keymap.json".as_ref(),
&r#"[{"bindings": {"backspace": "test_only::ActionB"}}]"#.into(),
Default::default(),
)
.await
.unwrap();
executor.run_until_parked();
assert_key_bindings_for(
window.into(),
cx,
vec![("backspace", &ActionB), ("k", &ActivatePreviousPane)],
line!(),
);
// Test modifying the base, while retaining the users keymap
app_state
.fs
.save(
paths::settings_file(),
&r#"{"base_keymap": "JetBrains"}"#.into(),
Default::default(),
)
.await
.unwrap();
executor.run_until_parked();
assert_key_bindings_for(
window.into(),
cx,
vec![
("backspace", &ActionB),
("{", &ActivatePreviousItem::default()),
],
line!(),
);
}
#[gpui::test]
async fn test_disabled_keymap_binding(cx: &mut gpui::TestAppContext) {
let executor = cx.executor();
let app_state = init_keymap_test(cx);
let project = Project::test(app_state.fs.clone(), [], cx).await;
let window =
cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let workspace = window
.read_with(cx, |mw, _| mw.workspace().clone())
.unwrap();
// From the Atom keymap
use workspace::ActivatePreviousPane;
// From the JetBrains keymap
use diagnostics::Deploy;
window
.update(cx, |_, _, cx| {
workspace.update(cx, |workspace, cx| {
workspace.register_action(|_, _: &ActionA, _window, _cx| {});
workspace.register_action(|_, _: &ActionB, _window, _cx| {});
workspace.register_action(|_, _: &Deploy, _window, _cx| {});
cx.notify();
});
})
.unwrap();
app_state
.fs
.save(
paths::settings_file(),
&r#"{"base_keymap": "Atom"}"#.into(),
Default::default(),
)
.await
.unwrap();
app_state
.fs
.save(
"/keymap.json".as_ref(),
&r#"[{"bindings": {"backspace": "test_only::ActionA"}}]"#.into(),
Default::default(),
)
.await
.unwrap();
cx.update(|cx| {
let (keymap_rx, keymap_watcher) = watch_config_file(
&executor,
app_state.fs.clone(),
PathBuf::from("/keymap.json"),
);
watch_settings_files(app_state.fs.clone(), cx);
handle_keymap_file_changes(keymap_rx, keymap_watcher, cx);
});
cx.background_executor.run_until_parked();
cx.background_executor.run_until_parked();
// Test loading the keymap base at all
assert_key_bindings_for(
window.into(),
cx,
vec![("backspace", &ActionA), ("k", &ActivatePreviousPane)],
line!(),
);
// Test disabling the key binding for the base keymap
app_state
.fs
.save(
"/keymap.json".as_ref(),
&r#"[{"bindings": {"backspace": null}}]"#.into(),
Default::default(),
)
.await
.unwrap();
cx.background_executor.run_until_parked();
assert_key_bindings_for(
window.into(),
cx,
vec![("k", &ActivatePreviousPane)],
line!(),
);
// Test modifying the base, while retaining the users keymap
app_state
.fs
.save(
paths::settings_file(),
&r#"{"base_keymap": "JetBrains"}"#.into(),
Default::default(),
)
.await
.unwrap();
cx.background_executor.run_until_parked();
assert_key_bindings_for(window.into(), cx, vec![("6", &Deploy)], line!());
}
#[gpui::test]
async fn test_generate_keymap_json_schema_for_registered_actions(
cx: &mut gpui::TestAppContext,
) {
init_keymap_test(cx);
cx.update(|cx| {
// Make sure it doesn't panic.
KeymapFile::generate_json_schema_for_registered_actions(cx);
});
}
/// Checks that action namespaces are the expected set. The purpose of this is to prevent typos
/// and let you know when introducing a new namespace.
#[gpui::test]
async fn test_action_namespaces(cx: &mut gpui::TestAppContext) {
use itertools::Itertools;
init_keymap_test(cx);
cx.update(|cx| {
let all_actions = cx.all_action_names();
let mut actions_without_namespace = Vec::new();
let all_namespaces = all_actions
.iter()
.filter_map(|action_name| {
let namespace = action_name
.split("::")
.collect::<Vec<_>>()
.into_iter()
.rev()
.skip(1)
.rev()
.join("::");
if namespace.is_empty() {
actions_without_namespace.push(*action_name);
}
if &namespace == "test_only" || &namespace == "stories" {
None
} else {
Some(namespace)
}
})
.sorted()
.dedup()
.collect::<Vec<_>>();
assert_eq!(actions_without_namespace, Vec::<&str>::new());
let expected_namespaces = vec![
"action",
"activity_indicator",
"agent",
"agents_sidebar",
"app_menu",
"assistant",
"assistant2",
"auto_update",
"branch_picker",
"bedrock",
"branches",
"buffer_search",
"channel_modal",
"cli",
"client",
"collab",
"collab_panel",
"command_palette",
"console",
"context_server",
"copilot",
"csv",
"debug_panel",
"debugger",
"dev",
"diagnostics",
"edit_prediction",
"editor",
"encoding_selector",
"feedback",
"file_finder",
"git",
"git_graph",
"git_onboarding",
"git_panel",
"git_picker",
"go_to_line",
"highlights_tree_view",
"icon_theme_selector",
"image_viewer",
"inline_assistant",
"journal",
"keymap_editor",
"keystroke_input",
"language_selector",
"welcome",
"line_ending_selector",
"lsp_tool",
"markdown",
"menu",
"multi_workspace",
"new_process_modal",
"notebook",
"onboarding",
"outline",
"outline_panel",
"pane",
"panel",
"picker",
"project_panel",
"project_search",
"project_symbols",
"projects",
"recent_projects",
"remote_debug",
"repl",
"search",
"settings_editor",
"settings_profile_selector",
"skill_creator",
"snippets",
"stash_picker",
"svg",
"syntax_tree_view",
"tab_switcher",
"task",
"terminal",
"terminal_panel",
"theme",
"theme_selector",
"toast",
"toolchain",
"variable_list",
"vim",
"window",
"workspace",
"worktree_picker",
"zed",
"zed_actions",
"zed_predict_onboarding",
"zeta",
];
assert_eq!(
all_namespaces,
expected_namespaces
.into_iter()
.map(|namespace| namespace.to_string())
.sorted()
.collect::<Vec<_>>()
);
});
}
#[gpui::test]
fn test_bundled_settings_and_themes(cx: &mut App) {
cx.text_system()
.add_fonts(vec![
Assets
.load("fonts/lilex/Lilex-Regular.ttf")
.unwrap()
.unwrap(),
Assets
.load("fonts/ibm-plex-sans/IBMPlexSans-Regular.ttf")
.unwrap()
.unwrap(),
])
.unwrap();
let themes = ThemeRegistry::default();
settings::init(cx);
theme_settings::init(theme::LoadThemes::JustBase, cx);
let mut has_default_theme = false;
for theme_name in themes.list().into_iter().map(|meta| meta.name) {
let theme = themes.get(&theme_name).unwrap();
assert_eq!(theme.name, theme_name);
if theme.name.as_ref() == "One Dark" {
has_default_theme = true;
}
}
assert!(has_default_theme);
}
#[gpui::test]
async fn test_bundled_files_editor(cx: &mut TestAppContext) {
let app_state = init_test(cx);
cx.update(init);
let project = Project::test(app_state.fs.clone(), [], cx).await;
let _window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx));
cx.update(|cx| {
cx.dispatch_action(&OpenDefaultSettings);
});
cx.run_until_parked();
assert_eq!(cx.read(|cx| cx.windows().len()), 1);
let multi_workspace = cx.windows()[0].downcast::<MultiWorkspace>().unwrap();
let active_editor = multi_workspace
.update(cx, |multi_workspace, _, cx| {
multi_workspace
.workspace()
.update(cx, |workspace, cx| workspace.active_item_as::<Editor>(cx))
})
.unwrap();
assert!(
active_editor.is_some(),
"Settings action should have opened an editor with the default file contents"
);
let active_editor = active_editor.unwrap();
assert!(
active_editor.read_with(cx, |editor, cx| editor.read_only(cx)),
"Default settings should be readonly"
);
assert!(
active_editor.read_with(cx, |editor, cx| editor.buffer().read(cx).read_only()),
"The underlying buffer should also be readonly for the shipped default settings"
);
}
#[gpui::test]
async fn test_bundled_files_reuse_existing_editor(cx: &mut TestAppContext) {
let app_state = init_test(cx);
cx.update(init);
let project = Project::test(app_state.fs.clone(), [], cx).await;
let _window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx));
cx.update(|cx| {
cx.dispatch_action(&OpenDefaultSettings);
});
cx.run_until_parked();
let multi_workspace = cx.windows()[0].downcast::<MultiWorkspace>().unwrap();
let first_item_id = multi_workspace
.update(cx, |multi_workspace, _, cx| {
multi_workspace.workspace().update(cx, |workspace, cx| {
workspace
.active_item(cx)
.expect("default settings should be open")
.item_id()
})
})
.unwrap();
cx.update(|cx| {
cx.dispatch_action(&OpenDefaultSettings);
});
cx.run_until_parked();
let (second_item_id, item_count) = multi_workspace
.update(cx, |multi_workspace, _, cx| {
multi_workspace.workspace().update(cx, |workspace, cx| {
let pane = workspace.active_pane().read(cx);
(
pane.active_item()
.expect("default settings should still be open")
.item_id(),
pane.items_len(),
)
})
})
.unwrap();
assert_eq!(first_item_id, second_item_id);
assert_eq!(item_count, 1);
}
#[gpui::test]
async fn test_bundled_languages(cx: &mut TestAppContext) {
let fs = fs::FakeFs::new(cx.background_executor.clone());
env_logger::builder().is_test(true).try_init().ok();
let settings = cx.update(SettingsStore::test);
cx.set_global(settings);
let languages = LanguageRegistry::test(cx.executor());
let languages = Arc::new(languages);
let node_runtime = node_runtime::NodeRuntime::unavailable();
cx.update(|cx| {
languages::init(languages.clone(), fs, node_runtime, cx);
});
for name in languages.language_names() {
languages
.language_for_name(name.as_ref())
.await
.with_context(|| format!("language name {name}"))
.unwrap();
}
cx.run_until_parked();
}
pub(crate) fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
init_test_with_state(cx, cx.update(AppState::test))
}
fn init_test_with_state(
cx: &mut TestAppContext,
mut app_state: Arc<AppState>,
) -> Arc<AppState> {
cx.update(move |cx| {
env_logger::builder().is_test(true).try_init().ok();
let state = Arc::get_mut(&mut app_state).unwrap();
state.build_window_options = build_window_options;
app_state.languages.add(markdown_lang());
gpui_tokio::init(cx);
AppState::set_global(app_state.clone(), cx);
theme_settings::init(theme::LoadThemes::JustBase, cx);
audio::init(cx);
channel::init(&app_state.client, app_state.user_store.clone(), cx);
call::init(app_state.client.clone(), app_state.user_store.clone(), cx);
notifications::init(app_state.client.clone(), app_state.user_store.clone(), cx);
workspace::init(app_state.clone(), cx);
release_channel::init(Version::new(0, 0, 0), cx);
command_palette::init(cx);
editor::init(cx);
collab_ui::init(&app_state, cx);
git_ui::init(cx);
project_panel::init(cx);
outline_panel::init(cx);
terminal_view::init(cx);
copilot_chat::init(
app_state.fs.clone(),
app_state.client.http_client(),
copilot_chat::CopilotChatConfiguration::default(),
cx,
);
image_viewer::init(cx);
language_model::init(cx);
client::RefreshLlmTokenListener::register(
app_state.client.clone(),
app_state.user_store.clone(),
cx,
);
language_models::init(app_state.user_store.clone(), app_state.client.clone(), cx);
web_search::init(cx);
git_graph::init(cx);
web_search_providers::init(app_state.client.clone(), app_state.user_store.clone(), cx);
let prompt_builder = PromptBuilder::load(app_state.fs.clone(), false, cx);
project::AgentRegistryStore::init_global(
cx,
app_state.fs.clone(),
app_state.client.http_client(),
);
agent_ui::init(
app_state.fs.clone(),
prompt_builder,
app_state.languages.clone(),
true,
false,
cx,
);
repl::init(app_state.fs.clone(), cx);
repl::notebook::init(cx);
tasks_ui::init(cx);
project::debugger::breakpoint_store::BreakpointStore::init(
&app_state.client.clone().into(),
);
project::debugger::dap_store::DapStore::init(&app_state.client.clone().into(), cx);
debugger_ui::init(cx);
initialize_workspace(app_state.clone(), cx);
search::init(cx);
cx.set_global(workspace::PaneSearchBarCallbacks {
setup_search_bar: |languages, toolbar, window, cx| {
let search_bar =
cx.new(|cx| search::BufferSearchBar::new(languages, window, cx));
toolbar.update(cx, |toolbar, cx| {
toolbar.add_item(search_bar, window, cx);
});
},
wrap_div_with_search_actions: search::buffer_search::register_pane_search_actions,
});
app_state
})
}
#[track_caller]
fn assert_key_bindings_for(
window: AnyWindowHandle,
cx: &TestAppContext,
actions: Vec<(&'static str, &dyn Action)>,
line: u32,
) {
let available_actions = cx
.update(|cx| window.update(cx, |_, window, cx| window.available_actions(cx)))
.unwrap();
for (key, action) in actions {
let bindings = cx
.update(|cx| window.update(cx, |_, window, _| window.bindings_for_action(action)))
.unwrap();
// assert that...
assert!(
available_actions.iter().any(|bound_action| {
// actions match...
bound_action.partial_eq(action)
}),
"On {} Failed to find {}",
line,
action.name(),
);
assert!(
// and key strokes contain the given key
bindings
.into_iter()
.any(|binding| binding.keystrokes().iter().any(|k| k.key() == key)),
"On {} Failed to find {} with key binding {}",
line,
action.name(),
key
);
}
}
#[gpui::test]
async fn test_opening_project_settings_when_excluded(cx: &mut gpui::TestAppContext) {
// Use the proper initialization for runtime state
let app_state = init_keymap_test(cx);
eprintln!("Running test_opening_project_settings_when_excluded");
// 1. Set up a project with some project settings
let settings_init =
r#"{ "UNIQUEVALUE": true, "git": { "inline_blame": { "enabled": false } } }"#;
app_state
.fs
.as_fake()
.insert_tree(
Path::new("/root"),
json!({
".zed": {
"settings.json": settings_init
}
}),
)
.await;
eprintln!("Created project with .zed/settings.json containing UNIQUEVALUE");
// 2. Create a project with the file system and load it
let project = Project::test(app_state.fs.clone(), [Path::new("/root")], cx).await;
// Save original settings content for comparison
let original_settings = app_state
.fs
.load(Path::new("/root/.zed/settings.json"))
.await
.unwrap();
let original_settings_str = original_settings.clone();
// Verify settings exist on disk and have expected content
eprintln!("Original settings content: {}", original_settings_str);
assert!(
original_settings_str.contains("UNIQUEVALUE"),
"Test setup failed - settings file doesn't contain our marker"
);
// 3. Add .zed to file scan exclusions in user settings
cx.update_global::<SettingsStore, _>(|store, cx| {
store.update_user_settings(cx, |worktree_settings| {
worktree_settings.project.worktree.file_scan_exclusions =
Some(vec![".zed".to_string()]);
});
});
eprintln!("Added .zed to file_scan_exclusions in settings");
// 4. Run tasks to apply settings
cx.background_executor.run_until_parked();
// 5. Critical: Verify .zed is actually excluded from worktree
let worktree = cx.update(|cx| project.read(cx).worktrees(cx).next().unwrap());
let has_zed_entry =
cx.update(|cx| worktree.read(cx).entry_for_path(rel_path(".zed")).is_some());
eprintln!(
"Is .zed directory visible in worktree after exclusion: {}",
has_zed_entry
);
// This assertion verifies the test is set up correctly to show the bug
// If .zed is not excluded, the test will fail here
assert!(
!has_zed_entry,
"Test precondition failed: .zed directory should be excluded but was found in worktree"
);
// 6. Create workspace and trigger the actual function that causes the bug
let window =
cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let workspace = window
.read_with(cx, |mw, _| mw.workspace().clone())
.unwrap();
window
.update(cx, |_, window, cx| {
workspace.update(cx, |workspace, cx| {
// Call the exact function that contains the bug
eprintln!("About to call open_project_settings_file");
open_project_settings_file(workspace, &OpenProjectSettingsFile, window, cx);
});
})
.unwrap();
// 7. Run background tasks until completion
cx.background_executor.run_until_parked();
// 8. Verify file contents after calling function
let new_content = app_state
.fs
.load(Path::new("/root/.zed/settings.json"))
.await
.unwrap();
let new_content_str = new_content;
eprintln!("New settings content: {}", new_content_str);
// The bug causes the settings to be overwritten with empty settings
// So if the unique value is no longer present, the bug has been reproduced
let bug_exists = !new_content_str.contains("UNIQUEVALUE");
eprintln!("Bug reproduced: {}", bug_exists);
// This assertion should fail if the bug exists - showing the bug is real
assert!(
new_content_str.contains("UNIQUEVALUE"),
"BUG FOUND: Project settings were overwritten when opening via command - original custom content was lost"
);
}
#[gpui::test]
async fn test_disable_ai_crash(cx: &mut gpui::TestAppContext) {
let app_state = init_test(cx);
cx.update(init);
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();
cx.update(|cx| {
SettingsStore::update_global(cx, |settings_store, cx| {
settings_store.update_user_settings(cx, |settings| {
settings.project.disable_ai = Some(SaturatingBool(true));
});
});
});
cx.run_until_parked();
// If this panics, the test has failed
}
#[gpui::test]
async fn test_prefer_focused_window(cx: &mut gpui::TestAppContext) {
let app_state = init_test(cx);
let paths = [PathBuf::from(path!("/dir/document.txt"))];
app_state
.fs
.as_fake()
.insert_tree(
path!("/dir"),
json!({
"document.txt": "Some of the documentation's content."
}),
)
.await;
let project_a = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
let window_a = cx.add_window({
let project = project_a.clone();
|window, cx| MultiWorkspace::test_new(project, window, cx)
});
let project_b = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
let window_b = cx.add_window({
let project = project_b.clone();
|window, cx| MultiWorkspace::test_new(project, window, cx)
});
let project_c = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
let window_c = cx.add_window({
let project = project_c.clone();
|window, cx| MultiWorkspace::test_new(project, window, cx)
});
for window in [window_a, window_b, window_c] {
let _ = cx.update_window(*window, |_, window, _| {
window.activate_window();
});
cx.update(|cx| {
let open_options = OpenOptions {
wait: true,
..Default::default()
};
workspace::open_paths(&paths, app_state.clone(), open_options, cx)
})
.await
.unwrap();
cx.update_window(*window, |_, window, _| assert!(window.is_window_active()))
.unwrap();
let _ = window.read_with(cx, |multi_workspace, cx| {
let pane = multi_workspace.workspace().read(cx).active_pane().read(cx);
let project_path = pane.active_item().unwrap().project_path(cx).unwrap();
assert_eq!(
project_path.path.as_ref().as_std_path().to_str().unwrap(),
path!("document.txt")
)
});
}
}
#[gpui::test]
async fn test_open_paths_switches_to_best_workspace(cx: &mut TestAppContext) {
let app_state = init_test(cx);
app_state
.fs
.as_fake()
.insert_tree(
path!("/"),
json!({
"dir1": {
"a.txt": "content a"
},
"dir2": {
"b.txt": "content b"
},
"dir3": {
"c.txt": "content c"
}
}),
)
.await;
// Create a window with workspace 0 containing /dir1
let project1 = Project::test(app_state.fs.clone(), [path!("/dir1").as_ref()], cx).await;
let window = cx.add_window({
let project = project1.clone();
|window, cx| MultiWorkspace::test_new(project, window, cx)
});
window
.update(cx, |multi_workspace, _, cx| {
multi_workspace.open_sidebar(cx);
})
.unwrap();
cx.run_until_parked();
assert_eq!(cx.windows().len(), 1, "Should start with 1 window");
// Create workspace 2 with /dir2
let project2 = Project::test(app_state.fs.clone(), [path!("/dir2").as_ref()], cx).await;
let workspace2 = window
.update(cx, |multi_workspace, window, cx| {
multi_workspace.test_add_workspace(project2.clone(), window, cx)
})
.unwrap();
// Create workspace 3 with /dir3
let project3 = Project::test(app_state.fs.clone(), [path!("/dir3").as_ref()], cx).await;
let workspace3 = window
.update(cx, |multi_workspace, window, cx| {
multi_workspace.test_add_workspace(project3.clone(), window, cx)
})
.unwrap();
let workspace1 = window
.read_with(cx, |multi_workspace, _| {
multi_workspace.workspaces().next().unwrap().clone()
})
.unwrap();
window
.update(cx, |multi_workspace, window, cx| {
multi_workspace.activate(workspace2.clone(), None, window, cx);
multi_workspace.activate(workspace3.clone(), None, window, cx);
// Switch back to workspace1 for test setup
multi_workspace.activate(workspace1.clone(), None, window, cx);
assert_eq!(multi_workspace.workspace(), &workspace1);
})
.unwrap();
cx.run_until_parked();
// Verify setup: 3 workspaces, workspace 0 active, still 1 window
window
.read_with(cx, |multi_workspace, _| {
assert_eq!(multi_workspace.workspaces().count(), 3);
assert_eq!(multi_workspace.workspace(), &workspace1);
})
.unwrap();
assert_eq!(cx.windows().len(), 1);
// Open a file in /dir3 - should switch to workspace 3 (not just "the other one")
cx.update(|cx| {
open_paths(
&[PathBuf::from(path!("/dir3/c.txt"))],
app_state.clone(),
OpenOptions::default(),
cx,
)
})
.await
.unwrap();
cx.run_until_parked();
// Verify workspace 2 is active and file opened there
window
.read_with(cx, |multi_workspace, cx| {
assert_eq!(
multi_workspace.workspace(),
&workspace3,
"Should have switched to workspace 3 which contains /dir3"
);
let active_item = multi_workspace
.workspace()
.read(cx)
.active_pane()
.read(cx)
.active_item()
.expect("Should have an active item");
assert_eq!(active_item.tab_content_text(0, cx), "c.txt");
})
.unwrap();
assert_eq!(cx.windows().len(), 1, "Should reuse existing window");
// Open a file in /dir2 - should switch to workspace 2
cx.update(|cx| {
open_paths(
&[PathBuf::from(path!("/dir2/b.txt"))],
app_state.clone(),
OpenOptions::default(),
cx,
)
})
.await
.unwrap();
cx.run_until_parked();
// Verify workspace 1 is active and file opened there
window
.read_with(cx, |multi_workspace, cx| {
assert_eq!(
multi_workspace.workspace(),
&workspace2,
"Should have switched to workspace 2 which contains /dir2"
);
let active_item = multi_workspace
.workspace()
.read(cx)
.active_pane()
.read(cx)
.active_item()
.expect("Should have an active item");
assert_eq!(active_item.tab_content_text(0, cx), "b.txt");
})
.unwrap();
// Verify c.txt is still in workspace 3 (file opened in correct workspace, not active one)
workspace3.read_with(cx, |workspace, cx| {
let active_item = workspace
.active_pane()
.read(cx)
.active_item()
.expect("Workspace 2 should have an active item");
assert_eq!(
active_item.tab_content_text(0, cx),
"c.txt",
"c.txt should have been opened in workspace 3, not the active workspace"
);
});
assert_eq!(cx.windows().len(), 1, "Should still have only 1 window");
// Open a file in /dir1 - should switch back to workspace 0
cx.update(|cx| {
open_paths(
&[PathBuf::from(path!("/dir1/a.txt"))],
app_state.clone(),
OpenOptions::default(),
cx,
)
})
.await
.unwrap();
cx.run_until_parked();
// Verify workspace 0 is active and file opened there
window
.read_with(cx, |multi_workspace, cx| {
assert_eq!(
multi_workspace.workspace(),
&workspace1,
"Should have switched back to workspace 0 which contains /dir1"
);
let active_item = multi_workspace
.workspace()
.read(cx)
.active_pane()
.read(cx)
.active_item()
.expect("Should have an active item");
assert_eq!(active_item.tab_content_text(0, cx), "a.txt");
})
.unwrap();
assert_eq!(cx.windows().len(), 1, "Should still have only 1 window");
}
#[gpui::test]
async fn test_quit_checks_all_workspaces_for_dirty_items(cx: &mut TestAppContext) {
let app_state = init_test(cx);
cx.update(init);
app_state
.fs
.as_fake()
.insert_tree(
path!("/"),
json!({
"dir1": {
"a.txt": "content a"
},
"dir2": {
"b.txt": "content b"
},
"dir3": {
"c.txt": "content c"
}
}),
)
.await;
// === Setup Window 1 with two workspaces ===
let project1 = Project::test(app_state.fs.clone(), [path!("/dir1").as_ref()], cx).await;
let window1 = cx.add_window({
let project = project1.clone();
|window, cx| MultiWorkspace::test_new(project, window, cx)
});
window1
.update(cx, |multi_workspace, _, cx| {
multi_workspace.open_sidebar(cx);
})
.unwrap();
cx.run_until_parked();
let project2 = Project::test(app_state.fs.clone(), [path!("/dir2").as_ref()], cx).await;
let workspace1_1 = window1
.read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone())
.unwrap();
let workspace1_2 = window1
.update(cx, |multi_workspace, window, cx| {
multi_workspace.test_add_workspace(project2.clone(), window, cx)
})
.unwrap();
window1
.update(cx, |multi_workspace, window, cx| {
multi_workspace.activate(workspace1_2.clone(), None, window, cx);
multi_workspace.activate(workspace1_1.clone(), None, window, cx);
})
.unwrap();
// === Setup Window 2 with one workspace ===
let project3 = Project::test(app_state.fs.clone(), [path!("/dir3").as_ref()], cx).await;
let window2 = cx.add_window({
let project = project3.clone();
|window, cx| MultiWorkspace::test_new(project, window, cx)
});
window2
.update(cx, |multi_workspace, _, cx| {
multi_workspace.open_sidebar(cx);
})
.unwrap();
cx.run_until_parked();
assert_eq!(cx.windows().len(), 2);
// === Case 1: Active workspace has dirty item, quit can be cancelled ===
let worktree1_id = project1.update(cx, |project, cx| {
project.worktrees(cx).next().unwrap().read(cx).id()
});
let editor1 = window1
.update(cx, |_, window, cx| {
workspace1_1.update(cx, |workspace, cx| {
workspace.open_path((worktree1_id, rel_path("a.txt")), None, true, window, cx)
})
})
.unwrap()
.await
.unwrap()
.downcast::<Editor>()
.unwrap();
window1
.update(cx, |_, window, cx| {
editor1.update(cx, |editor, cx| {
editor.insert("dirty in active workspace", window, cx);
});
})
.unwrap();
cx.run_until_parked();
// Verify workspace1_1 is active
window1
.read_with(cx, |multi_workspace, _| {
assert_eq!(multi_workspace.workspace(), &workspace1_1);
})
.unwrap();
cx.dispatch_action(*window1, Quit);
cx.run_until_parked();
assert!(
cx.has_pending_prompt(),
"Case 1: Should prompt to save dirty item in active workspace"
);
cx.simulate_prompt_answer("Cancel");
cx.run_until_parked();
assert_eq!(
cx.windows().len(),
2,
"Case 1: Windows should still exist after cancelling quit"
);
// Clean up Case 1: Close the dirty item without saving
let close_task = window1
.update(cx, |_, window, cx| {
workspace1_1.update(cx, |workspace, cx| {
workspace.active_pane().update(cx, |pane, cx| {
pane.close_active_item(&Default::default(), window, cx)
})
})
})
.unwrap();
cx.run_until_parked();
cx.simulate_prompt_answer("Don't Save");
close_task.await.ok();
cx.run_until_parked();
// === Case 2: Non-active workspace (same window) has dirty item ===
let worktree2_id = project2.update(cx, |project, cx| {
project.worktrees(cx).next().unwrap().read(cx).id()
});
let editor2 = window1
.update(cx, |_, window, cx| {
workspace1_2.update(cx, |workspace, cx| {
workspace.open_path((worktree2_id, rel_path("b.txt")), None, true, window, cx)
})
})
.unwrap()
.await
.unwrap()
.downcast::<Editor>()
.unwrap();
window1
.update(cx, |_, window, cx| {
editor2.update(cx, |editor, cx| {
editor.insert("dirty in non-active workspace", window, cx);
});
})
.unwrap();
cx.run_until_parked();
// Verify workspace1_1 is still active (not workspace1_2 with dirty item)
window1
.read_with(cx, |multi_workspace, _| {
assert_eq!(multi_workspace.workspace(), &workspace1_1);
})
.unwrap();
cx.dispatch_action(*window1, Quit);
cx.run_until_parked();
// Verify the non-active workspace got activated to show the dirty item
window1
.read_with(cx, |multi_workspace, _| {
assert_eq!(
multi_workspace.workspace(),
&workspace1_2,
"Case 2: Non-active workspace should be activated when it has dirty item"
);
})
.unwrap();
assert!(
cx.has_pending_prompt(),
"Case 2: Should prompt to save dirty item in non-active workspace"
);
cx.simulate_prompt_answer("Cancel");
cx.run_until_parked();
assert_eq!(
cx.windows().len(),
2,
"Case 2: Windows should still exist after cancelling quit"
);
// Clean up Case 2: Close the dirty item without saving
let close_task = window1
.update(cx, |_, window, cx| {
workspace1_2.update(cx, |workspace, cx| {
workspace.active_pane().update(cx, |pane, cx| {
pane.close_active_item(&Default::default(), window, cx)
})
})
})
.unwrap();
cx.run_until_parked();
cx.simulate_prompt_answer("Don't Save");
close_task.await.ok();
cx.run_until_parked();
// === Case 3: Non-active window has dirty item ===
let workspace3 = window2
.read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone())
.unwrap();
let worktree3_id = project3.update(cx, |project, cx| {
project.worktrees(cx).next().unwrap().read(cx).id()
});
let editor3 = window2
.update(cx, |_, window, cx| {
workspace3.update(cx, |workspace, cx| {
workspace.open_path((worktree3_id, rel_path("c.txt")), None, true, window, cx)
})
})
.unwrap()
.await
.unwrap()
.downcast::<Editor>()
.unwrap();
window2
.update(cx, |_, window, cx| {
editor3.update(cx, |editor, cx| {
editor.insert("dirty in other window", window, cx);
});
})
.unwrap();
cx.run_until_parked();
// Activate window1 explicitly (editing in window2 may have activated it)
window1
.update(cx, |_, window, _| window.activate_window())
.unwrap();
cx.run_until_parked();
// Verify window2 is not active (window1 should still be active)
assert_eq!(
cx.update(|cx| window2.is_active(cx)),
Some(false),
"Case 3: window2 should not be active before quit"
);
// Dispatch quit from window1 (window2 has the dirty item)
cx.dispatch_action(*window1, Quit);
cx.run_until_parked();
// Verify window2 is now active (quit handler activated it to show dirty item)
assert_eq!(
cx.update(|cx| window2.is_active(cx)),
Some(true),
"Case 3: window2 should be activated when it has dirty item"
);
assert!(
cx.has_pending_prompt(),
"Case 3: Should prompt to save dirty item in non-active window"
);
cx.simulate_prompt_answer("Cancel");
cx.run_until_parked();
assert_eq!(
cx.windows().len(),
2,
"Case 3: Windows should still exist after cancelling quit"
);
}
#[gpui::test]
async fn test_multi_workspace_session_restore(cx: &mut TestAppContext) {
use collections::HashMap;
use session::Session;
use util::path_list::PathList;
use workspace::{OpenMode, ProjectGroupKey, Workspace, WorkspaceId};
let app_state = init_test(cx);
let dir1 = path!("/dir1");
let dir2 = path!("/dir2");
let dir3 = path!("/dir3");
let fs = app_state.fs.clone();
let fake_fs = fs.as_fake();
fake_fs.insert_tree(dir1, json!({})).await;
fake_fs.insert_tree(dir2, json!({})).await;
fake_fs.insert_tree(dir3, json!({})).await;
let session_id = cx.read(|cx| app_state.session.read(cx).id().to_owned());
// --- Create 3 workspaces in 2 windows ---
//
// Window A: workspace for dir1, workspace for dir2
// Window B: workspace for dir3
let workspace::OpenResult {
window: window_a, ..
} = cx
.update(|cx| {
Workspace::new_local(
vec![dir1.into()],
app_state.clone(),
None,
None,
None,
OpenMode::Activate,
cx,
)
})
.await
.expect("failed to open first workspace");
window_a
.update(cx, |multi_workspace, _, cx| {
multi_workspace.open_sidebar(cx);
})
.unwrap();
window_a
.update(cx, |multi_workspace, window, cx| {
multi_workspace.open_project(vec![dir2.into()], OpenMode::Activate, window, cx)
})
.unwrap()
.await
.expect("failed to open second workspace into window A");
cx.run_until_parked();
let workspace::OpenResult {
window: window_b, ..
} = cx
.update(|cx| {
Workspace::new_local(
vec![dir3.into()],
app_state.clone(),
None,
None,
None,
OpenMode::Activate,
cx,
)
})
.await
.expect("failed to open third workspace");
window_b
.update(cx, |multi_workspace, _, cx| {
multi_workspace.open_sidebar(cx);
})
.unwrap();
// Currently dir2 is active because it was added last.
// So, switch window_a's active workspace to dir1 (index 0).
// This sets up a non-trivial assertion: after restore, dir1 should
// still be active rather than whichever workspace happened to restore last.
window_a
.update(cx, |multi_workspace, window, cx| {
let workspace = multi_workspace.workspaces().next().unwrap().clone();
multi_workspace.activate(workspace, None, window, cx);
})
.unwrap();
cx.run_until_parked();
flush_workspace_serialization(&window_a, cx).await;
flush_workspace_serialization(&window_b, cx).await;
cx.run_until_parked();
// Verify all workspaces retained their session_ids.
let db = cx.update(|cx| workspace::WorkspaceDb::global(cx));
let locations =
workspace::last_session_workspace_locations(&db, &session_id, None, fs.as_ref())
.await
.expect("expected session workspace locations");
assert_eq!(
locations.len(),
3,
"all 3 workspaces should have session_ids in the DB"
);
// Close the original windows.
window_a
.update(cx, |_, window, _| window.remove_window())
.unwrap();
window_b
.update(cx, |_, window, _| window.remove_window())
.unwrap();
cx.run_until_parked();
// Simulate a new session launch: replace the session so that
// `last_session_id()` returns the ID used during workspace creation.
// `restore_on_startup` defaults to `LastSession`, which is what we need.
cx.update(|cx| {
app_state.session.update(cx, |app_session, _cx| {
app_session
.replace_session_for_test(Session::test_with_old_session(session_id.clone()));
});
});
// --- Read back from DB and verify grouping ---
let locations =
workspace::last_session_workspace_locations(&db, &session_id, None, fs.as_ref())
.await
.expect("expected session workspace locations");
assert_eq!(locations.len(), 3, "expected 3 session workspaces");
let mut groups_by_window: HashMap<gpui::WindowId, Vec<WorkspaceId>> = HashMap::default();
for session_workspace in &locations {
if let Some(window_id) = session_workspace.window_id {
groups_by_window
.entry(window_id)
.or_default()
.push(session_workspace.workspace_id);
}
}
assert_eq!(
groups_by_window.len(),
2,
"expected 2 window groups, got {groups_by_window:?}"
);
assert!(
groups_by_window.values().any(|g| g.len() == 2),
"expected one group with 2 workspaces"
);
assert!(
groups_by_window.values().any(|g| g.len() == 1),
"expected one group with 1 workspace"
);
let mut async_cx = cx.to_async();
crate::restore_or_create_workspace(app_state.clone(), &mut async_cx)
.await
.expect("failed to restore workspaces");
cx.run_until_parked();
// --- Verify the restored windows ---
let restored_windows: Vec<WindowHandle<MultiWorkspace>> = cx.read(|cx| {
cx.windows()
.into_iter()
.filter_map(|window| window.downcast::<MultiWorkspace>())
.collect()
});
assert_eq!(restored_windows.len(), 2,);
// Identify restored windows by their active workspace root paths.
let (restored_a, restored_b) = {
let (mut with_dir1, mut with_dir3) = (None, None);
for window in &restored_windows {
let active_paths = window
.read_with(cx, |mw, cx| mw.workspace().read(cx).root_paths(cx))
.unwrap();
if active_paths.iter().any(|p| p.as_ref() == Path::new(dir1)) {
with_dir1 = Some(window);
} else {
with_dir3 = Some(window);
}
}
(
with_dir1.expect("expected a window with dir1 active"),
with_dir3.expect("expected a window with dir3 active"),
)
};
// Window A (dir1+dir2): 1 workspace restored, but 2 project group keys.
restored_a
.read_with(cx, |mw, _| {
assert_eq!(
mw.project_group_keys(),
vec![
ProjectGroupKey::new(None, PathList::new(&[dir2])),
ProjectGroupKey::new(None, PathList::new(&[dir1])),
]
);
assert_eq!(mw.workspaces().count(), 1);
})
.unwrap();
// Window B (dir3): 1 workspace, 1 project group key.
restored_b
.read_with(cx, |mw, _| {
assert_eq!(
mw.project_group_keys(),
vec![ProjectGroupKey::new(None, PathList::new(&[dir3]))]
);
assert_eq!(mw.workspaces().count(), 1);
})
.unwrap();
}
#[gpui::test]
async fn test_restored_project_groups_survive_workspace_key_change(cx: &mut TestAppContext) {
use session::Session;
use util::path_list::PathList;
use workspace::{OpenMode, ProjectGroupKey};
let app_state = init_test(cx);
let fs = app_state.fs.clone();
let fake_fs = fs.as_fake();
fake_fs
.insert_tree(path!("/root_a"), json!({ "file.txt": "" }))
.await;
fake_fs
.insert_tree(path!("/root_b"), json!({ "file.txt": "" }))
.await;
fake_fs
.insert_tree(path!("/root_c"), json!({ "file.txt": "" }))
.await;
fake_fs
.insert_tree(path!("/root_d"), json!({ "other.txt": "" }))
.await;
let session_id = cx.read(|cx| app_state.session.read(cx).id().to_owned());
// --- Phase 1: Build a multi-workspace with 3 project groups ---
let workspace::OpenResult { window, .. } = cx
.update(|cx| {
workspace::Workspace::new_local(
vec![path!("/root_a").into()],
app_state.clone(),
None,
None,
None,
OpenMode::Activate,
cx,
)
})
.await
.expect("failed to open workspace");
window.update(cx, |mw, _, cx| mw.open_sidebar(cx)).unwrap();
window
.update(cx, |mw, window, cx| {
mw.open_project(vec![path!("/root_b").into()], OpenMode::Add, window, cx)
})
.unwrap()
.await
.expect("failed to add root_b");
window
.update(cx, |mw, window, cx| {
mw.open_project(vec![path!("/root_c").into()], OpenMode::Add, window, cx)
})
.unwrap()
.await
.expect("failed to add root_c");
cx.run_until_parked();
let key_b = ProjectGroupKey::new(None, PathList::new(&[path!("/root_b")]));
let key_c = ProjectGroupKey::new(None, PathList::new(&[path!("/root_c")]));
// Make root_a the active workspace so it's the one eagerly restored.
window
.update(cx, |mw, window, cx| {
let workspace_a = mw
.workspaces()
.find(|ws| {
ws.read(cx)
.root_paths(cx)
.iter()
.any(|p| p.as_ref() == Path::new(path!("/root_a")))
})
.expect("workspace_a should exist")
.clone();
mw.activate(workspace_a, None, window, cx);
})
.unwrap();
cx.run_until_parked();
// --- Phase 2: Serialize, close, and restore ---
flush_workspace_serialization(&window, cx).await;
cx.run_until_parked();
window
.update(cx, |_, window, _| window.remove_window())
.unwrap();
cx.run_until_parked();
cx.update(|cx| {
app_state.session.update(cx, |app_session, _cx| {
app_session
.replace_session_for_test(Session::test_with_old_session(session_id.clone()));
});
});
let mut async_cx = cx.to_async();
crate::restore_or_create_workspace(app_state.clone(), &mut async_cx)
.await
.expect("failed to restore workspace");
cx.run_until_parked();
let restored_windows: Vec<WindowHandle<MultiWorkspace>> = cx.read(|cx| {
cx.windows()
.into_iter()
.filter_map(|w| w.downcast::<MultiWorkspace>())
.collect()
});
assert_eq!(restored_windows.len(), 1);
let restored = &restored_windows[0];
// Verify the restored window has all 3 project groups.
restored
.read_with(cx, |mw, _cx| {
let keys = mw.project_group_keys();
assert_eq!(
keys.len(),
3,
"restored window should have 3 groups; got {keys:?}"
);
assert!(keys.contains(&key_b), "should contain key_b");
assert!(keys.contains(&key_c), "should contain key_c");
})
.unwrap();
// --- Phase 3: Trigger a workspace key change and verify survival ---
let active_project = restored
.read_with(cx, |mw, cx| mw.workspace().read(cx).project().clone())
.unwrap();
active_project
.update(cx, |project, cx| {
project.find_or_create_worktree(path!("/root_d"), true, cx)
})
.await
.expect("adding worktree should succeed");
cx.run_until_parked();
restored
.read_with(cx, |mw, _cx| {
let keys = mw.project_group_keys();
assert!(
keys.contains(&key_b),
"restored group key_b should survive a workspace key change; got {keys:?}"
);
assert!(
keys.contains(&key_c),
"restored group key_c should survive a workspace key change; got {keys:?}"
);
})
.unwrap();
}
#[gpui::test]
async fn test_close_project_removes_project_group(cx: &mut TestAppContext) {
use util::path_list::PathList;
use workspace::{OpenMode, ProjectGroupKey};
let app_state = init_test(cx);
app_state
.fs
.as_fake()
.insert_tree(path!("/my-project"), json!({}))
.await;
let workspace::OpenResult { window, .. } = cx
.update(|cx| {
workspace::Workspace::new_local(
vec![path!("/my-project").into()],
app_state.clone(),
None,
None,
None,
OpenMode::Activate,
cx,
)
})
.await
.unwrap();
window.update(cx, |mw, _, cx| mw.open_sidebar(cx)).unwrap();
cx.background_executor.run_until_parked();
let project_key = ProjectGroupKey::new(None, PathList::new(&[path!("/my-project")]));
let keys = window
.read_with(cx, |mw, _| mw.project_group_keys())
.unwrap();
assert_eq!(
keys,
vec![project_key],
"project group should exist before CloseProject: {keys:?}"
);
cx.dispatch_action(window.into(), CloseProject);
let keys = window
.read_with(cx, |mw, _| mw.project_group_keys())
.unwrap();
assert!(
keys.is_empty(),
"project group should be removed after CloseProject: {keys:?}"
);
}
}