mirror of
https://github.com/zed-industries/zed.git
synced 2026-05-31 19:05:00 +07:00
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>
6670 lines
236 KiB
Rust
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, ¢er_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(¬ification_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(¬ification_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:?}"
|
|
);
|
|
}
|
|
}
|