Skip to repository content7495 lines · 273.4 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T04:40:20.555Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
zed.rs
1mod app_menus;
2pub mod edit_prediction_registry;
3#[cfg(target_os = "macos")]
4pub(crate) mod mac_only_instance;
5mod migrate;
6#[cfg(target_os = "macos")]
7mod open_listener;
8mod open_url_modal;
9mod quick_action_bar;
10pub mod remote_debug;
11pub mod telemetry_log;
12#[cfg(all(target_os = "macos", feature = "visual-tests"))]
13pub mod visual_tests;
14#[cfg(target_os = "windows")]
15pub(crate) mod windows_only_instance;
16
17use agent_settings::{UserAgentsMdState, init_user_agents_md};
18use agent_ui::AgentDiffToolbar;
19use anyhow::Context as _;
20pub use app_menus::*;
21use assets::Assets;
22
23use agent_computer_ui::AgentComputerPanel;
24use breadcrumbs::Breadcrumbs;
25use client::zed_urls;
26use collections::VecDeque;
27use debugger_ui::debugger_panel::DebugPanel;
28use editor::{Editor, MultiBuffer};
29use extension_host::ExtensionStore;
30use feature_flags::{FeatureFlagAppExt as _, PanicFeatureFlag};
31use fs::Fs;
32use futures::FutureExt as _;
33use futures::{StreamExt, channel::mpsc, select_biased};
34use git_ui::branch_diff::BranchDiffToolbar;
35use git_ui::commit_view::CommitViewToolbar;
36use git_ui::git_panel::GitPanel;
37use git_ui::project_diff::ProjectDiffToolbar;
38use git_ui::solo_diff_view::{SoloDiffGitToolbar, SoloDiffStyleToolbar};
39use git_ui::staged_diff::StagedDiffToolbar;
40use git_ui::unstaged_diff::UnstagedDiffToolbar;
41use gpui::{
42 Action, App, AppContext as _, AsyncWindowContext, ClipboardItem, Context, DismissEvent,
43 Element, Entity, FocusHandle, Focusable, Image, ImageFormat, KeyBinding, ParentElement,
44 PathPromptOptions, PromptLevel, ReadGlobal, SharedString, Size, Task, TaskExt, TitlebarOptions,
45 UpdateGlobal, WeakEntity, Window, WindowBounds, WindowHandle, WindowKind, WindowOptions,
46 actions, image_cache, img, point, px, retain_all,
47};
48use image_viewer::ImageInfo;
49use language::Capability;
50use language_onboarding::BasedPyrightBanner;
51use language_tools::lsp_button::{self, LspButton};
52use language_tools::lsp_log_view::LspLogToolbarItemView;
53use markdown::{Markdown, MarkdownElement, MarkdownFont, MarkdownStyle};
54use migrate::{MigrationBanner, MigrationEvent, MigrationNotification, MigrationType};
55use migrator::migrate_keymap;
56use onboarding::{await_identity_ready, multibuffer_hint::MultibufferHint};
57pub use open_listener::*;
58use outline_panel::OutlinePanel;
59use paths::{
60 local_debug_file_relative_path, local_settings_file_relative_path,
61 local_tasks_file_relative_path,
62};
63use project::{DirectoryLister, DisableAiSettings, ProjectItem};
64use project_panel::ProjectPanel;
65use quick_action_bar::QuickActionBar;
66use recent_projects::open_remote_project;
67use release_channel::{AppCommitSha, AppVersion, ReleaseChannel};
68use rope::Rope;
69use search::project_search::ProjectSearchBar;
70use settings::{
71 BaseKeymap, DEFAULT_KEYMAP_PATH, DefaultOpenBehavior, InvalidSettingsError, KeybindSource,
72 KeymapFile, KeymapFileLoadResult, MigrationStatus, SPECIFIC_OVERRIDES_KEYMAP_PATH, Settings,
73 SettingsFile, SettingsStore, VIM_KEYMAP_PATH, initial_local_debug_tasks_content,
74 initial_project_settings_content, initial_tasks_content, update_settings_file,
75};
76use sidebar::Sidebar;
77use workroom_ui::SarahWorkroomPanel;
78#[cfg(debug_assertions)]
79use workspace::workspace_error::{ErrorAction, ErrorSeverity, WorkspaceError};
80
81use std::{
82 borrow::Cow,
83 path::{Path, PathBuf},
84 sync::Arc,
85 sync::atomic::{self, AtomicBool},
86};
87use terminal_view::terminal_panel::{self, TerminalPanel};
88use theme::{ActiveTheme, SystemAppearance, ThemeRegistry, deserialize_icon_theme};
89use theme_settings::{ThemeSettings, load_user_theme};
90use ui::{Navigable, NavigableEntry, PopoverMenuHandle, TintColor, prelude::*};
91use util::markdown::MarkdownString;
92use util::rel_path::RelPath;
93use util::{ResultExt, asset_str, maybe};
94use uuid::Uuid;
95use vim_mode_setting::VimModeSetting;
96use workspace::notifications::{NotificationId, dismiss_app_notification, show_app_notification};
97
98use workspace::{
99 AppState, MultiWorkspace, NewFile, NewWindow, OpenLog, Panel, Toast, Workspace,
100 WorkspaceSettings, create_and_open_local_file,
101 notifications::simple_message_notification::MessageNotification, open_new,
102};
103use workspace::{
104 CloseIntent, CloseProject, CloseWindow, RestoreBanner, with_active_or_new_workspace,
105};
106use workspace::{Pane, notifications::DetachAndPromptErr};
107use zed_actions::{
108 About, GetMerch, OpenAccountSettings, OpenBrowser, OpenDocs, OpenProjectTasks,
109 OpenServerSettings, OpenSettingsFile, OpenStatusPage, OpenAppUrl, Quit,
110};
111
112const DOCS_URL: &str = app_identity::PRODUCT_DOCS_URL;
113const STATUS_URL: &str = "https://www.openagents.com/";
114const MERCH_URL: &str = "https://www.openagents.com/";
115
116pub struct CrashHandler(pub Arc<crashes::Client>);
117
118impl gpui::Global for CrashHandler {}
119
120actions!(
121 omega,
122 [
123 /// Opens the element inspector for debugging UI.
124 #[action(deprecated_aliases = ["zed::DebugElements"])]
125 DebugElements,
126 /// Hides the application window.
127 #[action(deprecated_aliases = ["zed::Hide"])]
128 Hide,
129 /// Hides all other application windows.
130 #[action(deprecated_aliases = ["zed::HideOthers"])]
131 HideOthers,
132 /// Minimizes the current window.
133 #[action(deprecated_aliases = ["zed::Minimize"])]
134 Minimize,
135 /// Opens the default settings file.
136 #[action(deprecated_aliases = ["zed::OpenDefaultSettings"])]
137 OpenDefaultSettings,
138 /// Opens project-specific settings file.
139 #[action(deprecated_aliases = ["zed::OpenProjectSettingsFile"])]
140 OpenProjectSettingsFile,
141 /// Opens the tasks panel.
142 #[action(deprecated_aliases = ["zed::OpenTasks"])]
143 OpenTasks,
144 /// Opens debug tasks configuration.
145 #[action(deprecated_aliases = ["zed::OpenDebugTasks"])]
146 OpenDebugTasks,
147 /// Shows the default semantic token rules (read-only).
148 #[action(deprecated_aliases = ["zed::ShowDefaultSemanticTokenRules"])]
149 ShowDefaultSemanticTokenRules,
150 /// Resets the application database.
151 #[action(deprecated_aliases = ["zed::ResetDatabase"])]
152 ResetDatabase,
153 /// Shows all hidden windows.
154 #[action(deprecated_aliases = ["zed::ShowAll"])]
155 ShowAll,
156 /// Toggles fullscreen mode.
157 #[action(deprecated_aliases = ["zed::ToggleFullScreen"])]
158 ToggleFullScreen,
159 /// Zooms the window.
160 #[action(deprecated_aliases = ["zed::Zoom"])]
161 Zoom,
162 /// Triggers a test panic for debugging.
163 #[action(deprecated_aliases = ["zed::TestPanic"])]
164 TestPanic,
165 /// Triggers a hard crash for debugging.
166 #[action(deprecated_aliases = ["zed::TestCrash"])]
167 TestCrash,
168 ]
169);
170
171actions!(
172 dev,
173 [
174 /// Opens a prompt to enter a URL to open.
175 OpenUrlPrompt,
176 /// Dumps the current accessibility tree (the last update sent to the
177 /// platform adapter) to a new buffer as JSON, for debugging what is
178 /// exposed to assistive technology.
179 DumpAccessibilityTree,
180 /// Copies the current accessibility tree to the clipboard as JSON,
181 /// without opening a buffer. See [`DumpAccessibilityTree`].
182 CopyAccessibilityTree,
183 ]
184);
185
186/// Serializes the window's most recent accessibility tree to JSON for the
187/// `dev: dump/copy accessibility tree` actions, falling back to a friendly
188/// placeholder when no tree has been built yet.
189fn accessibility_tree_dump(window: &Window) -> String {
190 window.debug_a11y_tree_json().unwrap_or_else(|| {
191 "No accessibility tree has been built yet. The tree is only \
192 produced once assistive technology (e.g. a screen reader) is \
193 active for this window."
194 .to_string()
195 })
196}
197
198#[cfg(debug_assertions)]
199actions!(
200 dev,
201 [
202 /// Show an error on the workspace level.
203 ShowWorkspaceError
204 ]
205);
206
207pub fn init(cx: &mut App) {
208 #[cfg(target_os = "macos")]
209 cx.on_action(|_: &Hide, cx| cx.hide());
210 #[cfg(target_os = "macos")]
211 cx.on_action(|_: &HideOthers, cx| cx.hide_other_apps());
212 #[cfg(target_os = "macos")]
213 cx.on_action(|_: &ShowAll, cx| cx.unhide_other_apps());
214 cx.on_action(quit);
215
216 cx.on_action(|_: &RestoreBanner, cx| title_bar::restore_banner(cx));
217
218 cx.observe_flag::<PanicFeatureFlag, _>({
219 let mut added = false;
220 move |flag, cx| {
221 if added || !*flag {
222 return;
223 }
224 added = true;
225 cx.on_action(|_: &TestPanic, _| panic!("Ran the TestPanic action"))
226 .on_action(|_: &TestCrash, _| {
227 unsafe extern "C" {
228 fn puts(s: *const i8);
229 }
230 unsafe {
231 puts(0xabad1d3a as *const i8);
232 }
233 });
234 }
235 })
236 .detach();
237
238 // When Zed logs to stdout rather than the log file, avoid registering
239 // handlers for both `OpenLog` and `RevealLogInFileManager`, as the log file
240 // does not exist in that scenario and these actions would error.
241 if !crate::stdout_is_a_pty() {
242 cx.on_action(|_: &OpenLog, cx| {
243 with_active_or_new_workspace(cx, |workspace, window, cx| {
244 open_log_file(workspace, window, cx);
245 });
246 })
247 .on_action(|_: &workspace::RevealLogInFileManager, cx| {
248 cx.reveal_path(paths::log_file().as_path());
249 });
250 }
251
252 cx.on_action(|_: &zed_actions::OpenLicenses, cx| {
253 with_active_or_new_workspace(cx, |workspace, window, cx| {
254 open_bundled_file(
255 workspace,
256 asset_str::<Assets>("licenses.md"),
257 "Open Source License Attribution",
258 "Markdown",
259 window,
260 cx,
261 );
262 });
263 })
264 .on_action(|&zed_actions::OpenKeymapFile, cx| {
265 with_active_or_new_workspace(cx, |_, window, cx| {
266 open_settings_file(
267 paths::keymap_file(),
268 || settings::initial_keymap_content().as_ref().into(),
269 window,
270 cx,
271 );
272 });
273 })
274 .on_action(|_: &OpenSettingsFile, cx| {
275 with_active_or_new_workspace(cx, |_, window, cx| {
276 open_settings_file(
277 paths::settings_file(),
278 || settings::initial_user_settings_content().as_ref().into(),
279 window,
280 cx,
281 );
282 });
283 })
284 .on_action(|_: &OpenAccountSettings, cx| {
285 with_active_or_new_workspace(cx, |_, _, cx| {
286 cx.open_url(&zed_urls::account_url(cx));
287 });
288 })
289 .on_action(|_: &OpenTasks, cx| {
290 with_active_or_new_workspace(cx, |_, window, cx| {
291 open_settings_file(
292 paths::tasks_file(),
293 || settings::initial_tasks_content().as_ref().into(),
294 window,
295 cx,
296 );
297 });
298 })
299 .on_action(|_: &OpenDebugTasks, cx| {
300 with_active_or_new_workspace(cx, |_, window, cx| {
301 open_settings_file(
302 paths::debug_scenarios_file(),
303 || settings::initial_debug_tasks_content().as_ref().into(),
304 window,
305 cx,
306 );
307 });
308 })
309 .on_action(|_: &ShowDefaultSemanticTokenRules, cx| {
310 with_active_or_new_workspace(cx, |workspace, window, cx| {
311 open_bundled_file(
312 workspace,
313 settings::default_semantic_token_rules(),
314 "Default Semantic Token Rules",
315 "JSONC",
316 window,
317 cx,
318 );
319 });
320 })
321 .on_action(|_: &OpenDefaultSettings, cx| {
322 with_active_or_new_workspace(cx, |workspace, window, cx| {
323 open_bundled_file(
324 workspace,
325 settings::default_settings(),
326 "Default Settings",
327 "JSON",
328 window,
329 cx,
330 );
331 });
332 })
333 .on_action(|_: &zed_actions::OpenDefaultKeymap, cx| {
334 with_active_or_new_workspace(cx, |workspace, window, cx| {
335 open_bundled_file(
336 workspace,
337 settings::default_keymap(),
338 "Default Key Bindings",
339 "JSON",
340 window,
341 cx,
342 );
343 });
344 })
345 .on_action(|_: &About, cx| {
346 open_about_window(cx);
347 });
348}
349
350fn bind_on_window_closed(cx: &mut App) -> Option<gpui::Subscription> {
351 #[cfg(target_os = "macos")]
352 {
353 WorkspaceSettings::get_global(cx)
354 .on_last_window_closed
355 .is_quit_app()
356 .then(|| {
357 cx.on_window_closed(|cx, _window_id| {
358 if cx.windows().is_empty() {
359 cx.quit();
360 }
361 })
362 })
363 }
364 #[cfg(not(target_os = "macos"))]
365 {
366 Some(cx.on_window_closed(|cx, _window_id| {
367 if cx.windows().is_empty() {
368 cx.quit();
369 }
370 }))
371 }
372}
373
374pub fn build_window_options(display_uuid: Option<Uuid>, cx: &mut App) -> WindowOptions {
375 let display = display_uuid.and_then(|uuid| {
376 cx.displays()
377 .into_iter()
378 .find(|display| display.uuid().ok() == Some(uuid))
379 });
380 let app_id = ReleaseChannel::global(cx).app_id();
381 let window_decorations = match std::env::var("ZED_WINDOW_DECORATIONS") {
382 Ok(val) if val == "server" => gpui::WindowDecorations::Server,
383 Ok(val) if val == "client" => gpui::WindowDecorations::Client,
384 _ => match WorkspaceSettings::get_global(cx).window_decorations {
385 settings::WindowDecorations::Server => gpui::WindowDecorations::Server,
386 settings::WindowDecorations::Client => gpui::WindowDecorations::Client,
387 },
388 };
389
390 let use_system_window_tabs = WorkspaceSettings::get_global(cx).use_system_window_tabs;
391
392 #[cfg(any(target_os = "linux", target_os = "freebsd"))]
393 static APP_ICON: std::sync::LazyLock<Option<std::sync::Arc<image::RgbaImage>>> =
394 std::sync::LazyLock::new(|| {
395 // this shouldn't fail since decode is checked in build.rs
396 const BYTES: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/app_icon.png"));
397 util::maybe!({
398 let image = image::ImageReader::new(std::io::Cursor::new(BYTES))
399 .with_guessed_format()?
400 .decode()?
401 .into();
402 anyhow::Ok(Arc::new(image))
403 })
404 .log_err()
405 });
406
407 WindowOptions {
408 titlebar: Some(TitlebarOptions {
409 title: None,
410 appears_transparent: true,
411 traffic_light_position: Some(point(px(9.0), px(9.0))),
412 }),
413 window_bounds: None,
414 focus: false,
415 show: false,
416 kind: WindowKind::Normal,
417 is_movable: true,
418 // Zed draws its own titlebar and moves the window via [`Window::start_window_move`],
419 // so on macOS AppKit should not own titlebar dragging. This avoids the titlebar
420 // click delay from AppKit's drag disambiguation (first observed on macOS 27) while
421 // keeping the window movable and the Window-menu tiling items enabled. No-op on
422 // other platforms.
423 app_owns_titlebar_drag: true,
424 display_id: display.map(|display| display.id()),
425 window_background: cx.theme().window_background_appearance(),
426 app_id: Some(app_id.to_owned()),
427 #[cfg(any(target_os = "linux", target_os = "freebsd"))]
428 icon: APP_ICON.as_ref().cloned(),
429 window_decorations: Some(window_decorations),
430 window_min_size: Some(gpui::Size {
431 width: px(360.0),
432 height: px(240.0),
433 }),
434 tabbing_identifier: if use_system_window_tabs {
435 Some(String::from("zed"))
436 } else {
437 None
438 },
439 ..Default::default()
440 }
441}
442
443pub fn initialize_workspace(app_state: Arc<AppState>, cx: &mut App) {
444 let mut _on_close_subscription = bind_on_window_closed(cx);
445 cx.observe_global::<SettingsStore>(move |cx| {
446 // A 1.92 regression causes unused-assignment to trigger on this variable.
447 _ = _on_close_subscription.is_some();
448 _on_close_subscription = bind_on_window_closed(cx);
449 })
450 .detach();
451
452 init_cursor_hide_mode(cx);
453 init_reduce_motion(cx);
454
455 cx.observe_new(|_multi_workspace: &mut MultiWorkspace, window, cx| {
456 let Some(window) = window else {
457 return;
458 };
459
460 #[cfg(feature = "track-project-leak")]
461 {
462 let multi_workspace_handle = cx.weak_entity();
463 let workspace_handle = _multi_workspace.workspace().downgrade();
464 let project_handle = _multi_workspace.workspace().read(cx).project().downgrade();
465 let window_id_2 = window.window_handle().window_id();
466 cx.on_window_closed(move |cx, window_id| {
467 let multi_workspace_handle = multi_workspace_handle.clone();
468 let workspace_handle = workspace_handle.clone();
469 let project_handle = project_handle.clone();
470 if window_id != window_id_2 {
471 return;
472 }
473 cx.spawn(async move |cx| {
474 cx.background_executor()
475 .timer(std::time::Duration::from_millis(1500))
476 .await;
477
478 multi_workspace_handle.assert_released();
479 workspace_handle.assert_released();
480 project_handle.assert_released();
481 })
482 .detach();
483 })
484 .detach();
485 }
486
487 cx.spawn_in(window, async move |_this, cx| {
488 const TELEMETRY_INTERVAL: std::time::Duration = std::time::Duration::from_secs(5 * 60);
489 loop {
490 cx.background_executor().timer(TELEMETRY_INTERVAL).await;
491 if cx
492 .update(|window, cx| {
493 input_latency_ui::report_input_latency_telemetry(window, cx);
494 })
495 .is_err()
496 {
497 break;
498 }
499 }
500 })
501 .detach();
502
503 let multi_workspace_handle = cx.entity().downgrade();
504 window.on_window_should_close(cx, move |window, cx| {
505 multi_workspace_handle
506 .update(cx, |multi_workspace, cx| {
507 // We'll handle closing asynchronously
508 multi_workspace.close_window(&CloseWindow, window, cx);
509 false
510 })
511 .unwrap_or(true)
512 });
513
514 let window_handle = window.window_handle();
515 let multi_workspace_handle = cx.entity();
516 cx.subscribe_in(
517 &multi_workspace_handle,
518 window,
519 |this, _multi_workspace, event: &workspace::MultiWorkspaceEvent, window, cx| {
520 let workspace::MultiWorkspaceEvent::ActiveWorkspaceChanged { source_workspace } =
521 event
522 else {
523 return;
524 };
525
526 let active_workspace = this.workspace().clone();
527 let source_workspace = source_workspace.clone();
528 active_workspace.update(cx, |workspace, cx| {
529 if let Some(ref source) = source_workspace {
530 if let Some(panel) = workspace.panel::<agent_ui::AgentPanel>(cx) {
531 panel.update(cx, |panel, cx| {
532 panel.initialize_from_source_workspace_if_needed(
533 source.clone(),
534 window,
535 cx,
536 );
537 });
538 }
539 }
540
541 ensure_agent_panel_for_workspace(workspace, source_workspace, window, cx)
542 .detach_and_log_err(cx);
543 });
544 },
545 )
546 .detach();
547
548 cx.defer(move |cx| {
549 window_handle
550 .update(cx, |_, window, cx| {
551 let sidebar =
552 cx.new(|cx| Sidebar::new(multi_workspace_handle.clone(), window, cx));
553 multi_workspace_handle.update(cx, |multi_workspace, cx| {
554 multi_workspace.register_sidebar(sidebar, cx);
555 });
556 })
557 .ok();
558 });
559 })
560 .detach();
561
562 cx.observe_new(move |workspace: &mut Workspace, window, cx| {
563 let Some(window) = window else {
564 return;
565 };
566
567 let workspace_handle = cx.entity();
568 let center_pane = workspace.active_pane().clone();
569 initialize_pane(workspace, ¢er_pane, window, cx);
570
571 cx.subscribe_in(&workspace_handle, window, {
572 move |workspace, _, event, window, cx| match event {
573 workspace::Event::PaneAdded(pane) => {
574 initialize_pane(workspace, pane, window, cx);
575 }
576 workspace::Event::OpenBundledFile {
577 text,
578 title,
579 language,
580 } => open_bundled_file(workspace, text.clone(), title, language, window, cx),
581 _ => {}
582 }
583 })
584 .detach();
585
586 #[cfg(not(any(test, target_os = "macos")))]
587 initialize_file_watcher(window, cx);
588
589 if let Some(specs) = window.gpu_specs() {
590 log::info!("Using GPU: {:?}", specs);
591 show_software_emulation_warning_if_needed(specs.clone(), window, cx);
592 if let Some(crash_client) = cx.try_global::<CrashHandler>() {
593 crashes::set_gpu_info(&crash_client.0, specs);
594 }
595 }
596
597 let edit_prediction_menu_handle = PopoverMenuHandle::default();
598 let edit_prediction_ui = cx.new(|cx| {
599 edit_prediction_ui::EditPredictionButton::new(
600 app_state.fs.clone(),
601 app_state.user_store.clone(),
602 edit_prediction_menu_handle.clone(),
603 workspace.project().clone(),
604 cx,
605 )
606 });
607 workspace.register_action({
608 move |_, _: &edit_prediction_ui::ToggleMenu, window, cx| {
609 edit_prediction_menu_handle.toggle(window, cx);
610 }
611 });
612
613 let search_button = cx.new(|_| search::search_status_button::SearchButton::new());
614 let diagnostic_summary =
615 cx.new(|cx| diagnostics::items::DiagnosticIndicator::new(workspace, cx));
616 let active_file_name = cx.new(|_| workspace::active_file_name::ActiveFileName::new());
617 let activity_indicator = activity_indicator::ActivityIndicator::new(
618 workspace,
619 workspace.project().read(cx).languages().clone(),
620 window,
621 cx,
622 );
623 let active_buffer_encoding =
624 cx.new(|_| encoding_selector::ActiveBufferEncoding::new(workspace));
625 let active_buffer_language =
626 cx.new(|_| language_selector::ActiveBufferLanguage::new(workspace));
627 let active_toolchain_language =
628 cx.new(|cx| toolchain_selector::ActiveToolchain::new(workspace, window, cx));
629 let vim_mode_indicator = cx.new(|cx| vim::ModeIndicator::new(window, cx));
630 let image_info = cx.new(|_cx| ImageInfo::new(workspace));
631
632 let lsp_button_menu_handle = PopoverMenuHandle::default();
633 let lsp_button =
634 cx.new(|cx| LspButton::new(workspace, lsp_button_menu_handle.clone(), window, cx));
635 workspace.register_action({
636 move |_, _: &lsp_button::ToggleMenu, window, cx| {
637 lsp_button_menu_handle.toggle(window, cx);
638 }
639 });
640
641 let cursor_position =
642 cx.new(|_| go_to_line::cursor_position::CursorPosition::new(workspace));
643 let line_ending_indicator =
644 cx.new(|_| line_ending_selector::LineEndingIndicator::default());
645 let git_blame_status = cx.new(|_| git_ui::GitBlameStatus::default());
646 let merge_conflict_indicator =
647 cx.new(|cx| git_ui::MergeConflictIndicator::new(workspace, cx));
648 workspace.status_bar().update(cx, |status_bar, cx| {
649 status_bar.add_left_item(search_button, window, cx);
650 status_bar.add_left_item(lsp_button, window, cx);
651 status_bar.add_left_item(diagnostic_summary, window, cx);
652 status_bar.add_left_item(active_file_name, window, cx);
653 status_bar.add_left_item(git_blame_status, window, cx);
654 status_bar.add_left_item(merge_conflict_indicator, window, cx);
655 status_bar.add_left_item(activity_indicator, window, cx);
656 status_bar.add_right_item(edit_prediction_ui, window, cx);
657 status_bar.add_right_item(active_buffer_encoding, window, cx);
658 status_bar.add_right_item(active_buffer_language, window, cx);
659 status_bar.add_right_item(active_toolchain_language, window, cx);
660 status_bar.add_right_item(line_ending_indicator, window, cx);
661 status_bar.add_right_item(vim_mode_indicator, window, cx);
662 status_bar.add_right_item(cursor_position, window, cx);
663 status_bar.add_right_item(image_info, window, cx);
664 });
665
666 let panels_task = initialize_panels(window, cx);
667 workspace.set_panels_task(panels_task);
668 register_actions(app_state.clone(), workspace, window, cx);
669
670 if !workspace.has_active_modal(window, cx) {
671 workspace.focus_handle(cx).focus(window, cx);
672 }
673 })
674 .detach();
675}
676
677#[cfg(any(target_os = "linux", target_os = "freebsd"))]
678#[allow(unused)]
679fn initialize_file_watcher(window: &mut Window, cx: &mut Context<Workspace>) {
680 if let Err(e) = fs::fs_watcher::global(|_| {}) {
681 let message = format!(
682 db::indoc! {r#"
683 inotify_init returned {}
684
685 This may be due to system-wide limits on inotify instances. For troubleshooting see: https://zed.dev/docs/linux
686 "#},
687 e
688 );
689 let prompt = window.prompt(
690 PromptLevel::Critical,
691 "Could not start inotify",
692 Some(&message),
693 &["Troubleshoot and Quit"],
694 cx,
695 );
696 cx.spawn(async move |_, cx| {
697 if prompt.await == Ok(0) {
698 cx.update(|cx| {
699 cx.open_url("https://zed.dev/docs/linux#could-not-start-inotify");
700 cx.quit();
701 });
702 }
703 })
704 .detach()
705 }
706}
707
708#[cfg(target_os = "windows")]
709#[allow(unused)]
710fn initialize_file_watcher(window: &mut Window, cx: &mut Context<Workspace>) {
711 if let Err(e) = fs::fs_watcher::global(|_| {}) {
712 let message = format!(
713 db::indoc! {r#"
714 ReadDirectoryChangesW initialization failed: {}
715
716 This may occur on network filesystems and WSL paths. For troubleshooting see: https://zed.dev/docs/windows
717 "#},
718 e
719 );
720 let prompt = window.prompt(
721 PromptLevel::Critical,
722 "Could not start ReadDirectoryChangesW",
723 Some(&message),
724 &["Troubleshoot and Quit"],
725 cx,
726 );
727 cx.spawn(async move |_, cx| {
728 if prompt.await == Ok(0) {
729 cx.update(|cx| {
730 cx.open_url("https://zed.dev/docs/windows");
731 cx.quit()
732 });
733 }
734 })
735 .detach()
736 }
737}
738
739fn show_software_emulation_warning_if_needed(
740 specs: gpui::GpuSpecs,
741 window: &mut Window,
742 cx: &mut Context<Workspace>,
743) {
744 if specs.is_software_emulated && std::env::var("ZED_ALLOW_EMULATED_GPU").is_err() {
745 let (graphics_api, docs_url, open_url) = if cfg!(target_os = "windows") {
746 (
747 "DirectX",
748 app_identity::PRODUCT_DOCS_URL,
749 app_identity::PRODUCT_DOCS_URL,
750 )
751 } else {
752 (
753 "Vulkan",
754 app_identity::PRODUCT_DOCS_URL,
755 app_identity::PRODUCT_DOCS_URL,
756 )
757 };
758 let message = format!(
759 db::indoc! {r#"
760 Omega uses {} for rendering and requires a compatible GPU.
761
762 Currently you are using a software emulated GPU ({}) which
763 will result in awful performance.
764
765 For troubleshooting see: {}
766 Set ZED_ALLOW_EMULATED_GPU=1 env var to permanently override.
767 "#},
768 graphics_api, specs.device_name, docs_url
769 );
770 let prompt = window.prompt(
771 PromptLevel::Critical,
772 "Unsupported GPU",
773 Some(&message),
774 &["Skip", "Troubleshoot and Quit"],
775 cx,
776 );
777 cx.spawn(async move |_, cx| {
778 if prompt.await == Ok(1) {
779 cx.update(|cx| {
780 cx.open_url(open_url);
781 cx.quit();
782 });
783 }
784 })
785 .detach()
786 }
787}
788
789fn initialize_panels(window: &mut Window, cx: &mut Context<Workspace>) -> Task<anyhow::Result<()>> {
790 cx.spawn_in(window, async move |workspace_handle, cx| {
791 let project_panel = ProjectPanel::load(workspace_handle.clone(), cx.clone());
792 let outline_panel = OutlinePanel::load(workspace_handle.clone(), cx.clone());
793 let terminal_panel = TerminalPanel::load(workspace_handle.clone(), cx.clone());
794 let git_panel = GitPanel::load(workspace_handle.clone(), cx.clone());
795 let debug_panel = DebugPanel::load(workspace_handle.clone(), cx);
796 let agent_computer_panel = AgentComputerPanel::load(workspace_handle.clone(), cx.clone());
797 let sarah_workroom_panel = SarahWorkroomPanel::load(workspace_handle.clone(), cx.clone());
798
799 async fn add_panel_when_ready(
800 panel_task: impl Future<Output = anyhow::Result<Entity<impl workspace::Panel>>> + 'static,
801 workspace_handle: WeakEntity<Workspace>,
802 mut cx: gpui::AsyncWindowContext,
803 ) {
804 if let Some(panel) = panel_task.await.context("failed to load panel").log_err()
805 {
806 workspace_handle
807 .update_in(&mut cx, |workspace, window, cx| {
808 workspace.add_panel(panel, window, cx);
809 })
810 .log_err();
811 }
812 }
813
814 futures::join!(
815 add_panel_when_ready(project_panel, workspace_handle.clone(), cx.clone()),
816 add_panel_when_ready(outline_panel, workspace_handle.clone(), cx.clone()),
817 add_panel_when_ready(terminal_panel, workspace_handle.clone(), cx.clone()),
818 add_panel_when_ready(git_panel, workspace_handle.clone(), cx.clone()),
819 add_panel_when_ready(debug_panel, workspace_handle.clone(), cx.clone()),
820 add_panel_when_ready(agent_computer_panel, workspace_handle.clone(), cx.clone()),
821 add_panel_when_ready(sarah_workroom_panel, workspace_handle.clone(), cx.clone()),
822 initialize_agent_panel(workspace_handle.clone(), cx.clone()).map(|r| r.log_err()),
823 );
824
825 if workroom_ui::public_demo_mode() {
826 workspace_handle
827 .update_in(cx, |workspace, window, cx| {
828 workspace.open_panel::<ProjectPanel>(window, cx);
829 workspace.open_panel::<SarahWorkroomPanel>(window, cx);
830 })
831 .log_err();
832 }
833
834 anyhow::Ok(())
835 })
836}
837
838fn setup_or_teardown_ai_panel<P: Panel>(
839 workspace: &mut Workspace,
840 window: &mut Window,
841 cx: &mut Context<Workspace>,
842 load_panel: impl FnOnce(
843 WeakEntity<Workspace>,
844 AsyncWindowContext,
845 ) -> Task<anyhow::Result<Entity<P>>>
846 + 'static,
847) -> Task<anyhow::Result<()>> {
848 let disable_ai = SettingsStore::global(cx)
849 .get::<DisableAiSettings>(None)
850 .disable_ai
851 || cfg!(test);
852 let existing_panel = workspace.panel::<P>(cx);
853 match (disable_ai, existing_panel) {
854 (false, None) => cx.spawn_in(window, async move |workspace, cx| {
855 let panel = load_panel(workspace.clone(), cx.clone()).await?;
856 workspace.update_in(cx, |workspace, window, cx| {
857 let disable_ai = SettingsStore::global(cx)
858 .get::<DisableAiSettings>(None)
859 .disable_ai;
860 let have_panel = workspace.panel::<P>(cx).is_some();
861 if !disable_ai && !have_panel {
862 workspace.add_panel(panel, window, cx);
863 }
864 })
865 }),
866 (true, Some(existing_panel)) => {
867 workspace.remove_panel::<P>(&existing_panel, window, cx);
868 Task::ready(Ok(()))
869 }
870 _ => Task::ready(Ok(())),
871 }
872}
873
874fn ensure_agent_panel_for_workspace(
875 workspace: &mut Workspace,
876 source_workspace: Option<WeakEntity<Workspace>>,
877 window: &mut Window,
878 cx: &mut Context<Workspace>,
879) -> Task<anyhow::Result<()>> {
880 let task = setup_or_teardown_ai_panel(workspace, window, cx, move |workspace, cx| {
881 agent_ui::AgentPanel::load(workspace, cx)
882 });
883
884 cx.spawn_in(window, async move |workspace, cx| {
885 task.await?;
886 workspace.update_in(cx, |workspace, window, cx| {
887 if let Some(source_workspace) = source_workspace.clone()
888 && let Some(panel) = workspace.panel::<agent_ui::AgentPanel>(cx)
889 {
890 panel.update(cx, |panel, cx| {
891 panel.initialize_from_source_workspace_if_needed(source_workspace, window, cx);
892 });
893 }
894 })
895 })
896}
897
898async fn initialize_agent_panel(
899 workspace_handle: WeakEntity<Workspace>,
900 mut cx: AsyncWindowContext,
901) -> anyhow::Result<()> {
902 workspace_handle
903 .update_in(&mut cx, |workspace, window, cx| {
904 ensure_agent_panel_for_workspace(workspace, None, window, cx)
905 })?
906 .await?;
907
908 workspace_handle.update_in(&mut cx, |workspace, window, cx| {
909 cx.observe_global_in::<SettingsStore>(window, move |workspace, window, cx| {
910 ensure_agent_panel_for_workspace(workspace, None, window, cx).detach_and_log_err(cx);
911 })
912 .detach();
913
914 // Register the actions that are shared between `assistant` and `assistant2`.
915 //
916 // We need to do this here instead of within the individual `init`
917 // functions so that we only register the actions once.
918 //
919 // Once we ship `assistant2` we can push this back down into `agent::agent_panel::init`.
920 if !cfg!(test) {
921 workspace
922 .register_action(agent_ui::AgentPanel::toggle_focus)
923 .register_action(agent_ui::AgentPanel::focus)
924 .register_action(agent_ui::AgentPanel::toggle)
925 .register_action(agent_ui::InlineAssistant::inline_assist);
926 }
927 })?;
928
929 anyhow::Ok(())
930}
931
932fn register_actions(
933 app_state: Arc<AppState>,
934 workspace: &mut Workspace,
935 _: &mut Window,
936 cx: &mut Context<Workspace>,
937) {
938 workspace
939 .register_action(|_, _: &OpenDocs, _, cx| cx.open_url(DOCS_URL))
940 .register_action(|_, _: &OpenStatusPage, _, cx| cx.open_url(STATUS_URL))
941 .register_action(|_, _: &GetMerch, _, cx| cx.open_url(MERCH_URL))
942 .register_action(
943 |workspace: &mut Workspace,
944 _: &input_latency_ui::DumpInputLatencyHistogram,
945 window: &mut Window,
946 cx: &mut Context<Workspace>| {
947 let report =
948 input_latency_ui::format_input_latency_report(window, cx);
949 let project = workspace.project().clone();
950 let buffer = project.update(cx, |project, cx| {
951 project.create_local_buffer(&report, None, true, cx)
952 });
953 let editor =
954 cx.new(|cx| Editor::for_buffer(buffer, Some(project), window, cx));
955 workspace.add_item_to_active_pane(Box::new(editor), None, true, window, cx);
956 },
957 )
958 .register_action(
959 |workspace: &mut Workspace,
960 _: &DumpAccessibilityTree,
961 window: &mut Window,
962 cx: &mut Context<Workspace>| {
963 let json = accessibility_tree_dump(window);
964 let language = workspace.app_state().languages.language_for_name("JSON");
965 cx.spawn_in(window, async move |workspace, cx| {
966 let language = language.await.log_err();
967 workspace
968 .update_in(cx, |workspace, window, cx| {
969 let project = workspace.project().clone();
970 let buffer = project.update(cx, |project, cx| {
971 project.create_local_buffer(&json, language, true, cx)
972 });
973 let title = "Accessibility Tree".to_string();
974 let buffer = cx.new(|cx| {
975 MultiBuffer::singleton(buffer, cx).with_title(title.clone())
976 });
977 let editor = cx.new(|cx| {
978 let mut editor = Editor::for_multibuffer(
979 buffer,
980 Some(project),
981 window,
982 cx,
983 );
984 editor.set_breadcrumb_header(title);
985 editor
986 });
987 workspace.add_item_to_active_pane(
988 Box::new(editor),
989 None,
990 true,
991 window,
992 cx,
993 );
994 })
995 .log_err();
996 })
997 .detach();
998 },
999 )
1000 .register_action(
1001 |_workspace: &mut Workspace,
1002 _: &CopyAccessibilityTree,
1003 window: &mut Window,
1004 cx: &mut Context<Workspace>| {
1005 let json = accessibility_tree_dump(window);
1006 cx.write_to_clipboard(ClipboardItem::new_string(json));
1007 },
1008 )
1009 .register_action(|_, _: &Minimize, window, _| {
1010 window.minimize_window();
1011 })
1012 .register_action(|_, _: &Zoom, window, _| {
1013 window.zoom_window();
1014 })
1015 .register_action(|_, _: &ToggleFullScreen, window, _| {
1016 window.toggle_fullscreen();
1017 })
1018 .register_action(|_, action: &OpenAppUrl, _, cx| {
1019 OpenListener::global(cx).open(RawOpenRequest {
1020 urls: vec![String::from(&*action.url)],
1021 ..Default::default()
1022 })
1023 })
1024 .register_action(|workspace, _: &OpenUrlPrompt, window, cx| {
1025 workspace.toggle_modal(window, cx, |window, cx| {
1026 open_url_modal::OpenUrlModal::new(window, cx)
1027 });
1028 })
1029 .register_action(|workspace, action: &OpenBrowser, _window, cx| {
1030 // Parse and validate the URL to ensure it's properly formatted
1031 match url::Url::parse(&action.url) {
1032 Ok(parsed_url) => {
1033 // Use the parsed URL's string representation which is properly escaped
1034 cx.open_url(parsed_url.as_str());
1035 }
1036 Err(e) => {
1037 workspace.show_error(
1038 format!(
1039 "Opening this URL in a browser failed because the URL is invalid: {}\n\nError was: {e}",
1040 action.url
1041 ),
1042 cx,
1043 );
1044 }
1045 }
1046 })
1047 .register_action(|workspace, action: &workspace::Open, window, cx| {
1048 let app_state = workspace.app_state().clone();
1049 let create_new_window = action.create_new_window;
1050 cx.spawn_in(window, async move |this, cx| {
1051 await_identity_ready(app_state.clone(), cx).await?;
1052 this.update_in(cx, |workspace, window, cx| {
1053 telemetry::event!("Project Opened");
1054 workspace::prompt_for_open_path_and_open(
1055 workspace,
1056 app_state,
1057 PathPromptOptions {
1058 files: true,
1059 directories: true,
1060 multiple: true,
1061 prompt: None,
1062 },
1063 create_new_window.unwrap_or_else(|| {
1064 matches!(
1065 WorkspaceSettings::get_global(cx).default_open_behavior,
1066 DefaultOpenBehavior::NewWindow
1067 )
1068 }),
1069 window,
1070 cx,
1071 );
1072 })?;
1073 anyhow::Ok(())
1074 })
1075 .detach_and_log_err(cx);
1076 })
1077 .register_action(|workspace, _: &workspace::OpenFiles, window, cx| {
1078 let app_state = workspace.app_state().clone();
1079 cx.spawn_in(window, async move |this, cx| {
1080 await_identity_ready(app_state.clone(), cx).await?;
1081 this.update_in(cx, |workspace, window, cx| {
1082 let directories = cx.can_select_mixed_files_and_dirs();
1083 workspace::prompt_for_open_path_and_open(
1084 workspace,
1085 app_state,
1086 PathPromptOptions {
1087 files: true,
1088 directories,
1089 multiple: true,
1090 prompt: None,
1091 },
1092 true,
1093 window,
1094 cx,
1095 );
1096 })?;
1097 anyhow::Ok(())
1098 })
1099 .detach_and_log_err(cx);
1100 })
1101 .register_action(|workspace, action: &zed_actions::OpenRemote, window, cx| {
1102 if !action.from_existing_connection {
1103 cx.propagate();
1104 return;
1105 }
1106 // You need existing remote connection to open it this way
1107 if workspace.project().read(cx).is_local() {
1108 return;
1109 }
1110 let app_state = workspace.app_state().clone();
1111 cx.spawn_in(window, async move |this, cx| {
1112 await_identity_ready(app_state, cx).await?;
1113 let paths = this.update_in(cx, |workspace, window, cx| {
1114 telemetry::event!("Project Opened");
1115 workspace.prompt_for_open_path(
1116 PathPromptOptions {
1117 files: true,
1118 directories: true,
1119 multiple: true,
1120 prompt: None,
1121 },
1122 DirectoryLister::Project(workspace.project().clone()),
1123 window,
1124 cx,
1125 )
1126 })?;
1127 let Some(paths) = paths.await.log_err().flatten() else {
1128 return anyhow::Ok(());
1129 };
1130 if let Some(task) = this
1131 .update_in(cx, |this, window, cx| {
1132 open_new_ssh_project_from_project(this, paths, window, cx)
1133 })
1134 .log_err()
1135 {
1136 task.await.log_err();
1137 }
1138 anyhow::Ok(())
1139 })
1140 .detach_and_log_err(cx)
1141 })
1142 .register_action({
1143 let fs = app_state.fs.clone();
1144 move |_, action: &zed_actions::IncreaseUiFontSize, _window, cx| {
1145 if action.persist {
1146 update_settings_file(fs.clone(), cx, move |settings, cx| {
1147 let ui_font_size = ThemeSettings::get_global(cx).ui_font_size(cx) + px(1.0);
1148 let _ = settings
1149 .theme
1150 .ui_font_size
1151 .insert(f32::from(theme_settings::clamp_font_size(ui_font_size)).into());
1152 });
1153 } else {
1154 theme_settings::adjust_ui_font_size(cx, |size| size + px(1.0));
1155 }
1156 }
1157 })
1158 .register_action({
1159 let fs = app_state.fs.clone();
1160 move |_, action: &zed_actions::DecreaseUiFontSize, _window, cx| {
1161 if action.persist {
1162 update_settings_file(fs.clone(), cx, move |settings, cx| {
1163 let ui_font_size = ThemeSettings::get_global(cx).ui_font_size(cx) - px(1.0);
1164 let _ = settings
1165 .theme
1166 .ui_font_size
1167 .insert(f32::from(theme_settings::clamp_font_size(ui_font_size)).into());
1168 });
1169 } else {
1170 theme_settings::adjust_ui_font_size(cx, |size| size - px(1.0));
1171 }
1172 }
1173 })
1174 .register_action({
1175 let fs = app_state.fs.clone();
1176 move |_, action: &zed_actions::ResetUiFontSize, _window, cx| {
1177 if action.persist {
1178 update_settings_file(fs.clone(), cx, move |settings, _| {
1179 settings.theme.ui_font_size = None;
1180 });
1181 } else {
1182 theme_settings::reset_ui_font_size(cx);
1183 }
1184 }
1185 })
1186 .register_action({
1187 let fs = app_state.fs.clone();
1188 move |_, action: &zed_actions::IncreaseBufferFontSize, _window, cx| {
1189 if action.persist {
1190 update_settings_file(fs.clone(), cx, move |settings, cx| {
1191 let buffer_font_size =
1192 ThemeSettings::get_global(cx).buffer_font_size(cx) + px(1.0);
1193 let _ = settings
1194 .theme
1195 .buffer_font_size
1196 .insert(f32::from(theme_settings::clamp_font_size(buffer_font_size)).into());
1197 });
1198 } else {
1199 theme_settings::increase_buffer_font_size(cx);
1200 }
1201 }
1202 })
1203 .register_action({
1204 let fs = app_state.fs.clone();
1205 move |_, action: &zed_actions::DecreaseBufferFontSize, _window, cx| {
1206 if action.persist {
1207 update_settings_file(fs.clone(), cx, move |settings, cx| {
1208 let buffer_font_size =
1209 ThemeSettings::get_global(cx).buffer_font_size(cx) - px(1.0);
1210 let _ = settings
1211 .theme
1212 .buffer_font_size
1213 .insert(f32::from(theme_settings::clamp_font_size(buffer_font_size)).into());
1214 });
1215 } else {
1216 theme_settings::decrease_buffer_font_size(cx);
1217 }
1218 }
1219 })
1220 .register_action({
1221 let fs = app_state.fs.clone();
1222 move |_, action: &zed_actions::ResetBufferFontSize, _window, cx| {
1223 if action.persist {
1224 update_settings_file(fs.clone(), cx, move |settings, _| {
1225 settings.theme.buffer_font_size = None;
1226 });
1227 } else {
1228 theme_settings::reset_buffer_font_size(cx);
1229 }
1230 }
1231 })
1232 .register_action({
1233 let fs = app_state.fs.clone();
1234 move |_, action: &zed_actions::ResetAllZoom, _window, cx| {
1235 if action.persist {
1236 update_settings_file(fs.clone(), cx, move |settings, _| {
1237 settings.theme.ui_font_size = None;
1238 settings.theme.buffer_font_size = None;
1239 settings.theme.agent_ui_font_size = None;
1240 settings.theme.agent_buffer_font_size = None;
1241 });
1242 } else {
1243 theme_settings::reset_ui_font_size(cx);
1244 theme_settings::reset_buffer_font_size(cx);
1245 theme_settings::reset_agent_ui_font_size(cx);
1246 theme_settings::reset_agent_buffer_font_size(cx);
1247 }
1248 }
1249 })
1250 .register_action(|_, _: &install_cli::RegisterAppScheme, window, cx| {
1251 cx.spawn_in(window, async move |workspace, cx| {
1252 install_cli::register_app_scheme(cx).await?;
1253 workspace.update_in(cx, |workspace, _, cx| {
1254 struct RegisterAppScheme;
1255
1256 workspace.show_toast(
1257 Toast::new(
1258 NotificationId::unique::<RegisterAppScheme>(),
1259 format!(
1260 "{}:// links will now open in {}.",
1261 ReleaseChannel::global(cx).protocol_scheme(),
1262 ReleaseChannel::global(cx).display_name()
1263 ),
1264 ),
1265 cx,
1266 )
1267 })?;
1268 Ok(())
1269 })
1270 .detach_and_prompt_err(
1271 "Error registering Omega URL scheme",
1272 window,
1273 cx,
1274 |_, _, _| None,
1275 );
1276 })
1277 .register_action(open_project_settings_file)
1278 .register_action(open_project_tasks_file)
1279 .register_action(open_worktree_setup_tasks_file)
1280 .register_action(open_project_debug_tasks_file)
1281 .register_action(
1282 |workspace: &mut Workspace,
1283 _: &zed_actions::project_panel::ToggleFocus,
1284 window: &mut Window,
1285 cx: &mut Context<Workspace>| {
1286 workspace.toggle_panel_focus::<ProjectPanel>(window, cx);
1287 },
1288 )
1289 .register_action(
1290 |workspace: &mut Workspace,
1291 _: &outline_panel::ToggleFocus,
1292 window: &mut Window,
1293 cx: &mut Context<Workspace>| {
1294 workspace.toggle_panel_focus::<OutlinePanel>(window, cx);
1295 },
1296 )
1297 .register_action(
1298 |workspace: &mut Workspace,
1299 _: &terminal_panel::ToggleFocus,
1300 window: &mut Window,
1301 cx: &mut Context<Workspace>| {
1302 workspace.toggle_panel_focus::<TerminalPanel>(window, cx);
1303 },
1304 )
1305 .register_action({
1306 let app_state = app_state.clone();
1307 move |_, _: &NewWindow, _, cx| {
1308 cx.spawn({
1309 let app_state = app_state.clone();
1310 async move |_, cx| {
1311 await_identity_ready(app_state.clone(), cx).await?;
1312 cx.update(|cx| {
1313 open_new(
1314 Default::default(),
1315 app_state,
1316 cx,
1317 |workspace, window, cx| {
1318 cx.activate(true);
1319 // Create buffer synchronously to avoid flicker
1320 let project = workspace.project().clone();
1321 let buffer = project.update(cx, |project, cx| {
1322 project.create_local_buffer("", None, true, cx)
1323 });
1324 let editor = cx.new(|cx| {
1325 Editor::for_buffer(buffer, Some(project), window, cx)
1326 });
1327 workspace.add_item_to_active_pane(
1328 Box::new(editor),
1329 None,
1330 true,
1331 window,
1332 cx,
1333 );
1334 },
1335 )
1336 .detach();
1337 });
1338 anyhow::Ok(())
1339 }
1340 })
1341 .detach_and_log_err(cx);
1342 }
1343 })
1344 .register_action({
1345 move |workspace, _: &CloseProject, window, cx| {
1346 let Some(window_handle) = window.window_handle().downcast::<MultiWorkspace>() else {
1347 return;
1348 };
1349 let old_group_key = workspace.project_group_key(cx);
1350 cx.spawn_in(window, async move |_, cx| {
1351 let task = window_handle.update(cx, |multi_workspace, window, cx| {
1352 multi_workspace.remove_project_group(&old_group_key, window, cx)
1353 })?;
1354 task.await?;
1355 anyhow::Ok(())
1356 })
1357 .detach_and_log_err(cx);
1358 }
1359 })
1360 .register_action({
1361 let app_state = app_state.clone();
1362 move |_, _: &NewFile, _, cx| {
1363 cx.spawn({
1364 let app_state = app_state.clone();
1365 async move |_, cx| {
1366 await_identity_ready(app_state.clone(), cx).await?;
1367 cx.update(|cx| {
1368 open_new(
1369 Default::default(),
1370 app_state,
1371 cx,
1372 |workspace, window, cx| {
1373 Editor::new_file(
1374 workspace,
1375 &Default::default(),
1376 window,
1377 cx,
1378 )
1379 },
1380 )
1381 .detach_and_log_err(cx);
1382 });
1383 anyhow::Ok(())
1384 }
1385 })
1386 .detach_and_log_err(cx);
1387 }
1388 });
1389
1390 #[cfg(not(target_os = "windows"))]
1391 workspace.register_action(install_cli);
1392
1393 if workspace.project().read(cx).is_via_remote_server() {
1394 workspace.register_action({
1395 move |workspace, _: &OpenServerSettings, window, cx| {
1396 let open_server_settings = workspace
1397 .project()
1398 .update(cx, |project, cx| project.open_server_settings(cx));
1399
1400 cx.spawn_in(window, async move |workspace, cx| {
1401 let buffer = open_server_settings.await?;
1402
1403 workspace
1404 .update_in(cx, |workspace, window, cx| {
1405 workspace.open_path(
1406 buffer
1407 .read(cx)
1408 .project_path(cx)
1409 .expect("Settings file must have a location"),
1410 None,
1411 true,
1412 window,
1413 cx,
1414 )
1415 })?
1416 .await?;
1417
1418 anyhow::Ok(())
1419 })
1420 .detach_and_log_err(cx);
1421 }
1422 });
1423 }
1424
1425 workspace.register_action(sidebar::dump_workspace_info);
1426
1427 #[cfg(debug_assertions)]
1428 workspace.register_action(|workspace, _: &ShowWorkspaceError, _, cx| {
1429 struct DebugError;
1430 struct SecondDebugError;
1431
1432 impl WorkspaceError for DebugError {
1433 fn primary_message(&self) -> SharedString {
1434 SharedString::new_static(
1435 "Error: Prepare rename via rust-analyzer failed: No references found at position",
1436 )
1437 }
1438
1439 fn severity(&self) -> ErrorSeverity {
1440 ErrorSeverity::Warning
1441 }
1442
1443 fn primary_action(&self) -> ErrorAction {
1444 ErrorAction::dismiss()
1445 }
1446 }
1447
1448 impl WorkspaceError for SecondDebugError {
1449 fn primary_message(&self) -> SharedString {
1450 SharedString::new_static("This is some error to ignore.")
1451 }
1452
1453 fn severity(&self) -> ErrorSeverity {
1454 ErrorSeverity::Error
1455 }
1456
1457 fn primary_action(&self) -> ErrorAction {
1458 ErrorAction::dismiss()
1459 }
1460 }
1461
1462 workspace.show_error(DebugError, cx);
1463 workspace.show_error(SecondDebugError, cx);
1464 });
1465}
1466
1467fn initialize_pane(
1468 workspace: &Workspace,
1469 pane: &Entity<Pane>,
1470 window: &mut Window,
1471 cx: &mut Context<Workspace>,
1472) {
1473 let workspace_handle = cx.weak_entity();
1474 pane.update(cx, |pane, cx| {
1475 pane.toolbar().update(cx, |toolbar, cx| {
1476 let multibuffer_hint = cx.new(|_| MultibufferHint::new());
1477 toolbar.add_item(multibuffer_hint, window, cx);
1478 let solo_diff_style_toolbar = cx.new(SoloDiffStyleToolbar::new);
1479 toolbar.add_item(solo_diff_style_toolbar, window, cx);
1480 let breadcrumbs = cx.new(|_| Breadcrumbs::new());
1481 toolbar.add_item(breadcrumbs, window, cx);
1482 let buffer_search_bar = cx.new(|cx| {
1483 search::BufferSearchBar::new(
1484 Some(workspace.project().read(cx).languages().clone()),
1485 window,
1486 cx,
1487 )
1488 });
1489 toolbar.add_item(buffer_search_bar.clone(), window, cx);
1490 let quick_action_bar =
1491 cx.new(|cx| QuickActionBar::new(buffer_search_bar, workspace, cx));
1492 toolbar.add_item(quick_action_bar, window, cx);
1493 let diagnostic_editor_controls = cx.new(|_| diagnostics::ToolbarControls::new());
1494 toolbar.add_item(diagnostic_editor_controls, window, cx);
1495 let project_search_bar = cx.new(|_| ProjectSearchBar::new());
1496 toolbar.add_item(project_search_bar, window, cx);
1497 let lsp_log_item = cx.new(|_| LspLogToolbarItemView::new());
1498 toolbar.add_item(lsp_log_item, window, cx);
1499 let dap_log_item = cx.new(|_| debugger_tools::DapLogToolbarItemView::new());
1500 toolbar.add_item(dap_log_item, window, cx);
1501 let acp_tools_item = cx.new(|_| acp_tools::AcpToolsToolbarItemView::new());
1502 toolbar.add_item(acp_tools_item, window, cx);
1503 let telemetry_log_item =
1504 cx.new(|cx| telemetry_log::TelemetryLogToolbarItemView::new(window, cx));
1505 toolbar.add_item(telemetry_log_item, window, cx);
1506 let syntax_tree_item = cx.new(|_| language_tools::SyntaxTreeToolbarItemView::new());
1507 toolbar.add_item(syntax_tree_item, window, cx);
1508 let migration_banner =
1509 cx.new(|inner_cx| MigrationBanner::new(workspace_handle.clone(), inner_cx));
1510 toolbar.add_item(migration_banner, window, cx);
1511 let highlights_tree_item =
1512 cx.new(|_| language_tools::HighlightsTreeToolbarItemView::new());
1513 toolbar.add_item(highlights_tree_item, window, cx);
1514 let project_diff_toolbar = cx.new(|cx| ProjectDiffToolbar::new(workspace, cx));
1515 toolbar.add_item(project_diff_toolbar, window, cx);
1516 let staged_diff_toolbar = cx.new(|cx| StagedDiffToolbar::new(workspace, cx));
1517 toolbar.add_item(staged_diff_toolbar, window, cx);
1518 let unstaged_diff_toolbar = cx.new(|cx| UnstagedDiffToolbar::new(workspace, cx));
1519 toolbar.add_item(unstaged_diff_toolbar, window, cx);
1520 let branch_diff_toolbar = cx.new(BranchDiffToolbar::new);
1521 toolbar.add_item(branch_diff_toolbar, window, cx);
1522 let solo_diff_git_toolbar = cx.new(SoloDiffGitToolbar::new);
1523 toolbar.add_item(solo_diff_git_toolbar, window, cx);
1524 let commit_view_toolbar = cx.new(|_| CommitViewToolbar::new());
1525 toolbar.add_item(commit_view_toolbar, window, cx);
1526 let agent_diff_toolbar = cx.new(AgentDiffToolbar::new);
1527 toolbar.add_item(agent_diff_toolbar, window, cx);
1528 let basedpyright_banner = cx.new(|cx| BasedPyrightBanner::new(workspace, cx));
1529 toolbar.add_item(basedpyright_banner, window, cx);
1530 let image_view_toolbar = cx.new(|_| image_viewer::ImageViewToolbarControls::new());
1531 toolbar.add_item(image_view_toolbar, window, cx);
1532 })
1533 });
1534}
1535
1536fn open_about_window(cx: &mut App) {
1537 fn about_window_icon(release_channel: ReleaseChannel) -> Arc<Image> {
1538 let bytes = match release_channel {
1539 ReleaseChannel::Dev => include_bytes!("../resources/app-icon-dev.png").as_slice(),
1540 ReleaseChannel::Nightly => {
1541 include_bytes!("../resources/app-icon-nightly.png").as_slice()
1542 }
1543 ReleaseChannel::Preview => {
1544 include_bytes!("../resources/app-icon-preview.png").as_slice()
1545 }
1546 ReleaseChannel::Stable => include_bytes!("../resources/app-icon.png").as_slice(),
1547 };
1548
1549 Arc::new(Image::from_bytes(ImageFormat::Png, bytes.to_vec()))
1550 }
1551
1552 struct AboutWindow {
1553 focus_handle: FocusHandle,
1554 ok_entry: NavigableEntry,
1555 copy_entry: NavigableEntry,
1556 app_icon: Arc<Image>,
1557 message: SharedString,
1558 commit: Option<SharedString>,
1559 full_version: SharedString,
1560 }
1561
1562 impl AboutWindow {
1563 fn new(cx: &mut Context<Self>) -> Self {
1564 let release_channel = ReleaseChannel::global(cx);
1565 let release_channel_name = release_channel.display_name();
1566 let full_version: SharedString = AppVersion::global(cx).to_string().into();
1567 let version = env!("CARGO_PKG_VERSION");
1568
1569 let debug = if cfg!(debug_assertions) {
1570 "(debug)"
1571 } else {
1572 ""
1573 };
1574 let message: SharedString = format!("{release_channel_name} {version} {debug}").into();
1575 let commit = AppCommitSha::try_global(cx)
1576 .map(|sha| sha.full())
1577 .filter(|commit| !commit.is_empty())
1578 .map(SharedString::from);
1579
1580 Self {
1581 focus_handle: cx.focus_handle(),
1582 ok_entry: NavigableEntry::focusable(cx),
1583 copy_entry: NavigableEntry::focusable(cx),
1584 app_icon: about_window_icon(release_channel),
1585 message,
1586 commit,
1587 full_version,
1588 }
1589 }
1590
1591 fn copy_details(&self, window: &mut Window, cx: &mut Context<Self>) {
1592 let content = match self.commit.as_ref() {
1593 Some(commit) => {
1594 format!(
1595 "{}\nCommit: {}\nVersion: {}",
1596 self.message, commit, self.full_version
1597 )
1598 }
1599 None => format!("{}\nVersion: {}", self.message, self.full_version),
1600 };
1601 cx.write_to_clipboard(ClipboardItem::new_string(content));
1602 window.remove_window();
1603 }
1604 }
1605
1606 impl Render for AboutWindow {
1607 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1608 let ok_is_focused = self.ok_entry.focus_handle.contains_focused(window, cx);
1609 let copy_is_focused = self.copy_entry.focus_handle.contains_focused(window, cx);
1610
1611 Navigable::new(
1612 v_flex()
1613 .id("about-window")
1614 .track_focus(&self.focus_handle)
1615 .on_action(cx.listener(|_, _: &menu::Cancel, window, _cx| {
1616 window.remove_window();
1617 }))
1618 .min_w_0()
1619 .size_full()
1620 .bg(cx.theme().colors().editor_background)
1621 .text_color(cx.theme().colors().text)
1622 .p_4()
1623 .when(cfg!(target_os = "macos"), |this| this.pt_10())
1624 .gap_4()
1625 .text_center()
1626 .justify_between()
1627 .child(
1628 v_flex()
1629 .w_full()
1630 .gap_2()
1631 .items_center()
1632 .child(img(self.app_icon.clone()).size_16().flex_none())
1633 .child(Headline::new(self.message.clone()))
1634 .when_some(self.commit.clone(), |this, commit| {
1635 this.child(
1636 Label::new("Commit")
1637 .color(Color::Muted)
1638 .size(LabelSize::XSmall),
1639 )
1640 .child(Label::new(commit).size(LabelSize::Small))
1641 })
1642 .child(
1643 Label::new("Version")
1644 .color(Color::Muted)
1645 .size(LabelSize::XSmall),
1646 )
1647 .child(Label::new(self.full_version.clone()).size(LabelSize::Small)),
1648 )
1649 .child(
1650 h_flex()
1651 .w_full()
1652 .gap_1()
1653 .child(
1654 div()
1655 .flex_1()
1656 .track_focus(&self.ok_entry.focus_handle)
1657 .on_action(cx.listener(|_, _: &menu::Confirm, window, _cx| {
1658 window.remove_window();
1659 }))
1660 .child(
1661 Button::new("ok", "OK")
1662 .full_width()
1663 .style(ButtonStyle::OutlinedGhost)
1664 .toggle_state(ok_is_focused)
1665 .selected_style(ButtonStyle::Tinted(TintColor::Accent))
1666 .on_click(cx.listener(|_, _, window, _cx| {
1667 window.remove_window();
1668 })),
1669 ),
1670 )
1671 .child(
1672 div()
1673 .flex_1()
1674 .track_focus(&self.copy_entry.focus_handle)
1675 .on_action(cx.listener(
1676 |this, _: &menu::Confirm, window, cx| {
1677 this.copy_details(window, cx);
1678 },
1679 ))
1680 .child(
1681 Button::new("copy", "Copy")
1682 .full_width()
1683 .style(ButtonStyle::Tinted(TintColor::Accent))
1684 .toggle_state(copy_is_focused)
1685 .selected_style(ButtonStyle::Tinted(TintColor::Accent))
1686 .on_click(cx.listener(|this, _event, window, cx| {
1687 this.copy_details(window, cx);
1688 })),
1689 ),
1690 ),
1691 )
1692 .into_any_element(),
1693 )
1694 .entry(self.ok_entry.clone())
1695 .entry(self.copy_entry.clone())
1696 }
1697 }
1698
1699 impl Focusable for AboutWindow {
1700 fn focus_handle(&self, _cx: &App) -> FocusHandle {
1701 self.ok_entry.focus_handle.clone()
1702 }
1703 }
1704
1705 // Don't open about window twice
1706 if let Some(existing) = cx
1707 .windows()
1708 .into_iter()
1709 .find_map(|w| w.downcast::<AboutWindow>())
1710 {
1711 existing
1712 .update(cx, |about_window, window, cx| {
1713 window.activate_window();
1714 about_window.ok_entry.focus_handle.focus(window, cx);
1715 })
1716 .log_err();
1717 return;
1718 }
1719
1720 let window_size = Size {
1721 width: px(440.),
1722 height: px(300.),
1723 };
1724
1725 cx.open_window(
1726 WindowOptions {
1727 titlebar: Some(TitlebarOptions {
1728 title: Some("About Omega".into()),
1729 appears_transparent: true,
1730 traffic_light_position: Some(point(px(12.), px(12.))),
1731 }),
1732 window_bounds: Some(WindowBounds::centered(window_size, cx)),
1733 is_resizable: false,
1734 is_minimizable: false,
1735 kind: WindowKind::Floating,
1736 app_id: Some(ReleaseChannel::global(cx).app_id().to_owned()),
1737 ..Default::default()
1738 },
1739 |window, cx| {
1740 let about_window = cx.new(AboutWindow::new);
1741 let focus_handle = about_window.read(cx).ok_entry.focus_handle.clone();
1742 window.activate_window();
1743 focus_handle.focus(window, cx);
1744 about_window
1745 },
1746 )
1747 .log_err();
1748}
1749
1750#[cfg(not(target_os = "windows"))]
1751fn install_cli(
1752 _: &mut Workspace,
1753 _: &install_cli::InstallCliBinary,
1754 window: &mut Window,
1755 cx: &mut Context<Workspace>,
1756) {
1757 install_cli::install_cli_binary(window, cx)
1758}
1759
1760static WAITING_QUIT_CONFIRMATION: AtomicBool = AtomicBool::new(false);
1761fn quit(_: &Quit, cx: &mut App) {
1762 if WAITING_QUIT_CONFIRMATION.load(atomic::Ordering::Acquire) {
1763 return;
1764 }
1765
1766 let should_confirm = WorkspaceSettings::get_global(cx).confirm_quit;
1767 cx.spawn(async move |cx| {
1768 let mut workspace_windows: Vec<WindowHandle<MultiWorkspace>> = cx.update(|cx| {
1769 cx.windows()
1770 .into_iter()
1771 .filter_map(|window| window.downcast::<MultiWorkspace>())
1772 .collect::<Vec<_>>()
1773 });
1774
1775 // If multiple windows have unsaved changes, and need a save prompt,
1776 // prompt in the active window before switching to a different window.
1777 cx.update(|cx| {
1778 workspace_windows.sort_by_key(|window| window.is_active(cx) == Some(false));
1779 });
1780
1781 if should_confirm && let Some(multi_workspace) = workspace_windows.first() {
1782 let answer = multi_workspace
1783 .update(cx, |_, window, cx| {
1784 window.prompt(
1785 PromptLevel::Info,
1786 "Are you sure you want to quit?",
1787 None,
1788 &["Quit", "Cancel"],
1789 cx,
1790 )
1791 })
1792 .log_err();
1793
1794 if let Some(answer) = answer {
1795 WAITING_QUIT_CONFIRMATION.store(true, atomic::Ordering::Release);
1796 let answer = answer.await.ok();
1797 WAITING_QUIT_CONFIRMATION.store(false, atomic::Ordering::Release);
1798 if answer != Some(0) {
1799 return Ok(());
1800 }
1801 }
1802 }
1803
1804 // If the user cancels any save prompt, then keep the app open.
1805 for window in &workspace_windows {
1806 let window = *window;
1807 let active_and_workspaces = window
1808 .update(cx, |multi_workspace, _, _cx| {
1809 (
1810 multi_workspace.workspace().clone(),
1811 multi_workspace.workspaces().cloned().collect::<Vec<_>>(),
1812 )
1813 })
1814 .log_err();
1815
1816 let Some((originally_active, workspaces)) = active_and_workspaces else {
1817 continue;
1818 };
1819
1820 for workspace in workspaces {
1821 if let Some(should_close) = window
1822 .update(cx, |multi_workspace, window, cx| {
1823 multi_workspace.activate(workspace.clone(), None, window, cx);
1824 window.activate_window();
1825 workspace.update(cx, |workspace, cx| {
1826 workspace.prepare_to_close(CloseIntent::Quit, window, cx)
1827 })
1828 })
1829 .log_err()
1830 {
1831 if !should_close.await? {
1832 // Activating each workspace above to surface its save
1833 // prompts changed which workspace is active. Restore the
1834 // user's focused workspace before bailing so the window
1835 // is left as they had it.
1836 window
1837 .update(cx, |multi_workspace, window, cx| {
1838 multi_workspace.activate(
1839 originally_active.clone(),
1840 None,
1841 window,
1842 cx,
1843 );
1844 })
1845 .log_err();
1846 return Ok(());
1847 }
1848 }
1849 }
1850
1851 // The loop above activated each workspace in turn, overwriting the
1852 // persisted active workspace. Re-activate the workspace the user
1853 // actually had focused so it is the one serialized (and restored on
1854 // next launch) as active, rather than whichever happened to be last.
1855 window
1856 .update(cx, |multi_workspace, window, cx| {
1857 multi_workspace.activate(originally_active, None, window, cx);
1858 })
1859 .log_err();
1860 }
1861 // Flush all pending workspace serialization before quitting so that
1862 // session_id/window_id are up-to-date in the database.
1863 let mut flush_tasks = Vec::new();
1864 for window in &workspace_windows {
1865 window
1866 .update(cx, |multi_workspace, window, cx| {
1867 for workspace in multi_workspace.workspaces() {
1868 flush_tasks.push(workspace.update(cx, |workspace, cx| {
1869 workspace.flush_serialization(window, cx)
1870 }));
1871 }
1872 flush_tasks.append(&mut multi_workspace.take_pending_removal_tasks());
1873 flush_tasks.push(multi_workspace.flush_serialization());
1874 })
1875 .log_err();
1876 }
1877 futures::future::join_all(flush_tasks).await;
1878
1879 cx.update(|cx| cx.quit());
1880 anyhow::Ok(())
1881 })
1882 .detach_and_log_err(cx);
1883}
1884
1885fn open_log_file(workspace: &mut Workspace, window: &mut Window, cx: &mut Context<Workspace>) {
1886 const MAX_LINES: usize = 1000;
1887 let app_state = workspace.app_state();
1888 let languages = app_state.languages.clone();
1889 let fs = app_state.fs.clone();
1890 cx.spawn_in(window, async move |workspace, cx| {
1891 let log = {
1892 let result = futures::join!(
1893 fs.load(&paths::old_log_file()),
1894 fs.load(&paths::log_file()),
1895 languages.language_for_name("log")
1896 );
1897 match result {
1898 (Err(_), Err(e), _) => Err(e),
1899 (old_log, new_log, lang) => {
1900 let mut lines = VecDeque::with_capacity(MAX_LINES);
1901 for line in old_log
1902 .iter()
1903 .flat_map(|log| log.lines())
1904 .chain(new_log.iter().flat_map(|log| log.lines()))
1905 {
1906 if lines.len() == MAX_LINES {
1907 lines.pop_front();
1908 }
1909 lines.push_back(line);
1910 }
1911 Ok((
1912 lines
1913 .into_iter()
1914 .flat_map(|line| [line, "\n"])
1915 .collect::<String>(),
1916 lang.ok(),
1917 ))
1918 }
1919 }
1920 };
1921
1922 let (log, log_language) = match log {
1923 Ok((log, log_language)) => (log, log_language),
1924 Err(e) => {
1925 struct OpenLogError;
1926
1927 workspace
1928 .update(cx, |workspace, cx| {
1929 workspace.show_notification(
1930 NotificationId::unique::<OpenLogError>(),
1931 cx,
1932 |cx| {
1933 cx.new(|cx| {
1934 MessageNotification::new(
1935 format!(
1936 "Unable to access/open log file at path \
1937 {}: {e:#}",
1938 paths::log_file().display()
1939 ),
1940 cx,
1941 )
1942 })
1943 },
1944 );
1945 })
1946 .ok();
1947 return;
1948 }
1949 };
1950 maybe!(async move {
1951 let project = workspace
1952 .read_with(cx, |workspace, _| workspace.project().clone())
1953 .ok()?;
1954 let buffer = project
1955 .update(cx, |project, cx| {
1956 project.create_buffer(log_language, false, cx)
1957 })
1958 .await
1959 .ok()?;
1960 buffer.update(cx, |buffer, cx| {
1961 buffer.set_capability(Capability::ReadOnly, cx);
1962 buffer.set_text(log, cx);
1963 });
1964
1965 let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx).with_title("Log".into()));
1966
1967 let editor = cx
1968 .new_window_entity(|window, cx| {
1969 let mut editor = Editor::for_multibuffer(buffer, Some(project), window, cx);
1970 editor.set_read_only(true);
1971 editor.set_breadcrumb_header(format!(
1972 "Last {} lines in {}",
1973 MAX_LINES,
1974 paths::log_file().display()
1975 ));
1976 let last_multi_buffer_offset = editor.buffer().read(cx).len(cx);
1977 editor.change_selections(Default::default(), window, cx, |s| {
1978 s.select_ranges(Some(last_multi_buffer_offset..last_multi_buffer_offset));
1979 });
1980 editor
1981 })
1982 .ok()?;
1983
1984 workspace
1985 .update_in(cx, |workspace, window, cx| {
1986 workspace.add_item_to_active_pane(Box::new(editor), None, true, window, cx);
1987 })
1988 .ok()
1989 })
1990 .await;
1991 })
1992 .detach();
1993}
1994
1995fn notify_settings_errors(result: settings::SettingsParseResult, is_user: bool, cx: &mut App) {
1996 if let settings::ParseStatus::Failed { error: err } = &result.parse_status {
1997 let settings_type = if is_user { "user" } else { "global" };
1998 log::error!("Failed to load {} settings: {err}", settings_type);
1999 }
2000
2001 let error = match result.parse_status {
2002 settings::ParseStatus::Failed { error } => Some(anyhow::format_err!(error)),
2003 settings::ParseStatus::Success => None,
2004 settings::ParseStatus::Unchanged => return,
2005 };
2006 let id = NotificationId::Named(format!("failed-to-parse-settings-{is_user}").into());
2007
2008 let showed_parse_error = match error {
2009 Some(error) => {
2010 if let Some(InvalidSettingsError::LocalSettings { .. }) =
2011 error.downcast_ref::<InvalidSettingsError>()
2012 {
2013 false
2014 // Local settings errors are displayed by the projects
2015 } else {
2016 show_app_notification(id, cx, move |cx| {
2017 cx.new(|cx| {
2018 MessageNotification::new(format!("Invalid user settings file\n{error}"), cx)
2019 .primary_message("Open Settings File")
2020 .primary_icon(IconName::Settings)
2021 .primary_on_click(|window, cx| {
2022 window.dispatch_action(
2023 zed_actions::OpenSettingsFile.boxed_clone(),
2024 cx,
2025 );
2026 cx.emit(DismissEvent);
2027 })
2028 })
2029 });
2030 true
2031 }
2032 }
2033 None => {
2034 dismiss_app_notification(&id, cx);
2035 false
2036 }
2037 };
2038 let id = NotificationId::Named(format!("failed-to-migrate-settings-{is_user}").into());
2039
2040 match result.migration_status {
2041 settings::MigrationStatus::Succeeded | settings::MigrationStatus::NotNeeded => {
2042 dismiss_app_notification(&id, cx);
2043 }
2044 settings::MigrationStatus::Failed { error: err } => {
2045 if !showed_parse_error {
2046 show_app_notification(id, cx, move |cx| {
2047 cx.new(|cx| {
2048 MessageNotification::new(
2049 format!(
2050 "Failed to migrate settings\n\
2051 {err}"
2052 ),
2053 cx,
2054 )
2055 .primary_message("Open Settings File")
2056 .primary_icon(IconName::Settings)
2057 .primary_on_click(|window, cx| {
2058 window.dispatch_action(zed_actions::OpenSettingsFile.boxed_clone(), cx);
2059 cx.emit(DismissEvent);
2060 })
2061 })
2062 });
2063 }
2064 }
2065 };
2066}
2067
2068#[derive(Copy, Clone, Debug, settings::RegisterSetting)]
2069struct CursorHideModeSetting(gpui::CursorHideMode);
2070
2071impl Settings for CursorHideModeSetting {
2072 fn from_settings(content: &settings::SettingsContent) -> Self {
2073 Self(match content.hide_mouse.unwrap_or_default() {
2074 settings::HideMouseMode::Never => gpui::CursorHideMode::Never,
2075 settings::HideMouseMode::OnTyping => gpui::CursorHideMode::OnTyping,
2076 settings::HideMouseMode::OnTypingAndAction => gpui::CursorHideMode::OnTypingAndAction,
2077 })
2078 }
2079}
2080
2081fn init_cursor_hide_mode(cx: &mut App) {
2082 let apply = |cx: &mut App| cx.set_cursor_hide_mode(CursorHideModeSetting::get_global(cx).0);
2083 apply(cx);
2084 cx.observe_global::<SettingsStore>(apply).detach();
2085}
2086
2087#[derive(Copy, Clone, Debug, settings::RegisterSetting)]
2088struct ReduceMotionSetting(settings::ReduceMotionMode);
2089
2090impl Settings for ReduceMotionSetting {
2091 fn from_settings(content: &settings::SettingsContent) -> Self {
2092 Self(content.reduce_motion.unwrap_or_default())
2093 }
2094}
2095
2096fn init_reduce_motion(cx: &mut App) {
2097 let apply = |cx: &mut App| {
2098 let reduce_motion = ReduceMotionSetting::get_global(cx).0 == settings::ReduceMotionMode::On;
2099 cx.set_reduce_motion(reduce_motion);
2100 };
2101 apply(cx);
2102 cx.observe_global::<SettingsStore>(apply).detach();
2103}
2104
2105/// Starts watching `~/.config/zed/AGENTS.md` (or the platform equivalent) and
2106/// surfaces any read errors using the same notification UI as settings errors.
2107///
2108/// The file itself is loaded into [`agent_settings::UserAgentsMd`] for inclusion
2109/// in prompts.
2110pub fn watch_user_agents_md(fs: Arc<dyn fs::Fs>, cx: &mut App) {
2111 struct UserAgentsMdParseError;
2112 let notification_id = NotificationId::unique::<UserAgentsMdParseError>();
2113
2114 init_user_agents_md(fs, cx, move |state, cx| match state {
2115 UserAgentsMdState::Loaded(_) | UserAgentsMdState::Empty => {
2116 dismiss_app_notification(¬ification_id, cx);
2117 }
2118 UserAgentsMdState::Error(message) => {
2119 let path = paths::agents_file().display().to_string();
2120 log::error!("Failed to load user AGENTS.md from {path}: {message}");
2121 let body = format!("Failed to load {path}\n{message}");
2122 let notification_id = notification_id.clone();
2123 show_app_notification(notification_id, cx, move |cx| {
2124 let body = body.clone();
2125 cx.new(|cx| MessageNotification::new(body, cx))
2126 });
2127 }
2128 });
2129}
2130
2131pub fn watch_settings_files(fs: Arc<dyn fs::Fs>, cx: &mut App) {
2132 MigrationNotification::set_global(cx.new(|_| MigrationNotification), cx);
2133
2134 SettingsStore::update_global(cx, move |store, cx| {
2135 store.watch_settings_files(fs, cx, |settings_file, result, cx| {
2136 let is_user = matches!(settings_file, SettingsFile::User);
2137 let migrating_in_memory =
2138 matches!(&result.migration_status, MigrationStatus::Succeeded);
2139 notify_settings_errors(result, is_user, cx);
2140 if let Some(notifier) = MigrationNotification::try_global(cx) {
2141 notifier.update(cx, |_, cx| {
2142 cx.emit(MigrationEvent::ContentChanged {
2143 migration_type: MigrationType::Settings,
2144 migrating_in_memory,
2145 });
2146 });
2147 }
2148 });
2149 });
2150}
2151
2152pub fn handle_keymap_file_changes(
2153 mut user_keymap_file_rx: mpsc::UnboundedReceiver<String>,
2154 user_keymap_watcher: gpui::Task<()>,
2155 cx: &mut App,
2156) {
2157 let (base_keymap_tx, mut base_keymap_rx) = mpsc::unbounded();
2158 let (keyboard_layout_tx, mut keyboard_layout_rx) = mpsc::unbounded();
2159 let mut old_base_keymap = *BaseKeymap::get_global(cx);
2160 let mut old_vim_enabled = VimModeSetting::get_global(cx).0;
2161 let mut old_helix_enabled = vim_mode_setting::HelixModeSetting::get_global(cx).0;
2162 let mut old_disable_ai = DisableAiSettings::get_global(cx).disable_ai;
2163
2164 cx.observe_global::<SettingsStore>(move |cx| {
2165 let new_base_keymap = *BaseKeymap::get_global(cx);
2166 let new_vim_enabled = VimModeSetting::get_global(cx).0;
2167 let new_helix_enabled = vim_mode_setting::HelixModeSetting::get_global(cx).0;
2168 let new_disable_ai = DisableAiSettings::get_global(cx).disable_ai;
2169
2170 if new_base_keymap != old_base_keymap
2171 || new_vim_enabled != old_vim_enabled
2172 || new_helix_enabled != old_helix_enabled
2173 || new_disable_ai != old_disable_ai
2174 {
2175 old_base_keymap = new_base_keymap;
2176 old_vim_enabled = new_vim_enabled;
2177 old_helix_enabled = new_helix_enabled;
2178 old_disable_ai = new_disable_ai;
2179
2180 base_keymap_tx.unbounded_send(()).unwrap();
2181 }
2182 })
2183 .detach();
2184
2185 #[cfg(target_os = "windows")]
2186 {
2187 let mut current_layout_id = cx.keyboard_layout().id().to_string();
2188 cx.on_keyboard_layout_change(move |cx| {
2189 let next_layout_id = cx.keyboard_layout().id();
2190 if next_layout_id != current_layout_id {
2191 current_layout_id = next_layout_id.to_string();
2192 keyboard_layout_tx.unbounded_send(()).ok();
2193 }
2194 })
2195 .detach();
2196 }
2197
2198 #[cfg(not(target_os = "windows"))]
2199 {
2200 let mut current_mapping = cx.keyboard_mapper().get_key_equivalents().cloned();
2201 cx.on_keyboard_layout_change(move |cx| {
2202 let next_mapping = cx.keyboard_mapper().get_key_equivalents();
2203 if current_mapping.as_ref() != next_mapping {
2204 current_mapping = next_mapping.cloned();
2205 keyboard_layout_tx.unbounded_send(()).ok();
2206 }
2207 })
2208 .detach();
2209 }
2210
2211 load_default_keymap(cx);
2212
2213 struct KeymapParseErrorNotification;
2214 let notification_id = NotificationId::unique::<KeymapParseErrorNotification>();
2215
2216 cx.spawn(async move |cx| {
2217 let _user_keymap_watcher = user_keymap_watcher;
2218 let mut user_keymap_content = String::new();
2219 let mut migrating_in_memory = false;
2220 loop {
2221 select_biased! {
2222 _ = base_keymap_rx.next() => {},
2223 _ = keyboard_layout_rx.next() => {},
2224 content = user_keymap_file_rx.next() => {
2225 if let Some(content) = content {
2226 if let Ok(Some(migrated_content)) = migrate_keymap(&content) {
2227 user_keymap_content = migrated_content;
2228 migrating_in_memory = true;
2229 } else {
2230 user_keymap_content = content;
2231 migrating_in_memory = false;
2232 }
2233 }
2234 }
2235 };
2236 cx.update(|cx| {
2237 if let Some(notifier) = MigrationNotification::try_global(cx) {
2238 notifier.update(cx, |_, cx| {
2239 cx.emit(MigrationEvent::ContentChanged {
2240 migration_type: MigrationType::Keymap,
2241 migrating_in_memory,
2242 });
2243 });
2244 }
2245 let load_result = KeymapFile::load(&user_keymap_content, cx);
2246 match load_result {
2247 KeymapFileLoadResult::Success { key_bindings } => {
2248 reload_keymaps(cx, key_bindings);
2249 dismiss_app_notification(¬ification_id.clone(), cx);
2250 }
2251 KeymapFileLoadResult::SomeFailedToLoad {
2252 key_bindings,
2253 error_message,
2254 } => {
2255 if !key_bindings.is_empty() {
2256 reload_keymaps(cx, key_bindings);
2257 }
2258 show_keymap_file_load_error(notification_id.clone(), error_message, cx);
2259 }
2260 KeymapFileLoadResult::JsonParseFailure { error } => {
2261 show_keymap_file_json_error(notification_id.clone(), &error, cx)
2262 }
2263 }
2264 });
2265 }
2266 })
2267 .detach();
2268}
2269
2270fn show_keymap_file_json_error(
2271 notification_id: NotificationId,
2272 error: &anyhow::Error,
2273 cx: &mut App,
2274) {
2275 let message: SharedString =
2276 format!("JSON parse error in keymap file. Bindings not reloaded.\n\n{error}").into();
2277 show_app_notification(notification_id, cx, move |cx| {
2278 cx.new(|cx| {
2279 MessageNotification::new(message.clone(), cx)
2280 .primary_message("Open Keymap File")
2281 .primary_icon(IconName::Settings)
2282 .primary_on_click(|window, cx| {
2283 window.dispatch_action(zed_actions::OpenKeymapFile.boxed_clone(), cx);
2284 cx.emit(DismissEvent);
2285 })
2286 })
2287 });
2288}
2289
2290fn show_keymap_file_load_error(
2291 notification_id: NotificationId,
2292 error_message: MarkdownString,
2293 cx: &mut App,
2294) {
2295 show_markdown_app_notification(
2296 notification_id,
2297 error_message,
2298 "Open Keymap File".into(),
2299 |window, cx| {
2300 window.dispatch_action(zed_actions::OpenKeymapFile.boxed_clone(), cx);
2301 cx.emit(DismissEvent);
2302 },
2303 cx,
2304 )
2305}
2306
2307fn show_markdown_app_notification<F>(
2308 notification_id: NotificationId,
2309 message: MarkdownString,
2310 primary_button_message: SharedString,
2311 primary_button_on_click: F,
2312 cx: &mut App,
2313) where
2314 F: 'static + Send + Sync + Fn(&mut Window, &mut Context<MessageNotification>),
2315{
2316 let markdown = cx.new(|cx| Markdown::new(message.0.into(), None, None, cx));
2317 let primary_button_on_click = Arc::new(primary_button_on_click);
2318
2319 show_app_notification(notification_id, cx, move |cx| {
2320 let markdown = markdown.clone();
2321 let primary_button_message = primary_button_message.clone();
2322 let primary_button_on_click = primary_button_on_click.clone();
2323
2324 cx.new(move |cx| {
2325 MessageNotification::new_from_builder(cx, move |window, cx| {
2326 image_cache(retain_all("notification-cache"))
2327 .child(div().text_ui(cx).child(MarkdownElement::new(
2328 markdown.clone(),
2329 MarkdownStyle::themed(MarkdownFont::Editor, window, cx),
2330 )))
2331 .into_any()
2332 })
2333 .primary_message(primary_button_message)
2334 .primary_icon(IconName::Settings)
2335 .primary_on_click_arc(primary_button_on_click)
2336 })
2337 })
2338}
2339
2340fn reload_keymaps(cx: &mut App, mut user_key_bindings: Vec<KeyBinding>) {
2341 cx.clear_key_bindings();
2342 load_default_keymap(cx);
2343
2344 for key_binding in &mut user_key_bindings {
2345 key_binding.set_meta(KeybindSource::User.meta());
2346 }
2347 cx.bind_keys(filter_disabled_ai_bindings(user_key_bindings, cx));
2348
2349 let menus = app_menus(cx);
2350 cx.set_menus(menus);
2351 // On Windows, this is set in the `update_jump_list` method of the `HistoryManager`.
2352 #[cfg(not(target_os = "windows"))]
2353 cx.set_dock_menu(vec![gpui::MenuItem::action(
2354 "New Window",
2355 workspace::NewWindow,
2356 )]);
2357 // todo: nicer api here?
2358 keymap_editor::KeymapEventChannel::trigger_keymap_changed(cx);
2359}
2360
2361pub fn load_default_keymap(cx: &mut App) {
2362 let base_keymap = *BaseKeymap::get_global(cx);
2363 if base_keymap == BaseKeymap::None {
2364 return;
2365 }
2366
2367 cx.bind_keys(filter_disabled_ai_bindings(
2368 KeymapFile::load_asset(DEFAULT_KEYMAP_PATH, Some(KeybindSource::Default), cx).unwrap(),
2369 cx,
2370 ));
2371
2372 if let Some(asset_path) = base_keymap.asset_path() {
2373 cx.bind_keys(filter_disabled_ai_bindings(
2374 KeymapFile::load_asset(asset_path, Some(KeybindSource::Base), cx).unwrap(),
2375 cx,
2376 ));
2377 }
2378
2379 if VimModeSetting::get_global(cx).0 || vim_mode_setting::HelixModeSetting::get_global(cx).0 {
2380 cx.bind_keys(filter_disabled_ai_bindings(
2381 KeymapFile::load_asset(VIM_KEYMAP_PATH, Some(KeybindSource::Vim), cx).unwrap(),
2382 cx,
2383 ));
2384 }
2385
2386 cx.bind_keys(
2387 KeymapFile::load_asset(
2388 SPECIFIC_OVERRIDES_KEYMAP_PATH,
2389 Some(KeybindSource::Default),
2390 cx,
2391 )
2392 .unwrap(),
2393 );
2394}
2395
2396/// Namespaces of actions that are part of an AI feature. When the user opts out
2397/// of AI via the `disable_ai` setting, bindings to these actions are dropped so
2398/// that lower-precedence editor defaults (e.g. `editor::NewlineBelow` for
2399/// `ctrl-enter`) can fire instead of being shadowed by an action whose handler
2400/// silently no-ops.
2401const AI_ACTION_NAMESPACES: &[&str] = &[
2402 "acp::",
2403 "agent::",
2404 "assistant::",
2405 "edit_prediction::",
2406 "inline_assistant::",
2407 "zeta::",
2408];
2409
2410fn is_ai_keybinding(binding: &KeyBinding) -> bool {
2411 let name = binding.action().name();
2412 AI_ACTION_NAMESPACES
2413 .iter()
2414 .any(|namespace| name.starts_with(namespace))
2415}
2416
2417fn filter_disabled_ai_bindings(bindings: Vec<KeyBinding>, cx: &App) -> Vec<KeyBinding> {
2418 if !DisableAiSettings::get_global(cx).disable_ai {
2419 return bindings;
2420 }
2421 bindings
2422 .into_iter()
2423 .filter(|binding| !is_ai_keybinding(binding))
2424 .collect()
2425}
2426
2427pub fn open_new_ssh_project_from_project(
2428 workspace: &mut Workspace,
2429 paths: Vec<PathBuf>,
2430 window: &mut Window,
2431 cx: &mut Context<Workspace>,
2432) -> Task<anyhow::Result<()>> {
2433 let app_state = workspace.app_state().clone();
2434 let Some(ssh_client) = workspace.project().read(cx).remote_client() else {
2435 return Task::ready(Err(anyhow::anyhow!("Not an ssh project")));
2436 };
2437 let connection_options = ssh_client.read(cx).connection_options();
2438 cx.spawn_in(window, async move |_, cx| {
2439 open_remote_project(
2440 connection_options,
2441 paths,
2442 app_state,
2443 workspace::OpenOptions {
2444 workspace_matching: workspace::WorkspaceMatching::None,
2445 ..Default::default()
2446 },
2447 cx,
2448 )
2449 .await
2450 .map(|_| ())
2451 })
2452}
2453
2454fn open_project_settings_file(
2455 workspace: &mut Workspace,
2456 _: &OpenProjectSettingsFile,
2457 window: &mut Window,
2458 cx: &mut Context<Workspace>,
2459) {
2460 if let Some(task) = open_local_file(
2461 workspace,
2462 local_settings_file_relative_path(),
2463 initial_project_settings_content(),
2464 window,
2465 cx,
2466 ) {
2467 task.detach_and_log_err(cx);
2468 }
2469}
2470
2471fn open_project_tasks_file(
2472 workspace: &mut Workspace,
2473 _: &OpenProjectTasks,
2474 window: &mut Window,
2475 cx: &mut Context<Workspace>,
2476) {
2477 if let Some(task) = open_local_file(
2478 workspace,
2479 local_tasks_file_relative_path(),
2480 initial_tasks_content(),
2481 window,
2482 cx,
2483 ) {
2484 task.detach_and_log_err(cx);
2485 }
2486}
2487
2488fn open_worktree_setup_tasks_file(
2489 workspace: &mut Workspace,
2490 _: &zed_actions::OpenWorktreeSetupTasks,
2491 window: &mut Window,
2492 cx: &mut Context<Workspace>,
2493) {
2494 // Kept harmless on purpose: tasks with the `create_worktree` hook run automatically
2495 // when a worktree is created, so the example must be safe to save unedited.
2496 const WORKTREE_SETUP_TASK_EXAMPLE: &str = r#" {
2497 // Runs automatically after Omega creates a new git worktree.
2498 // $ZED_WORKTREE_ROOT is the new worktree's root directory, and
2499 // $ZED_MAIN_GIT_WORKTREE is the original repository's working directory.
2500 "label": "Set up new worktree",
2501 "command": "echo \"Setting up $ZED_WORKTREE_ROOT — edit this command\"",
2502 "cwd": "$ZED_WORKTREE_ROOT",
2503 "hooks": ["create_worktree"]
2504 }"#;
2505
2506 let Some(open_task) = open_local_file(
2507 workspace,
2508 local_tasks_file_relative_path(),
2509 settings::initial_worktree_setup_tasks_content(),
2510 window,
2511 cx,
2512 ) else {
2513 return;
2514 };
2515
2516 cx.spawn_in(window, async move |_, cx| {
2517 let editor = open_task.await?;
2518 editor.update_in(cx, |editor, window, cx| {
2519 // Skip insertion if the file already mentions the hook (even in a comment,
2520 // like the seeded template's example — uncommenting it beats duplicating it).
2521 // `create_git_worktree` is a serde alias for the same hook.
2522 let text = editor.text(cx);
2523 if text.contains("create_worktree") || text.contains("create_git_worktree") {
2524 return anyhow::Ok(());
2525 }
2526 tasks_ui::insert_task_json_into_editor(
2527 editor,
2528 WORKTREE_SETUP_TASK_EXAMPLE.to_string(),
2529 window,
2530 cx,
2531 )
2532 })?
2533 })
2534 .detach_and_log_err(cx);
2535}
2536
2537fn open_project_debug_tasks_file(
2538 workspace: &mut Workspace,
2539 _: &zed_actions::OpenProjectDebugTasks,
2540 window: &mut Window,
2541 cx: &mut Context<Workspace>,
2542) {
2543 if let Some(task) = open_local_file(
2544 workspace,
2545 local_debug_file_relative_path(),
2546 initial_local_debug_tasks_content(),
2547 window,
2548 cx,
2549 ) {
2550 task.detach_and_log_err(cx);
2551 }
2552}
2553
2554fn open_local_file(
2555 workspace: &mut Workspace,
2556 settings_relative_path: &'static RelPath,
2557 initial_contents: Cow<'static, str>,
2558 window: &mut Window,
2559 cx: &mut Context<Workspace>,
2560) -> Option<gpui::Task<anyhow::Result<Entity<Editor>>>> {
2561 let project = workspace.project().clone();
2562 let worktree = project
2563 .read(cx)
2564 .visible_worktrees(cx)
2565 .find_map(|tree| tree.read(cx).root_entry()?.is_dir().then_some(tree));
2566 if let Some(worktree) = worktree {
2567 let tree_id = worktree.read(cx).id();
2568 Some(cx.spawn_in(window, async move |workspace, cx| {
2569 // Check if the file actually exists on disk (even if it's excluded from worktree)
2570 let file_exists = {
2571 let full_path = worktree.read_with(cx, |tree, _| {
2572 tree.abs_path().join(settings_relative_path.as_std_path())
2573 });
2574
2575 let fs = project.read_with(cx, |project, _| project.fs().clone());
2576
2577 fs.metadata(&full_path)
2578 .await
2579 .ok()
2580 .flatten()
2581 .is_some_and(|metadata| !metadata.is_dir && !metadata.is_fifo)
2582 };
2583
2584 if !file_exists {
2585 if let Some(dir_path) = settings_relative_path.parent()
2586 && worktree.read_with(cx, |tree, _| tree.entry_for_path(dir_path).is_none())
2587 {
2588 project
2589 .update(cx, |project, cx| {
2590 project.create_entry((tree_id, dir_path), true, cx)
2591 })
2592 .await
2593 .context("worktree was removed")?;
2594 }
2595
2596 if worktree.read_with(cx, |tree, _| {
2597 tree.entry_for_path(settings_relative_path).is_none()
2598 }) {
2599 project
2600 .update(cx, |project, cx| {
2601 project.create_entry((tree_id, settings_relative_path), false, cx)
2602 })
2603 .await
2604 .context("worktree was removed")?;
2605 }
2606 }
2607
2608 let editor = workspace
2609 .update_in(cx, |workspace, window, cx| {
2610 workspace.open_path((tree_id, settings_relative_path), None, true, window, cx)
2611 })?
2612 .await?
2613 .downcast::<Editor>()
2614 .context("unexpected item type: expected editor item")?;
2615
2616 editor.update(cx, |editor, cx| {
2617 if let Some(buffer) = editor.buffer().read(cx).as_singleton()
2618 && buffer.read(cx).is_empty()
2619 {
2620 buffer.update(cx, |buffer, cx| {
2621 buffer.edit([(0..0, initial_contents)], None, cx)
2622 });
2623 }
2624 });
2625
2626 anyhow::Ok(editor)
2627 }))
2628 } else {
2629 struct NoOpenFolders;
2630
2631 workspace.show_notification(NotificationId::unique::<NoOpenFolders>(), cx, |cx| {
2632 cx.new(|cx| MessageNotification::new("This project has no folders open.", cx))
2633 });
2634 None
2635 }
2636}
2637
2638fn open_bundled_file(
2639 workspace: &mut Workspace,
2640 text: Cow<'static, str>,
2641 title: &'static str,
2642 language: &'static str,
2643 window: &mut Window,
2644 cx: &mut Context<Workspace>,
2645) {
2646 let existing = workspace.items_of_type::<Editor>(cx).find(|editor| {
2647 editor.read_with(cx, |editor, cx| {
2648 editor.read_only(cx)
2649 && editor.title(cx).as_ref() == title
2650 && editor
2651 .buffer()
2652 .read(cx)
2653 .as_singleton()
2654 .is_some_and(|buffer| buffer.read(cx).file().is_none())
2655 })
2656 });
2657 if let Some(existing) = existing {
2658 workspace.activate_item(&existing, true, true, window, cx);
2659 return;
2660 }
2661
2662 let language = workspace.app_state().languages.language_for_name(language);
2663 cx.spawn_in(window, async move |workspace, cx| {
2664 let language = language.await.log_err();
2665 workspace
2666 .update_in(cx, move |workspace, window, cx| {
2667 let project = workspace.project().clone();
2668 let buffer = project.update(cx, move |project, cx| {
2669 project.create_buffer(language, false, cx)
2670 });
2671 cx.spawn_in(window, async move |workspace, cx| {
2672 let buffer = buffer.await?;
2673 buffer.update(cx, |buffer, cx| {
2674 buffer.set_text(text.into_owned(), cx);
2675 buffer.set_capability(Capability::ReadOnly, cx);
2676 });
2677 let buffer =
2678 cx.new(|cx| MultiBuffer::singleton(buffer, cx).with_title(title.into()));
2679 workspace.update_in(cx, |workspace, window, cx| {
2680 workspace.add_item_to_active_pane(
2681 Box::new(cx.new(|cx| {
2682 let mut editor = Editor::for_multibuffer(
2683 buffer,
2684 Some(project.clone()),
2685 window,
2686 cx,
2687 );
2688 editor.set_read_only(true);
2689 editor.set_should_serialize(false, cx);
2690 editor.set_breadcrumb_header(title.into());
2691 editor
2692 })),
2693 None,
2694 true,
2695 window,
2696 cx,
2697 )
2698 })
2699 })
2700 })?
2701 .await
2702 })
2703 .detach_and_log_err(cx);
2704}
2705
2706fn open_settings_file(
2707 abs_path: &'static Path,
2708 default_content: impl FnOnce() -> Rope + Send + 'static,
2709 window: &mut Window,
2710 cx: &mut Context<Workspace>,
2711) {
2712 cx.spawn_in(window, async move |workspace, cx| {
2713 workspace
2714 .update_in(cx, |workspace, window, cx| {
2715 workspace.with_local_or_wsl_workspace(window, cx, move |workspace, window, cx| {
2716 let project = workspace.project().clone();
2717
2718 cx.spawn_in(window, async move |workspace, cx| {
2719 let config_dir = project
2720 .update(cx, |project, cx| {
2721 project.try_windows_path_to_wsl(paths::config_dir().as_path(), cx)
2722 })
2723 .await?;
2724 // Set up a dedicated worktree for settings, since
2725 // otherwise we're dropping and re-starting LSP servers
2726 // for each file inside on every settings file
2727 // close/open
2728
2729 // TODO: Do note that all other external files (e.g.
2730 // drag and drop from OS) still have their worktrees
2731 // released on file close, causing LSP servers'
2732 // restarts.
2733 let (_worktree, _) = project
2734 .update(cx, |project, cx| {
2735 project.find_or_create_worktree(&config_dir, false, cx)
2736 })
2737 .await?;
2738
2739 workspace
2740 .update_in(cx, |_, window, cx| {
2741 create_and_open_local_file(abs_path, window, cx, default_content)
2742 })?
2743 .await?;
2744 anyhow::Ok(())
2745 })
2746 })
2747 })?
2748 .await?
2749 .await?;
2750 anyhow::Ok(())
2751 })
2752 .detach_and_log_err(cx);
2753}
2754
2755/// Eagerly loads the active theme and icon theme based on the selections in the
2756/// theme settings.
2757///
2758/// This fast path exists to load these themes as soon as possible so the user
2759/// doesn't see the default themes while waiting on extensions to load.
2760pub(crate) fn eager_load_active_theme_and_icon_theme(fs: Arc<dyn Fs>, cx: &mut App) {
2761 let extension_store = ExtensionStore::global(cx);
2762 let theme_registry = ThemeRegistry::global(cx);
2763 let theme_settings = ThemeSettings::get_global(cx);
2764 let appearance = SystemAppearance::global(cx).0;
2765
2766 enum LoadTarget {
2767 Theme(PathBuf),
2768 IconTheme((PathBuf, PathBuf)),
2769 }
2770
2771 let theme_name = theme_settings.theme.name(appearance);
2772 let icon_theme_name = theme_settings.icon_theme.name(appearance);
2773 let themes_to_load = [
2774 theme_registry
2775 .get(&theme_name.0)
2776 .is_err()
2777 .then(|| {
2778 extension_store
2779 .read(cx)
2780 .path_to_extension_theme(&theme_name.0)
2781 })
2782 .flatten()
2783 .map(LoadTarget::Theme),
2784 theme_registry
2785 .get_icon_theme(&icon_theme_name.0)
2786 .is_err()
2787 .then(|| {
2788 extension_store
2789 .read(cx)
2790 .path_to_extension_icon_theme(&icon_theme_name.0)
2791 })
2792 .flatten()
2793 .map(LoadTarget::IconTheme),
2794 ];
2795
2796 enum ReloadTarget {
2797 Theme,
2798 IconTheme,
2799 }
2800
2801 let executor = cx.background_executor();
2802 let reload_tasks = parking_lot::Mutex::new(Vec::with_capacity(themes_to_load.len()));
2803
2804 let mut themes_to_load = themes_to_load.into_iter().flatten().peekable();
2805
2806 if themes_to_load.peek().is_none() {
2807 return;
2808 }
2809
2810 cx.foreground_executor().block_on(executor.scoped(|scope| {
2811 for load_target in themes_to_load {
2812 let theme_registry = &theme_registry;
2813 let reload_tasks = &reload_tasks;
2814 let fs = fs.clone();
2815
2816 scope.spawn(async move {
2817 match load_target {
2818 LoadTarget::Theme(theme_path) => {
2819 if let Some(bytes) = fs.load_bytes(&theme_path).await.log_err()
2820 && load_user_theme(theme_registry, &bytes).log_err().is_some()
2821 {
2822 reload_tasks.lock().push(ReloadTarget::Theme);
2823 }
2824 }
2825 LoadTarget::IconTheme((icon_theme_path, icons_root_path)) => {
2826 if let Some(bytes) = fs.load_bytes(&icon_theme_path).await.log_err()
2827 && let Some(icon_theme_family) =
2828 deserialize_icon_theme(&bytes).log_err()
2829 && theme_registry
2830 .load_icon_theme(icon_theme_family, &icons_root_path)
2831 .log_err()
2832 .is_some()
2833 {
2834 reload_tasks.lock().push(ReloadTarget::IconTheme);
2835 }
2836 }
2837 }
2838 });
2839 }
2840 }));
2841
2842 for reload_target in reload_tasks.into_inner() {
2843 match reload_target {
2844 ReloadTarget::Theme => theme_settings::reload_theme(cx),
2845 ReloadTarget::IconTheme => theme_settings::reload_icon_theme(cx),
2846 };
2847 }
2848}
2849
2850#[cfg(test)]
2851mod tests {
2852 use super::*;
2853 use assets::Assets;
2854 use collections::HashSet;
2855 use editor::{
2856 DisplayPoint, Editor, MultiBufferOffset, SelectionEffects, display_map::DisplayRow,
2857 };
2858 use gpui::{
2859 Action, AnyWindowHandle, App, AssetSource, BorrowAppContext, Modifiers, TestAppContext,
2860 UpdateGlobal, VisualTestContext, WindowHandle, actions, point, px,
2861 };
2862 use language::LanguageRegistry;
2863 use languages::{markdown_lang, rust_lang};
2864 use pretty_assertions::{assert_eq, assert_ne};
2865 use project::{Project, ProjectPath};
2866 use prompt_store::PromptBuilder;
2867 use semver::Version;
2868 use serde_json::json;
2869 use settings::{SaturatingBool, SettingsStore, watch_config_file};
2870 use std::{
2871 path::{Path, PathBuf},
2872 sync::Arc,
2873 time::Duration,
2874 };
2875 use theme::ThemeRegistry;
2876 use util::{
2877 path,
2878 rel_path::{RelPath, rel_path},
2879 };
2880 use workspace::MultiWorkspace;
2881 use workspace::{
2882 NewFile, OpenOptions, OpenVisible, SERIALIZATION_THROTTLE_TIME, SaveIntent, SplitDirection,
2883 WorkspaceHandle,
2884 item::SaveOptions,
2885 item::{Item, ItemHandle},
2886 open_new, open_paths, pane,
2887 };
2888
2889 async fn flush_workspace_serialization(
2890 window: &WindowHandle<MultiWorkspace>,
2891 cx: &mut TestAppContext,
2892 ) {
2893 let all_tasks = window
2894 .update(cx, |multi_workspace, window, cx| {
2895 let mut tasks = multi_workspace
2896 .workspaces()
2897 .map(|workspace| {
2898 workspace.update(cx, |workspace, cx| {
2899 workspace.flush_serialization(window, cx)
2900 })
2901 })
2902 .collect::<Vec<_>>();
2903 tasks.push(multi_workspace.flush_serialization());
2904 tasks
2905 })
2906 .unwrap();
2907
2908 futures::future::join_all(all_tasks).await;
2909 }
2910
2911 #[gpui::test]
2912 async fn test_open_non_existing_file(cx: &mut TestAppContext) {
2913 let app_state = init_test(cx);
2914 app_state
2915 .fs
2916 .as_fake()
2917 .insert_tree(
2918 path!("/root"),
2919 json!({
2920 "a": {
2921 },
2922 }),
2923 )
2924 .await;
2925
2926 cx.update(|cx| {
2927 open_paths(
2928 &[PathBuf::from(path!("/root/a/new"))],
2929 app_state.clone(),
2930 workspace::OpenOptions::default(),
2931 cx,
2932 )
2933 })
2934 .await
2935 .unwrap();
2936 assert_eq!(cx.read(|cx| cx.windows().len()), 1);
2937
2938 let multi_workspace = cx.windows()[0].downcast::<MultiWorkspace>().unwrap();
2939 multi_workspace
2940 .update(cx, |multi_workspace, _, cx| {
2941 multi_workspace.workspace().update(cx, |workspace, cx| {
2942 assert!(workspace.active_item_as::<Editor>(cx).is_some())
2943 });
2944 })
2945 .unwrap();
2946 }
2947
2948 #[gpui::test]
2949 async fn test_open_paths_action(cx: &mut TestAppContext) {
2950 let app_state = init_test(cx);
2951 app_state
2952 .fs
2953 .as_fake()
2954 .insert_tree(
2955 path!("/root"),
2956 json!({
2957 "a": {
2958 "aa": null,
2959 "ab": null,
2960 },
2961 "b": {
2962 "ba": null,
2963 "bb": null,
2964 },
2965 "c": {
2966 "ca": null,
2967 "cb": null,
2968 },
2969 "d": {
2970 "da": null,
2971 "db": null,
2972 },
2973 "e": {
2974 "ea": null,
2975 "eb": null,
2976 }
2977 }),
2978 )
2979 .await;
2980
2981 cx.update(|cx| {
2982 open_paths(
2983 &[
2984 PathBuf::from(path!("/root/a")),
2985 PathBuf::from(path!("/root/b")),
2986 ],
2987 app_state.clone(),
2988 workspace::OpenOptions::default(),
2989 cx,
2990 )
2991 })
2992 .await
2993 .unwrap();
2994 assert_eq!(cx.read(|cx| cx.windows().len()), 1);
2995
2996 cx.update(|cx| {
2997 open_paths(
2998 &[PathBuf::from(path!("/root/a"))],
2999 app_state.clone(),
3000 workspace::OpenOptions::default(),
3001 cx,
3002 )
3003 })
3004 .await
3005 .unwrap();
3006 assert_eq!(cx.read(|cx| cx.windows().len()), 1);
3007 let multi_workspace_1 = cx
3008 .read(|cx| cx.windows()[0].downcast::<MultiWorkspace>())
3009 .unwrap();
3010 cx.run_until_parked();
3011 multi_workspace_1
3012 .update(cx, |multi_workspace, window, cx| {
3013 multi_workspace.workspace().update(cx, |workspace, cx| {
3014 assert_eq!(workspace.worktrees(cx).count(), 2);
3015 assert!(workspace.right_dock().read(cx).is_open());
3016 assert!(
3017 workspace
3018 .active_pane()
3019 .read(cx)
3020 .focus_handle(cx)
3021 .is_focused(window)
3022 );
3023 });
3024 })
3025 .unwrap();
3026
3027 cx.update(|cx| {
3028 open_paths(
3029 &[
3030 PathBuf::from(path!("/root/c")),
3031 PathBuf::from(path!("/root/d")),
3032 ],
3033 app_state.clone(),
3034 workspace::OpenOptions::default(),
3035 cx,
3036 )
3037 })
3038 .await
3039 .unwrap();
3040 assert_eq!(cx.read(|cx| cx.windows().len()), 1);
3041 cx.run_until_parked();
3042 multi_workspace_1
3043 .update(cx, |multi_workspace, _window, cx| {
3044 assert_eq!(multi_workspace.workspaces().count(), 2);
3045 assert!(multi_workspace.sidebar_open());
3046 let workspace = multi_workspace.workspace().read(cx);
3047 assert_eq!(
3048 workspace
3049 .worktrees(cx)
3050 .map(|w| w.read(cx).abs_path())
3051 .collect::<Vec<_>>(),
3052 &[
3053 Path::new(path!("/root/c")).into(),
3054 Path::new(path!("/root/d")).into(),
3055 ]
3056 );
3057 })
3058 .unwrap();
3059
3060 // Opening with -n (reuse_worktrees: false) still creates a new window.
3061 cx.update(|cx| {
3062 open_paths(
3063 &[PathBuf::from(path!("/root/e"))],
3064 app_state,
3065 workspace::OpenOptions {
3066 workspace_matching: workspace::WorkspaceMatching::None,
3067 ..Default::default()
3068 },
3069 cx,
3070 )
3071 })
3072 .await
3073 .unwrap();
3074 cx.background_executor.run_until_parked();
3075 assert_eq!(cx.read(|cx| cx.windows().len()), 2);
3076 }
3077
3078 #[gpui::test]
3079 async fn test_open_add_new(cx: &mut TestAppContext) {
3080 let app_state = init_test(cx);
3081 app_state
3082 .fs
3083 .as_fake()
3084 .insert_tree(
3085 path!("/root"),
3086 json!({"a": "hey", "b": "", "dir": {"c": "f"}}),
3087 )
3088 .await;
3089
3090 cx.update(|cx| {
3091 open_paths(
3092 &[PathBuf::from(path!("/root/dir"))],
3093 app_state.clone(),
3094 workspace::OpenOptions::default(),
3095 cx,
3096 )
3097 })
3098 .await
3099 .unwrap();
3100 assert_eq!(cx.update(|cx| cx.windows().len()), 1);
3101
3102 cx.update(|cx| {
3103 open_paths(
3104 &[PathBuf::from(path!("/root/a"))],
3105 app_state.clone(),
3106 workspace::OpenOptions {
3107 workspace_matching: workspace::WorkspaceMatching::MatchSubdirectory,
3108 ..Default::default()
3109 },
3110 cx,
3111 )
3112 })
3113 .await
3114 .unwrap();
3115 assert_eq!(cx.update(|cx| cx.windows().len()), 1);
3116
3117 // Opening a file inside the existing worktree with -n creates a new window.
3118 cx.update(|cx| {
3119 open_paths(
3120 &[PathBuf::from(path!("/root/dir/c"))],
3121 app_state.clone(),
3122 workspace::OpenOptions {
3123 workspace_matching: workspace::WorkspaceMatching::None,
3124 ..Default::default()
3125 },
3126 cx,
3127 )
3128 })
3129 .await
3130 .unwrap();
3131 assert_eq!(cx.update(|cx| cx.windows().len()), 2);
3132
3133 // Opening a path NOT in any existing worktree with -n creates a new window.
3134 cx.update(|cx| {
3135 open_paths(
3136 &[PathBuf::from(path!("/root/b"))],
3137 app_state.clone(),
3138 workspace::OpenOptions {
3139 workspace_matching: workspace::WorkspaceMatching::None,
3140 ..Default::default()
3141 },
3142 cx,
3143 )
3144 })
3145 .await
3146 .unwrap();
3147 assert_eq!(cx.update(|cx| cx.windows().len()), 3);
3148 }
3149
3150 #[gpui::test]
3151 async fn test_open_file_in_many_spaces(cx: &mut TestAppContext) {
3152 let app_state = init_test(cx);
3153 app_state
3154 .fs
3155 .as_fake()
3156 .insert_tree(
3157 path!("/root"),
3158 json!({"dir1": {"a": "b"}, "dir2": {"c": "d"}}),
3159 )
3160 .await;
3161
3162 cx.update(|cx| {
3163 open_paths(
3164 &[PathBuf::from(path!("/root/dir1/a"))],
3165 app_state.clone(),
3166 workspace::OpenOptions::default(),
3167 cx,
3168 )
3169 })
3170 .await
3171 .unwrap();
3172 assert_eq!(cx.update(|cx| cx.windows().len()), 1);
3173
3174 cx.update(|cx| {
3175 open_paths(
3176 &[PathBuf::from(path!("/root/dir2/c"))],
3177 app_state.clone(),
3178 workspace::OpenOptions::default(),
3179 cx,
3180 )
3181 })
3182 .await
3183 .unwrap();
3184 assert_eq!(cx.update(|cx| cx.windows().len()), 1);
3185
3186 // Opening a directory with default options adds to the existing window
3187 // rather than creating a new one.
3188 cx.update(|cx| {
3189 open_paths(
3190 &[PathBuf::from(path!("/root/dir2"))],
3191 app_state.clone(),
3192 workspace::OpenOptions::default(),
3193 cx,
3194 )
3195 })
3196 .await
3197 .unwrap();
3198 assert_eq!(cx.update(|cx| cx.windows().len()), 1);
3199
3200 // Opening a directory already in a worktree with -n creates a new window.
3201 cx.update(|cx| {
3202 open_paths(
3203 &[PathBuf::from(path!("/root/dir2"))],
3204 app_state.clone(),
3205 workspace::OpenOptions {
3206 workspace_matching: workspace::WorkspaceMatching::None,
3207 ..Default::default()
3208 },
3209 cx,
3210 )
3211 })
3212 .await
3213 .unwrap();
3214 assert_eq!(cx.update(|cx| cx.windows().len()), 2);
3215
3216 // Opening a directory NOT in any worktree with -n creates a new window.
3217 cx.update(|cx| {
3218 open_paths(
3219 &[PathBuf::from(path!("/root"))],
3220 app_state.clone(),
3221 workspace::OpenOptions {
3222 workspace_matching: workspace::WorkspaceMatching::None,
3223 ..Default::default()
3224 },
3225 cx,
3226 )
3227 })
3228 .await
3229 .unwrap();
3230 assert_eq!(cx.update(|cx| cx.windows().len()), 3);
3231 }
3232
3233 #[gpui::test]
3234 async fn test_window_edit_state_restoring_disabled(cx: &mut TestAppContext) {
3235 let executor = cx.executor();
3236 let app_state = init_test(cx);
3237
3238 cx.update(|cx| {
3239 SettingsStore::update_global(cx, |store, cx| {
3240 store.update_user_settings(cx, |settings| {
3241 settings
3242 .session
3243 .get_or_insert_default()
3244 .restore_unsaved_buffers = Some(false)
3245 });
3246 });
3247 });
3248
3249 app_state
3250 .fs
3251 .as_fake()
3252 .insert_tree(path!("/root"), json!({"a": "hey"}))
3253 .await;
3254
3255 cx.update(|cx| {
3256 open_paths(
3257 &[PathBuf::from(path!("/root/a"))],
3258 app_state.clone(),
3259 workspace::OpenOptions::default(),
3260 cx,
3261 )
3262 })
3263 .await
3264 .unwrap();
3265 assert_eq!(cx.update(|cx| cx.windows().len()), 1);
3266
3267 // When opening the workspace, the window is not in a edited state.
3268 let window = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
3269
3270 let window_is_edited = |window: WindowHandle<MultiWorkspace>, cx: &mut TestAppContext| {
3271 cx.update(|cx| window.read(cx).unwrap().workspace().read(cx).is_edited())
3272 };
3273 let pane = window
3274 .read_with(cx, |multi_workspace, cx| {
3275 multi_workspace.workspace().read(cx).active_pane().clone()
3276 })
3277 .unwrap();
3278 let editor = window
3279 .read_with(cx, |multi_workspace, cx| {
3280 multi_workspace
3281 .workspace()
3282 .read(cx)
3283 .active_item(cx)
3284 .unwrap()
3285 .downcast::<Editor>()
3286 .unwrap()
3287 })
3288 .unwrap();
3289
3290 assert!(!window_is_edited(window, cx));
3291
3292 // Editing a buffer marks the window as edited.
3293 window
3294 .update(cx, |_, window, cx| {
3295 editor.update(cx, |editor, cx| editor.insert("EDIT", window, cx));
3296 })
3297 .unwrap();
3298
3299 assert!(window_is_edited(window, cx));
3300
3301 // Undoing the edit restores the window's edited state.
3302 window
3303 .update(cx, |_, window, cx| {
3304 editor.update(cx, |editor, cx| {
3305 editor.undo(&Default::default(), window, cx)
3306 });
3307 })
3308 .unwrap();
3309 assert!(!window_is_edited(window, cx));
3310
3311 // Redoing the edit marks the window as edited again.
3312 window
3313 .update(cx, |_, window, cx| {
3314 editor.update(cx, |editor, cx| {
3315 editor.redo(&Default::default(), window, cx)
3316 });
3317 })
3318 .unwrap();
3319 assert!(window_is_edited(window, cx));
3320 let weak = editor.downgrade();
3321
3322 // Closing the item restores the window's edited state.
3323 let close = window
3324 .update(cx, |_, window, cx| {
3325 pane.update(cx, |pane, cx| {
3326 drop(editor);
3327 pane.close_active_item(&Default::default(), window, cx)
3328 })
3329 })
3330 .unwrap();
3331 executor.run_until_parked();
3332
3333 cx.simulate_prompt_answer("Don't Save");
3334 close.await.unwrap();
3335
3336 // Advance the clock to ensure that the item has been serialized and dropped from the queue
3337 cx.executor().advance_clock(Duration::from_secs(1));
3338
3339 weak.assert_released();
3340 assert!(!window_is_edited(window, cx));
3341 // Opening the buffer again doesn't impact the window's edited state.
3342 cx.update(|cx| {
3343 open_paths(
3344 &[PathBuf::from(path!("/root/a"))],
3345 app_state,
3346 workspace::OpenOptions::default(),
3347 cx,
3348 )
3349 })
3350 .await
3351 .unwrap();
3352 executor.run_until_parked();
3353
3354 window
3355 .update(cx, |multi_workspace, _, cx| {
3356 multi_workspace.workspace().update(cx, |workspace, cx| {
3357 let editor = workspace
3358 .active_item(cx)
3359 .unwrap()
3360 .downcast::<Editor>()
3361 .unwrap();
3362
3363 editor.update(cx, |editor, cx| {
3364 assert_eq!(editor.text(cx), "hey");
3365 });
3366 });
3367 })
3368 .unwrap();
3369
3370 let editor = window
3371 .read_with(cx, |multi_workspace, cx| {
3372 multi_workspace
3373 .workspace()
3374 .read(cx)
3375 .active_item(cx)
3376 .unwrap()
3377 .downcast::<Editor>()
3378 .unwrap()
3379 })
3380 .unwrap();
3381 assert!(!window_is_edited(window, cx));
3382
3383 // Editing the buffer marks the window as edited.
3384 window
3385 .update(cx, |_, window, cx| {
3386 editor.update(cx, |editor, cx| editor.insert("EDIT", window, cx));
3387 })
3388 .unwrap();
3389 executor.run_until_parked();
3390 assert!(window_is_edited(window, cx));
3391
3392 // Ensure closing the window via the mouse gets preempted due to the
3393 // buffer having unsaved changes.
3394 assert!(!VisualTestContext::from_window(window.into(), cx).simulate_close());
3395 executor.run_until_parked();
3396 assert_eq!(cx.update(|cx| cx.windows().len()), 1);
3397
3398 // The window is successfully closed after the user dismisses the prompt.
3399 cx.simulate_prompt_answer("Don't Save");
3400 executor.run_until_parked();
3401 assert_eq!(cx.update(|cx| cx.windows().len()), 0);
3402 }
3403
3404 #[ignore = "This test has timing issues across platforms."]
3405 #[gpui::test]
3406 async fn test_window_edit_state_restoring_enabled(cx: &mut TestAppContext) {
3407 let app_state = init_test(cx);
3408 app_state
3409 .fs
3410 .as_fake()
3411 .insert_tree(path!("/root"), json!({"a": "hey"}))
3412 .await;
3413
3414 cx.update(|cx| {
3415 open_paths(
3416 &[PathBuf::from(path!("/root/a"))],
3417 app_state.clone(),
3418 workspace::OpenOptions::default(),
3419 cx,
3420 )
3421 })
3422 .await
3423 .unwrap();
3424
3425 assert_eq!(cx.update(|cx| cx.windows().len()), 1);
3426
3427 // When opening the workspace, the window is not in a edited state.
3428 let window = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
3429
3430 let window_is_edited = |window: WindowHandle<MultiWorkspace>, cx: &mut TestAppContext| {
3431 cx.update(|cx| window.read(cx).unwrap().workspace().read(cx).is_edited())
3432 };
3433 let workspace_database_id = |window: WindowHandle<MultiWorkspace>,
3434 cx: &mut TestAppContext| {
3435 cx.update(|cx| window.read(cx).unwrap().workspace().read(cx).database_id())
3436 };
3437
3438 let editor = window
3439 .read_with(cx, |multi_workspace, cx| {
3440 multi_workspace
3441 .workspace()
3442 .read(cx)
3443 .active_item(cx)
3444 .unwrap()
3445 .downcast::<Editor>()
3446 .unwrap()
3447 })
3448 .unwrap();
3449
3450 assert!(!window_is_edited(window, cx));
3451 let initial_database_id = workspace_database_id(window, cx);
3452 assert!(
3453 initial_database_id.is_some(),
3454 "a restored workspace must have a stable database id"
3455 );
3456
3457 // Editing a buffer marks the window as edited.
3458 window
3459 .update(cx, |_, window, cx| {
3460 editor.update(cx, |editor, cx| editor.insert("EDIT", window, cx));
3461 })
3462 .unwrap();
3463 cx.run_until_parked();
3464
3465 assert!(window_is_edited(window, cx));
3466
3467 // Advance the clock to make sure the workspace is serialized
3468 cx.executor().advance_clock(Duration::from_secs(1));
3469
3470 // When closing the window, no prompt shows up and the window is closed.
3471 // buffer having unsaved changes.
3472 assert!(!VisualTestContext::from_window(window.into(), cx).simulate_close());
3473 cx.run_until_parked();
3474 assert_eq!(cx.update(|cx| cx.windows().len()), 0);
3475
3476 // When we now reopen the window, the edited state and the edited buffer are back
3477 cx.update(|cx| {
3478 open_paths(
3479 &[PathBuf::from(path!("/root/a"))],
3480 app_state.clone(),
3481 workspace::OpenOptions::default(),
3482 cx,
3483 )
3484 })
3485 .await
3486 .unwrap();
3487
3488 assert_eq!(cx.update(|cx| cx.windows().len()), 1);
3489 assert!(cx.update(|cx| cx.active_window().is_some()));
3490
3491 cx.run_until_parked();
3492
3493 // When opening the workspace, the window is not in a edited state.
3494 let window = cx.update(|cx| {
3495 cx.active_window()
3496 .unwrap()
3497 .downcast::<MultiWorkspace>()
3498 .unwrap()
3499 });
3500 assert!(window_is_edited(window, cx));
3501 assert_eq!(
3502 workspace_database_id(window, cx),
3503 initial_database_id,
3504 "the workspace must keep the same database id across a close/reopen cycle"
3505 );
3506
3507 window
3508 .update(cx, |multi_workspace, _, cx| {
3509 multi_workspace.workspace().update(cx, |workspace, cx| {
3510 let editor = workspace
3511 .active_item(cx)
3512 .unwrap()
3513 .downcast::<editor::Editor>()
3514 .unwrap();
3515 editor.update(cx, |editor, cx| {
3516 assert_eq!(editor.text(cx), "EDIThey");
3517 assert!(editor.is_dirty(cx));
3518 });
3519 });
3520 })
3521 .unwrap();
3522 }
3523
3524 #[gpui::test]
3525 async fn test_new_empty_workspace(cx: &mut TestAppContext) {
3526 let app_state = init_test(cx);
3527 cx.update(|cx| {
3528 open_new(
3529 Default::default(),
3530 app_state.clone(),
3531 cx,
3532 |workspace, window, cx| {
3533 Editor::new_file(workspace, &Default::default(), window, cx)
3534 },
3535 )
3536 })
3537 .await
3538 .unwrap();
3539 cx.run_until_parked();
3540
3541 let multi_workspace = cx
3542 .update(|cx| cx.windows().first().unwrap().downcast::<MultiWorkspace>())
3543 .unwrap();
3544
3545 let editor = multi_workspace
3546 .update(cx, |multi_workspace, _, cx| {
3547 multi_workspace.workspace().update(cx, |workspace, cx| {
3548 let editor = workspace
3549 .active_item(cx)
3550 .unwrap()
3551 .downcast::<editor::Editor>()
3552 .unwrap();
3553 editor.update(cx, |editor, cx| {
3554 assert!(editor.text(cx).is_empty());
3555 assert!(!editor.is_dirty(cx));
3556 });
3557
3558 editor
3559 })
3560 })
3561 .unwrap();
3562
3563 let save_task = multi_workspace
3564 .update(cx, |multi_workspace, window, cx| {
3565 multi_workspace.workspace().update(cx, |workspace, cx| {
3566 workspace.save_active_item(SaveIntent::Save, window, cx)
3567 })
3568 })
3569 .unwrap();
3570 app_state.fs.create_dir(Path::new("/root")).await.unwrap();
3571 cx.background_executor.run_until_parked();
3572 cx.simulate_new_path_selection(|_| Some(PathBuf::from("/root/the-new-name")));
3573 save_task.await.unwrap();
3574 multi_workspace
3575 .update(cx, |_, _, cx| {
3576 editor.update(cx, |editor, cx| {
3577 assert!(!editor.is_dirty(cx));
3578 assert_eq!(editor.title(cx), "the-new-name");
3579 });
3580 })
3581 .unwrap();
3582 }
3583
3584 #[gpui::test]
3585 async fn test_open_entry(cx: &mut TestAppContext) {
3586 let app_state = init_test(cx);
3587 app_state
3588 .fs
3589 .as_fake()
3590 .insert_tree(
3591 path!("/root"),
3592 json!({
3593 "a": {
3594 "file1": "contents 1",
3595 "file2": "contents 2",
3596 "file3": "contents 3",
3597 },
3598 }),
3599 )
3600 .await;
3601
3602 let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
3603 project.update(cx, |project, _cx| project.languages().add(markdown_lang()));
3604 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx));
3605 let workspace = window
3606 .read_with(cx, |mw, _| mw.workspace().clone())
3607 .unwrap();
3608
3609 let entries = cx.read(|cx| workspace.file_project_paths(cx));
3610 let file1 = entries[0].clone();
3611 let file2 = entries[1].clone();
3612 let file3 = entries[2].clone();
3613
3614 // Open the first entry
3615 let entry_1 = window
3616 .update(cx, |_, window, cx| {
3617 workspace.update(cx, |w, cx| {
3618 w.open_path(file1.clone(), None, true, window, cx)
3619 })
3620 })
3621 .unwrap()
3622 .await
3623 .unwrap();
3624 cx.read(|cx| {
3625 let pane = workspace.read(cx).active_pane().read(cx);
3626 assert_eq!(
3627 pane.active_item().unwrap().project_path(cx),
3628 Some(file1.clone())
3629 );
3630 assert_eq!(pane.items_len(), 1);
3631 });
3632
3633 // Open the second entry
3634 window
3635 .update(cx, |_, window, cx| {
3636 workspace.update(cx, |w, cx| {
3637 w.open_path(file2.clone(), None, true, window, cx)
3638 })
3639 })
3640 .unwrap()
3641 .await
3642 .unwrap();
3643 cx.read(|cx| {
3644 let pane = workspace.read(cx).active_pane().read(cx);
3645 assert_eq!(
3646 pane.active_item().unwrap().project_path(cx),
3647 Some(file2.clone())
3648 );
3649 assert_eq!(pane.items_len(), 2);
3650 });
3651
3652 // Open the first entry again. The existing pane item is activated.
3653 let entry_1b = window
3654 .update(cx, |_, window, cx| {
3655 workspace.update(cx, |w, cx| {
3656 w.open_path(file1.clone(), None, true, window, cx)
3657 })
3658 })
3659 .unwrap()
3660 .await
3661 .unwrap();
3662 assert_eq!(entry_1.item_id(), entry_1b.item_id());
3663
3664 cx.read(|cx| {
3665 let pane = workspace.read(cx).active_pane().read(cx);
3666 assert_eq!(
3667 pane.active_item().unwrap().project_path(cx),
3668 Some(file1.clone())
3669 );
3670 assert_eq!(pane.items_len(), 2);
3671 });
3672
3673 // Split the pane with the first entry, then open the second entry again.
3674 window
3675 .update(cx, |_, window, cx| {
3676 workspace.update(cx, |w, cx| {
3677 w.split_and_clone(w.active_pane().clone(), SplitDirection::Right, window, cx)
3678 })
3679 })
3680 .unwrap()
3681 .await
3682 .unwrap();
3683 window
3684 .update(cx, |_, window, cx| {
3685 workspace.update(cx, |w, cx| {
3686 w.open_path(file2.clone(), None, true, window, cx)
3687 })
3688 })
3689 .unwrap()
3690 .await
3691 .unwrap();
3692
3693 cx.read(|cx| {
3694 assert_eq!(
3695 workspace
3696 .read(cx)
3697 .active_pane()
3698 .read(cx)
3699 .active_item()
3700 .unwrap()
3701 .project_path(cx),
3702 Some(file2.clone())
3703 );
3704 });
3705
3706 // Open the third entry twice concurrently. Only one pane item is added.
3707 let (t1, t2) = window
3708 .update(cx, |_, window, cx| {
3709 workspace.update(cx, |w, cx| {
3710 (
3711 w.open_path(file3.clone(), None, true, window, cx),
3712 w.open_path(file3.clone(), None, true, window, cx),
3713 )
3714 })
3715 })
3716 .unwrap();
3717 t1.await.unwrap();
3718 t2.await.unwrap();
3719 cx.read(|cx| {
3720 let pane = workspace.read(cx).active_pane().read(cx);
3721 assert_eq!(
3722 pane.active_item().unwrap().project_path(cx),
3723 Some(file3.clone())
3724 );
3725 let pane_entries = pane
3726 .items()
3727 .map(|i| i.project_path(cx).unwrap())
3728 .collect::<Vec<_>>();
3729 assert_eq!(pane_entries, &[file1, file2, file3]);
3730 });
3731 }
3732
3733 #[gpui::test]
3734 async fn test_open_paths(cx: &mut TestAppContext) {
3735 let app_state = init_test(cx);
3736
3737 app_state
3738 .fs
3739 .as_fake()
3740 .insert_tree(
3741 path!("/"),
3742 json!({
3743 "dir1": {
3744 "a.txt": ""
3745 },
3746 "dir2": {
3747 "b.txt": ""
3748 },
3749 "dir3": {
3750 "c.txt": ""
3751 },
3752 "d.txt": ""
3753 }),
3754 )
3755 .await;
3756
3757 cx.update(|cx| {
3758 open_paths(
3759 &[PathBuf::from(path!("/dir1/"))],
3760 app_state,
3761 workspace::OpenOptions::default(),
3762 cx,
3763 )
3764 })
3765 .await
3766 .unwrap();
3767 cx.run_until_parked();
3768 assert_eq!(cx.update(|cx| cx.windows().len()), 1);
3769 let window = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
3770 let workspace = window
3771 .read_with(cx, |mw, _| mw.workspace().clone())
3772 .unwrap();
3773
3774 #[track_caller]
3775 fn assert_project_panel_selection(
3776 workspace: &Workspace,
3777 expected_worktree_path: &Path,
3778 expected_entry_path: &RelPath,
3779 cx: &App,
3780 ) {
3781 let project_panel = [
3782 workspace.left_dock().read(cx).panel::<ProjectPanel>(),
3783 workspace.right_dock().read(cx).panel::<ProjectPanel>(),
3784 workspace.bottom_dock().read(cx).panel::<ProjectPanel>(),
3785 ]
3786 .into_iter()
3787 .find_map(std::convert::identity)
3788 .expect("found no project panels")
3789 .read(cx);
3790 let (selected_worktree, selected_entry) = project_panel
3791 .selected_entry(cx)
3792 .expect("project panel should have a selected entry");
3793 assert_eq!(
3794 selected_worktree.abs_path().as_ref(),
3795 expected_worktree_path,
3796 "Unexpected project panel selected worktree path"
3797 );
3798 assert_eq!(
3799 selected_entry.path.as_ref(),
3800 expected_entry_path,
3801 "Unexpected project panel selected entry path"
3802 );
3803 }
3804
3805 // Open a file within an existing worktree.
3806 window
3807 .update(cx, |multi_workspace, window, cx| {
3808 multi_workspace.workspace().update(cx, |workspace, cx| {
3809 workspace.open_paths(
3810 vec![path!("/dir1/a.txt").into()],
3811 OpenOptions {
3812 visible: Some(OpenVisible::All),
3813 ..Default::default()
3814 },
3815 None,
3816 window,
3817 cx,
3818 )
3819 })
3820 })
3821 .unwrap()
3822 .await;
3823 cx.run_until_parked();
3824 cx.read(|cx| {
3825 let workspace = workspace.read(cx);
3826 assert_project_panel_selection(
3827 workspace,
3828 Path::new(path!("/dir1")),
3829 rel_path("a.txt"),
3830 cx,
3831 );
3832 assert_eq!(
3833 workspace
3834 .active_pane()
3835 .read(cx)
3836 .active_item()
3837 .unwrap()
3838 .act_as::<Editor>(cx)
3839 .unwrap()
3840 .read(cx)
3841 .title(cx),
3842 "a.txt"
3843 );
3844 });
3845
3846 // Open a file outside of any existing worktree.
3847 window
3848 .update(cx, |multi_workspace, window, cx| {
3849 multi_workspace.workspace().update(cx, |workspace, cx| {
3850 workspace.open_paths(
3851 vec![path!("/dir2/b.txt").into()],
3852 OpenOptions {
3853 visible: Some(OpenVisible::All),
3854 ..Default::default()
3855 },
3856 None,
3857 window,
3858 cx,
3859 )
3860 })
3861 })
3862 .unwrap()
3863 .await;
3864 cx.run_until_parked();
3865 cx.read(|cx| {
3866 let workspace = workspace.read(cx);
3867 assert_project_panel_selection(
3868 workspace,
3869 Path::new(path!("/dir2/b.txt")),
3870 rel_path(""),
3871 cx,
3872 );
3873 let worktree_roots = workspace
3874 .worktrees(cx)
3875 .map(|w| w.read(cx).as_local().unwrap().abs_path().as_ref())
3876 .collect::<HashSet<_>>();
3877 assert_eq!(
3878 worktree_roots,
3879 vec![path!("/dir1"), path!("/dir2/b.txt")]
3880 .into_iter()
3881 .map(Path::new)
3882 .collect(),
3883 );
3884 assert_eq!(
3885 workspace
3886 .active_pane()
3887 .read(cx)
3888 .active_item()
3889 .unwrap()
3890 .act_as::<Editor>(cx)
3891 .unwrap()
3892 .read(cx)
3893 .title(cx),
3894 "b.txt"
3895 );
3896 });
3897
3898 // Ensure opening a directory and one of its children only adds one worktree.
3899 window
3900 .update(cx, |multi_workspace, window, cx| {
3901 multi_workspace.workspace().update(cx, |workspace, cx| {
3902 workspace.open_paths(
3903 vec![path!("/dir3").into(), path!("/dir3/c.txt").into()],
3904 OpenOptions {
3905 visible: Some(OpenVisible::All),
3906 ..Default::default()
3907 },
3908 None,
3909 window,
3910 cx,
3911 )
3912 })
3913 })
3914 .unwrap()
3915 .await;
3916 cx.run_until_parked();
3917 cx.read(|cx| {
3918 let workspace = workspace.read(cx);
3919 assert_project_panel_selection(
3920 workspace,
3921 Path::new(path!("/dir3")),
3922 rel_path("c.txt"),
3923 cx,
3924 );
3925 let worktree_roots = workspace
3926 .worktrees(cx)
3927 .map(|w| w.read(cx).as_local().unwrap().abs_path().as_ref())
3928 .collect::<HashSet<_>>();
3929 assert_eq!(
3930 worktree_roots,
3931 vec![path!("/dir1"), path!("/dir2/b.txt"), path!("/dir3")]
3932 .into_iter()
3933 .map(Path::new)
3934 .collect(),
3935 );
3936 assert_eq!(
3937 workspace
3938 .active_pane()
3939 .read(cx)
3940 .active_item()
3941 .unwrap()
3942 .act_as::<Editor>(cx)
3943 .unwrap()
3944 .read(cx)
3945 .title(cx),
3946 "c.txt"
3947 );
3948 });
3949
3950 // Ensure opening invisibly a file outside an existing worktree adds a new, invisible worktree.
3951 window
3952 .update(cx, |multi_workspace, window, cx| {
3953 multi_workspace.workspace().update(cx, |workspace, cx| {
3954 workspace.open_paths(
3955 vec![path!("/d.txt").into()],
3956 OpenOptions {
3957 visible: Some(OpenVisible::None),
3958 ..Default::default()
3959 },
3960 None,
3961 window,
3962 cx,
3963 )
3964 })
3965 })
3966 .unwrap()
3967 .await;
3968 cx.run_until_parked();
3969 cx.read(|cx| {
3970 let workspace = workspace.read(cx);
3971 assert_project_panel_selection(workspace, Path::new(path!("/d.txt")), rel_path(""), cx);
3972 let worktree_roots = workspace
3973 .worktrees(cx)
3974 .map(|w| w.read(cx).as_local().unwrap().abs_path().as_ref())
3975 .collect::<HashSet<_>>();
3976 assert_eq!(
3977 worktree_roots,
3978 vec![
3979 path!("/dir1"),
3980 path!("/dir2/b.txt"),
3981 path!("/dir3"),
3982 path!("/d.txt")
3983 ]
3984 .into_iter()
3985 .map(Path::new)
3986 .collect(),
3987 );
3988
3989 let visible_worktree_roots = workspace
3990 .visible_worktrees(cx)
3991 .map(|w| w.read(cx).as_local().unwrap().abs_path().as_ref())
3992 .collect::<HashSet<_>>();
3993 assert_eq!(
3994 visible_worktree_roots,
3995 vec![path!("/dir1"), path!("/dir2/b.txt"), path!("/dir3")]
3996 .into_iter()
3997 .map(Path::new)
3998 .collect(),
3999 );
4000
4001 assert_eq!(
4002 workspace
4003 .active_pane()
4004 .read(cx)
4005 .active_item()
4006 .unwrap()
4007 .act_as::<Editor>(cx)
4008 .unwrap()
4009 .read(cx)
4010 .title(cx),
4011 "d.txt"
4012 );
4013 });
4014 }
4015
4016 #[gpui::test]
4017 async fn test_opening_excluded_paths(cx: &mut TestAppContext) {
4018 let app_state = init_test(cx);
4019 cx.update(|cx| {
4020 cx.update_global::<SettingsStore, _>(|store, cx| {
4021 store.update_user_settings(cx, |project_settings| {
4022 project_settings.project.worktree.file_scan_exclusions =
4023 Some(vec!["excluded_dir".to_string(), "**/.git".to_string()]);
4024 });
4025 });
4026 });
4027 app_state
4028 .fs
4029 .as_fake()
4030 .insert_tree(
4031 path!("/root"),
4032 json!({
4033 ".gitignore": "ignored_dir\n",
4034 ".git": {
4035 "HEAD": "ref: refs/heads/main",
4036 },
4037 "regular_dir": {
4038 "file": "regular file contents",
4039 },
4040 "ignored_dir": {
4041 "ignored_subdir": {
4042 "file": "ignored subfile contents",
4043 },
4044 "file": "ignored file contents",
4045 },
4046 "excluded_dir": {
4047 "file": "excluded file contents",
4048 "ignored_subdir": {
4049 "file": "ignored subfile contents",
4050 },
4051 },
4052 }),
4053 )
4054 .await;
4055
4056 let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
4057 project.update(cx, |project, _cx| project.languages().add(markdown_lang()));
4058 let window = cx.add_window({
4059 let project = project.clone();
4060 |window, cx| MultiWorkspace::test_new(project, window, cx)
4061 });
4062 let workspace = window
4063 .read_with(cx, |mw, _| mw.workspace().clone())
4064 .unwrap();
4065
4066 let initial_entries = cx.read(|cx| workspace.file_project_paths(cx));
4067 let paths_to_open = [
4068 PathBuf::from(path!("/root/excluded_dir/file")),
4069 PathBuf::from(path!("/root/.git/HEAD")),
4070 PathBuf::from(path!("/root/excluded_dir/ignored_subdir")),
4071 ];
4072 let workspace::OpenResult {
4073 window: opened_workspace,
4074 opened_items: new_items,
4075 ..
4076 } = cx
4077 .update(|cx| {
4078 workspace::open_paths(
4079 &paths_to_open,
4080 app_state,
4081 workspace::OpenOptions::default(),
4082 cx,
4083 )
4084 })
4085 .await
4086 .unwrap();
4087
4088 assert_eq!(
4089 opened_workspace
4090 .read_with(cx, |mw, _| mw.workspace().entity_id())
4091 .unwrap(),
4092 workspace.entity_id(),
4093 "Excluded files in subfolders of a workspace root should be opened in the workspace"
4094 );
4095 let mut opened_paths = cx.read(|cx| {
4096 assert_eq!(
4097 new_items.len(),
4098 paths_to_open.len(),
4099 "Expect to get the same number of opened items as submitted paths to open"
4100 );
4101 new_items
4102 .iter()
4103 .zip(paths_to_open.iter())
4104 .map(|(i, path)| {
4105 match i {
4106 Some(Ok(i)) => Some(i.project_path(cx).map(|p| p.path)),
4107 Some(Err(e)) => panic!("Excluded file {path:?} failed to open: {e:?}"),
4108 None => None,
4109 }
4110 .flatten()
4111 })
4112 .collect::<Vec<_>>()
4113 });
4114 opened_paths.sort();
4115 assert_eq!(
4116 opened_paths,
4117 vec![
4118 None,
4119 Some(rel_path(".git/HEAD").into()),
4120 Some(rel_path("excluded_dir/file").into()),
4121 ],
4122 "Excluded files should get opened, excluded dir should not get opened"
4123 );
4124
4125 let entries = cx.read(|cx| workspace.file_project_paths(cx));
4126 assert_eq!(
4127 initial_entries, entries,
4128 "Workspace entries should not change after opening excluded files and directories paths"
4129 );
4130
4131 cx.read(|cx| {
4132 let pane = workspace.read(cx).active_pane().read(cx);
4133 let mut opened_buffer_paths = pane
4134 .items()
4135 .map(|i| {
4136 i.project_path(cx)
4137 .expect("all excluded files that got open should have a path")
4138 .path
4139 })
4140 .collect::<Vec<_>>();
4141 opened_buffer_paths.sort();
4142 assert_eq!(
4143 opened_buffer_paths,
4144 vec![rel_path(".git/HEAD").into(), rel_path("excluded_dir/file").into()],
4145 "Despite not being present in the worktrees, buffers for excluded files are opened and added to the pane"
4146 );
4147 });
4148 }
4149
4150 #[gpui::test]
4151 async fn test_save_conflicting_item(cx: &mut TestAppContext) {
4152 let app_state = init_test(cx);
4153 app_state
4154 .fs
4155 .as_fake()
4156 .insert_tree(path!("/root"), json!({ "a.txt": "" }))
4157 .await;
4158
4159 let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
4160 project.update(cx, |project, _cx| project.languages().add(markdown_lang()));
4161 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx));
4162 let workspace = window
4163 .read_with(cx, |mw, _| mw.workspace().clone())
4164 .unwrap();
4165
4166 // Open a file within an existing worktree.
4167 window
4168 .update(cx, |_, window, cx| {
4169 workspace.update(cx, |workspace, cx| {
4170 workspace.open_paths(
4171 vec![PathBuf::from(path!("/root/a.txt"))],
4172 OpenOptions {
4173 visible: Some(OpenVisible::All),
4174 ..Default::default()
4175 },
4176 None,
4177 window,
4178 cx,
4179 )
4180 })
4181 })
4182 .unwrap()
4183 .await;
4184 let editor = cx.read(|cx| {
4185 let pane = workspace.read(cx).active_pane().read(cx);
4186 let item = pane.active_item().unwrap();
4187 item.downcast::<Editor>().unwrap()
4188 });
4189
4190 window
4191 .update(cx, |_, window, cx| {
4192 editor.update(cx, |editor, cx| editor.handle_input("x", window, cx));
4193 })
4194 .unwrap();
4195
4196 app_state
4197 .fs
4198 .as_fake()
4199 .insert_file(path!("/root/a.txt"), b"changed".to_vec())
4200 .await;
4201
4202 cx.run_until_parked();
4203 cx.read(|cx| assert!(editor.is_dirty(cx)));
4204 cx.read(|cx| assert!(editor.has_conflict(cx)));
4205
4206 let save_task = window
4207 .update(cx, |_, window, cx| {
4208 workspace.update(cx, |workspace, cx| {
4209 workspace.save_active_item(SaveIntent::Save, window, cx)
4210 })
4211 })
4212 .unwrap();
4213 cx.background_executor.run_until_parked();
4214 cx.simulate_prompt_answer("Overwrite");
4215 save_task.await.unwrap();
4216 window
4217 .update(cx, |_, _, cx| {
4218 editor.update(cx, |editor, cx| {
4219 assert!(!editor.is_dirty(cx));
4220 assert!(!editor.has_conflict(cx));
4221 });
4222 })
4223 .unwrap();
4224 }
4225
4226 #[gpui::test]
4227 async fn test_open_and_save_new_file(cx: &mut TestAppContext) {
4228 let app_state = init_test(cx);
4229 app_state
4230 .fs
4231 .create_dir(Path::new(path!("/root")))
4232 .await
4233 .unwrap();
4234
4235 let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
4236 project.update(cx, |project, _| {
4237 project.languages().add(markdown_lang());
4238 project.languages().add(rust_lang());
4239 });
4240 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx));
4241 let workspace = window
4242 .read_with(cx, |mw, _| mw.workspace().clone())
4243 .unwrap();
4244 let worktree = cx.read(|cx| workspace.read(cx).worktrees(cx).next().unwrap());
4245
4246 // Create a new untitled buffer
4247 cx.dispatch_action(window.into(), NewFile);
4248 let editor = cx.read(|cx| {
4249 workspace
4250 .read(cx)
4251 .active_item(cx)
4252 .unwrap()
4253 .downcast::<Editor>()
4254 .unwrap()
4255 });
4256
4257 window
4258 .update(cx, |_, window, cx| {
4259 editor.update(cx, |editor, cx| {
4260 assert!(!editor.is_dirty(cx));
4261 assert_eq!(editor.title(cx), "untitled");
4262 assert!(Arc::ptr_eq(
4263 &editor
4264 .buffer()
4265 .read(cx)
4266 .language_at(MultiBufferOffset(0), cx)
4267 .unwrap(),
4268 &languages::PLAIN_TEXT
4269 ));
4270 editor.handle_input("hi", window, cx);
4271 assert!(editor.is_dirty(cx));
4272 });
4273 })
4274 .unwrap();
4275
4276 // Save the buffer. This prompts for a filename.
4277 let save_task = window
4278 .update(cx, |_, window, cx| {
4279 workspace.update(cx, |workspace, cx| {
4280 workspace.save_active_item(SaveIntent::Save, window, cx)
4281 })
4282 })
4283 .unwrap();
4284 cx.background_executor.run_until_parked();
4285 cx.simulate_new_path_selection(|parent_dir| {
4286 assert_eq!(parent_dir, Path::new(path!("/root")));
4287 Some(parent_dir.join("the-new-name.rs"))
4288 });
4289 cx.read(|cx| {
4290 assert!(editor.is_dirty(cx));
4291 assert_eq!(editor.read(cx).title(cx), "hi");
4292 });
4293
4294 // When the save completes, the buffer's title is updated and the language is assigned based
4295 // on the path.
4296 save_task.await.unwrap();
4297 window
4298 .update(cx, |_, _, cx| {
4299 editor.update(cx, |editor, cx| {
4300 assert!(!editor.is_dirty(cx));
4301 assert_eq!(editor.title(cx), "the-new-name.rs");
4302 assert_eq!(
4303 editor
4304 .buffer()
4305 .read(cx)
4306 .language_at(MultiBufferOffset(0), cx)
4307 .unwrap()
4308 .name(),
4309 "Rust"
4310 );
4311 });
4312 })
4313 .unwrap();
4314
4315 // Edit the file and save it again. This time, there is no filename prompt.
4316 window
4317 .update(cx, |_, window, cx| {
4318 editor.update(cx, |editor, cx| {
4319 editor.handle_input(" there", window, cx);
4320 assert!(editor.is_dirty(cx));
4321 });
4322 })
4323 .unwrap();
4324
4325 let save_task = window
4326 .update(cx, |_, window, cx| {
4327 workspace.update(cx, |workspace, cx| {
4328 workspace.save_active_item(SaveIntent::Save, window, cx)
4329 })
4330 })
4331 .unwrap();
4332 save_task.await.unwrap();
4333
4334 assert!(!cx.did_prompt_for_new_path());
4335 window
4336 .update(cx, |_, _, cx| {
4337 editor.update(cx, |editor, cx| {
4338 assert!(!editor.is_dirty(cx));
4339 assert_eq!(editor.title(cx), "the-new-name.rs")
4340 });
4341 })
4342 .unwrap();
4343
4344 // Open the same newly-created file in another pane item. The new editor should reuse
4345 // the same buffer.
4346 cx.dispatch_action(window.into(), NewFile);
4347 window
4348 .update(cx, |_, window, cx| {
4349 workspace.update(cx, |workspace, cx| {
4350 workspace.split_and_clone(
4351 workspace.active_pane().clone(),
4352 SplitDirection::Right,
4353 window,
4354 cx,
4355 )
4356 })
4357 })
4358 .unwrap()
4359 .await
4360 .unwrap();
4361 window
4362 .update(cx, |_, window, cx| {
4363 workspace.update(cx, |workspace, cx| {
4364 workspace.open_path(
4365 (worktree.read(cx).id(), rel_path("the-new-name.rs")),
4366 None,
4367 true,
4368 window,
4369 cx,
4370 )
4371 })
4372 })
4373 .unwrap()
4374 .await
4375 .unwrap();
4376 let editor2 = cx.read(|cx| {
4377 workspace
4378 .read(cx)
4379 .active_item(cx)
4380 .unwrap()
4381 .downcast::<Editor>()
4382 .unwrap()
4383 });
4384 cx.read(|cx| {
4385 assert_eq!(
4386 editor2.read(cx).buffer().read(cx).as_singleton().unwrap(),
4387 editor.read(cx).buffer().read(cx).as_singleton().unwrap()
4388 );
4389 })
4390 }
4391
4392 #[gpui::test]
4393 async fn test_setting_language_when_saving_as_single_file_worktree(cx: &mut TestAppContext) {
4394 let app_state = init_test(cx);
4395 app_state.fs.create_dir(Path::new("/root")).await.unwrap();
4396
4397 let project = Project::test(app_state.fs.clone(), [], cx).await;
4398 project.update(cx, |project, _| {
4399 project.languages().add(language::rust_lang());
4400 project.languages().add(language::markdown_lang());
4401 });
4402 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx));
4403 let workspace = window
4404 .read_with(cx, |mw, _| mw.workspace().clone())
4405 .unwrap();
4406
4407 // Create a new untitled buffer
4408 cx.dispatch_action(window.into(), NewFile);
4409 let editor = cx.read(|cx| {
4410 workspace
4411 .read(cx)
4412 .active_item(cx)
4413 .unwrap()
4414 .downcast::<Editor>()
4415 .unwrap()
4416 });
4417 window
4418 .update(cx, |_, window, cx| {
4419 editor.update(cx, |editor, cx| {
4420 assert!(Arc::ptr_eq(
4421 &editor
4422 .buffer()
4423 .read(cx)
4424 .language_at(MultiBufferOffset(0), cx)
4425 .unwrap(),
4426 &languages::PLAIN_TEXT
4427 ));
4428 editor.handle_input("hi", window, cx);
4429 assert!(editor.is_dirty(cx));
4430 });
4431 })
4432 .unwrap();
4433
4434 // Save the buffer. This prompts for a filename.
4435 let save_task = window
4436 .update(cx, |_, window, cx| {
4437 workspace.update(cx, |workspace, cx| {
4438 workspace.save_active_item(SaveIntent::Save, window, cx)
4439 })
4440 })
4441 .unwrap();
4442 cx.background_executor.run_until_parked();
4443 cx.simulate_new_path_selection(|_| Some(PathBuf::from("/root/the-new-name.rs")));
4444 save_task.await.unwrap();
4445 // The buffer is not dirty anymore and the language is assigned based on the path.
4446 window
4447 .update(cx, |_, _, cx| {
4448 editor.update(cx, |editor, cx| {
4449 assert!(!editor.is_dirty(cx));
4450 assert_eq!(
4451 editor
4452 .buffer()
4453 .read(cx)
4454 .language_at(MultiBufferOffset(0), cx)
4455 .unwrap()
4456 .name(),
4457 "Rust"
4458 )
4459 });
4460 })
4461 .unwrap();
4462 }
4463
4464 #[gpui::test]
4465 async fn test_pane_actions(cx: &mut TestAppContext) {
4466 let app_state = init_test(cx);
4467 app_state
4468 .fs
4469 .as_fake()
4470 .insert_tree(
4471 path!("/root"),
4472 json!({
4473 "a": {
4474 "file1": "contents 1",
4475 "file2": "contents 2",
4476 "file3": "contents 3",
4477 },
4478 }),
4479 )
4480 .await;
4481
4482 let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
4483 project.update(cx, |project, _cx| project.languages().add(markdown_lang()));
4484 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx));
4485 let workspace = window
4486 .read_with(cx, |mw, _| mw.workspace().clone())
4487 .unwrap();
4488 let cx = &mut VisualTestContext::from_window(*window, cx);
4489
4490 let entries = cx.read(|cx| workspace.file_project_paths(cx));
4491 let file1 = entries[0].clone();
4492
4493 let pane_1 = cx.read(|cx| workspace.read(cx).active_pane().clone());
4494
4495 workspace
4496 .update_in(cx, |w, window, cx| {
4497 w.open_path(file1.clone(), None, true, window, cx)
4498 })
4499 .await
4500 .unwrap();
4501
4502 let (editor_1, buffer) = workspace.update_in(cx, |_, window, cx| {
4503 pane_1.update(cx, |pane_1, cx| {
4504 let editor = pane_1.active_item().unwrap().downcast::<Editor>().unwrap();
4505 assert_eq!(editor.read(cx).active_project_path(cx), Some(file1.clone()));
4506 let buffer = editor.update(cx, |editor, cx| {
4507 editor.insert("dirt", window, cx);
4508 editor.buffer().downgrade()
4509 });
4510 (editor.downgrade(), buffer)
4511 })
4512 });
4513
4514 cx.dispatch_action(pane::SplitRight::default());
4515 let editor_2 = cx.update(|_, cx| {
4516 let pane_2 = workspace.read(cx).active_pane().clone();
4517 assert_ne!(pane_1, pane_2);
4518
4519 let pane2_item = pane_2.read(cx).active_item().unwrap();
4520 assert_eq!(pane2_item.project_path(cx), Some(file1.clone()));
4521
4522 pane2_item.downcast::<Editor>().unwrap().downgrade()
4523 });
4524 cx.dispatch_action(workspace::CloseActiveItem {
4525 save_intent: None,
4526 close_pinned: false,
4527 });
4528
4529 cx.background_executor.run_until_parked();
4530 workspace.read_with(cx, |workspace, _| {
4531 assert_eq!(workspace.panes().len(), 1);
4532 assert_eq!(workspace.active_pane(), &pane_1);
4533 });
4534
4535 cx.dispatch_action(workspace::CloseActiveItem {
4536 save_intent: None,
4537 close_pinned: false,
4538 });
4539 cx.background_executor.run_until_parked();
4540 cx.simulate_prompt_answer("Don't Save");
4541 cx.background_executor.run_until_parked();
4542
4543 workspace.read_with(cx, |workspace, cx| {
4544 assert_eq!(workspace.panes().len(), 1);
4545 assert!(workspace.active_item(cx).is_none());
4546 });
4547
4548 cx.background_executor
4549 .advance_clock(SERIALIZATION_THROTTLE_TIME);
4550 cx.update(|_, _| {});
4551 editor_1.assert_released();
4552 editor_2.assert_released();
4553 buffer.assert_released();
4554 }
4555
4556 #[gpui::test]
4557 async fn test_editor_zoom_with_scroll_wheel(cx: &mut TestAppContext) {
4558 let app_state = init_test(cx);
4559 app_state
4560 .fs
4561 .as_fake()
4562 .insert_tree(path!("/root"), json!({ "file.txt": "hello\nworld\n" }))
4563 .await;
4564
4565 let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
4566 let window =
4567 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4568 let workspace = window
4569 .read_with(cx, |mw, _| mw.workspace().clone())
4570 .unwrap();
4571 let cx = &mut VisualTestContext::from_window(*window, cx);
4572
4573 let mouse_position = point(px(250.), px(250.));
4574
4575 let event_modifiers = {
4576 #[cfg(target_os = "macos")]
4577 {
4578 Modifiers {
4579 platform: true,
4580 ..Modifiers::default()
4581 }
4582 }
4583
4584 #[cfg(not(target_os = "macos"))]
4585 {
4586 Modifiers {
4587 control: true,
4588 ..Modifiers::default()
4589 }
4590 }
4591 };
4592
4593 workspace
4594 .update_in(cx, |workspace, window, cx| {
4595 workspace.open_abs_path(
4596 PathBuf::from(path!("/root/file.txt")),
4597 OpenOptions::default(),
4598 window,
4599 cx,
4600 )
4601 })
4602 .await
4603 .unwrap()
4604 .downcast::<Editor>()
4605 .unwrap();
4606
4607 cx.update(|window, cx| {
4608 window.draw(cx).clear(cx);
4609 });
4610
4611 // mouse_wheel_zoom is disabled by default — zoom should not work.
4612 let initial_font_size =
4613 cx.update(|_, cx| ThemeSettings::get_global(cx).buffer_font_size(cx).as_f32());
4614
4615 cx.simulate_event(gpui::ScrollWheelEvent {
4616 position: mouse_position,
4617 delta: gpui::ScrollDelta::Pixels(point(px(0.), px(1.))),
4618 modifiers: event_modifiers,
4619 ..Default::default()
4620 });
4621
4622 let font_size_after_disabled_zoom =
4623 cx.update(|_, cx| ThemeSettings::get_global(cx).buffer_font_size(cx).as_f32());
4624
4625 assert_eq!(
4626 initial_font_size, font_size_after_disabled_zoom,
4627 "Editor buffer font-size should not change when mouse_wheel_zoom is disabled"
4628 );
4629
4630 // Enable mouse_wheel_zoom and verify zoom works.
4631 cx.update(|_, cx| {
4632 SettingsStore::update_global(cx, |store, cx| {
4633 store.update_user_settings(cx, |settings| {
4634 settings.editor.mouse_wheel_zoom = Some(true);
4635 });
4636 });
4637 });
4638
4639 cx.update(|window, cx| {
4640 window.draw(cx).clear(cx);
4641 });
4642
4643 cx.simulate_event(gpui::ScrollWheelEvent {
4644 position: mouse_position,
4645 delta: gpui::ScrollDelta::Pixels(point(px(0.), px(1.))),
4646 modifiers: event_modifiers,
4647 ..Default::default()
4648 });
4649
4650 let increased_font_size =
4651 cx.update(|_, cx| ThemeSettings::get_global(cx).buffer_font_size(cx).as_f32());
4652
4653 assert!(
4654 increased_font_size > initial_font_size,
4655 "Editor buffer font-size should have increased from scroll-zoom"
4656 );
4657
4658 cx.update(|window, cx| {
4659 window.draw(cx).clear(cx);
4660 });
4661
4662 cx.simulate_event(gpui::ScrollWheelEvent {
4663 position: mouse_position,
4664 delta: gpui::ScrollDelta::Pixels(point(px(0.), px(-1.))),
4665 modifiers: event_modifiers,
4666 ..Default::default()
4667 });
4668
4669 let decreased_font_size =
4670 cx.update(|_, cx| ThemeSettings::get_global(cx).buffer_font_size(cx).as_f32());
4671
4672 assert!(
4673 decreased_font_size < increased_font_size,
4674 "Editor buffer font-size should have decreased from scroll-zoom"
4675 );
4676
4677 // Disable mouse_wheel_zoom again and verify zoom stops working.
4678 cx.update(|_, cx| {
4679 SettingsStore::update_global(cx, |store, cx| {
4680 store.update_user_settings(cx, |settings| {
4681 settings.editor.mouse_wheel_zoom = Some(false);
4682 });
4683 });
4684 });
4685
4686 let font_size_before =
4687 cx.update(|_, cx| ThemeSettings::get_global(cx).buffer_font_size(cx).as_f32());
4688
4689 cx.update(|window, cx| {
4690 window.draw(cx).clear(cx);
4691 });
4692
4693 cx.simulate_event(gpui::ScrollWheelEvent {
4694 position: mouse_position,
4695 delta: gpui::ScrollDelta::Pixels(point(px(0.), px(1.))),
4696 modifiers: event_modifiers,
4697 ..Default::default()
4698 });
4699
4700 let font_size_after =
4701 cx.update(|_, cx| ThemeSettings::get_global(cx).buffer_font_size(cx).as_f32());
4702
4703 assert_eq!(
4704 font_size_before, font_size_after,
4705 "Editor buffer font-size should not change when mouse_wheel_zoom is re-disabled"
4706 );
4707 }
4708
4709 #[gpui::test]
4710 async fn test_navigation(cx: &mut TestAppContext) {
4711 let app_state = init_test(cx);
4712 app_state
4713 .fs
4714 .as_fake()
4715 .insert_tree(
4716 path!("/root"),
4717 json!({
4718 "a": {
4719 "file1": "contents 1\n".repeat(20),
4720 "file2": "contents 2\n".repeat(20),
4721 "file3": "contents 3\n".repeat(20),
4722 },
4723 }),
4724 )
4725 .await;
4726
4727 let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
4728 project.update(cx, |project, _cx| project.languages().add(markdown_lang()));
4729 let window =
4730 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
4731 let workspace = window
4732 .read_with(cx, |mw, _| mw.workspace().clone())
4733 .unwrap();
4734 let cx = &mut VisualTestContext::from_window(*window, cx);
4735 let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone());
4736
4737 let entries = cx.read(|cx| workspace.file_project_paths(cx));
4738 let file1 = entries[0].clone();
4739 let file2 = entries[1].clone();
4740 let file3 = entries[2].clone();
4741
4742 let editor1 = workspace
4743 .update_in(cx, |w, window, cx| {
4744 w.open_path(file1.clone(), None, true, window, cx)
4745 })
4746 .await
4747 .unwrap()
4748 .downcast::<Editor>()
4749 .unwrap();
4750 workspace.update_in(cx, |_, window, cx| {
4751 editor1.update(cx, |editor, cx| {
4752 editor.change_selections(Default::default(), window, cx, |s| {
4753 s.select_display_ranges([
4754 DisplayPoint::new(DisplayRow(10), 0)..DisplayPoint::new(DisplayRow(10), 0)
4755 ])
4756 });
4757 });
4758 });
4759
4760 let editor2 = workspace
4761 .update_in(cx, |w, window, cx| {
4762 w.open_path(file2.clone(), None, true, window, cx)
4763 })
4764 .await
4765 .unwrap()
4766 .downcast::<Editor>()
4767 .unwrap();
4768 let editor3 = workspace
4769 .update_in(cx, |w, window, cx| {
4770 w.open_path(file3.clone(), None, true, window, cx)
4771 })
4772 .await
4773 .unwrap()
4774 .downcast::<Editor>()
4775 .unwrap();
4776
4777 workspace
4778 .update_in(cx, |_, window, cx| {
4779 editor3.update(cx, |editor, cx| {
4780 editor.change_selections(Default::default(), window, cx, |s| {
4781 s.select_display_ranges([DisplayPoint::new(DisplayRow(12), 0)
4782 ..DisplayPoint::new(DisplayRow(12), 0)])
4783 });
4784 editor.newline(&Default::default(), window, cx);
4785 editor.newline(&Default::default(), window, cx);
4786 editor.move_down(&Default::default(), window, cx);
4787 editor.move_down(&Default::default(), window, cx);
4788 editor.save(
4789 SaveOptions {
4790 format: true,
4791 force_format: false,
4792 autosave: false,
4793 },
4794 project.clone(),
4795 window,
4796 cx,
4797 )
4798 })
4799 })
4800 .await
4801 .unwrap();
4802 workspace.update_in(cx, |_, window, cx| {
4803 editor3.update(cx, |editor, cx| {
4804 editor.set_scroll_position(point(0., 12.5), window, cx)
4805 });
4806 });
4807 assert_eq!(
4808 active_location(&workspace, cx),
4809 (file3.clone(), DisplayPoint::new(DisplayRow(16), 0), 12.5)
4810 );
4811
4812 workspace
4813 .update_in(cx, |w, window, cx| {
4814 w.go_back(w.active_pane().downgrade(), window, cx)
4815 })
4816 .await
4817 .unwrap();
4818 assert_eq!(
4819 active_location(&workspace, cx),
4820 (file3.clone(), DisplayPoint::new(DisplayRow(0), 0), 0.)
4821 );
4822
4823 workspace
4824 .update_in(cx, |w, window, cx| {
4825 w.go_back(w.active_pane().downgrade(), window, cx)
4826 })
4827 .await
4828 .unwrap();
4829 assert_eq!(
4830 active_location(&workspace, cx),
4831 (file2.clone(), DisplayPoint::new(DisplayRow(0), 0), 0.)
4832 );
4833
4834 workspace
4835 .update_in(cx, |w, window, cx| {
4836 w.go_back(w.active_pane().downgrade(), window, cx)
4837 })
4838 .await
4839 .unwrap();
4840 assert_eq!(
4841 active_location(&workspace, cx),
4842 (file1.clone(), DisplayPoint::new(DisplayRow(10), 0), 0.)
4843 );
4844
4845 workspace
4846 .update_in(cx, |w, window, cx| {
4847 w.go_back(w.active_pane().downgrade(), window, cx)
4848 })
4849 .await
4850 .unwrap();
4851 assert_eq!(
4852 active_location(&workspace, cx),
4853 (file1.clone(), DisplayPoint::new(DisplayRow(0), 0), 0.)
4854 );
4855
4856 // Go back one more time and ensure we don't navigate past the first item in the history.
4857 workspace
4858 .update_in(cx, |w, window, cx| {
4859 w.go_back(w.active_pane().downgrade(), window, cx)
4860 })
4861 .await
4862 .unwrap();
4863 assert_eq!(
4864 active_location(&workspace, cx),
4865 (file1.clone(), DisplayPoint::new(DisplayRow(0), 0), 0.)
4866 );
4867
4868 workspace
4869 .update_in(cx, |w, window, cx| {
4870 w.go_forward(w.active_pane().downgrade(), window, cx)
4871 })
4872 .await
4873 .unwrap();
4874 assert_eq!(
4875 active_location(&workspace, cx),
4876 (file1.clone(), DisplayPoint::new(DisplayRow(10), 0), 0.)
4877 );
4878
4879 workspace
4880 .update_in(cx, |w, window, cx| {
4881 w.go_forward(w.active_pane().downgrade(), window, cx)
4882 })
4883 .await
4884 .unwrap();
4885 assert_eq!(
4886 active_location(&workspace, cx),
4887 (file2.clone(), DisplayPoint::new(DisplayRow(0), 0), 0.)
4888 );
4889
4890 // Go forward to an item that has been closed, ensuring it gets re-opened at the same
4891 // location.
4892 workspace
4893 .update_in(cx, |_, window, cx| {
4894 pane.update(cx, |pane, cx| {
4895 let editor3_id = editor3.entity_id();
4896 drop(editor3);
4897 pane.close_item_by_id(editor3_id, SaveIntent::Close, window, cx)
4898 })
4899 })
4900 .await
4901 .unwrap();
4902 workspace
4903 .update_in(cx, |w, window, cx| {
4904 w.go_forward(w.active_pane().downgrade(), window, cx)
4905 })
4906 .await
4907 .unwrap();
4908 assert_eq!(
4909 active_location(&workspace, cx),
4910 (file3.clone(), DisplayPoint::new(DisplayRow(0), 0), 0.)
4911 );
4912
4913 workspace
4914 .update_in(cx, |w, window, cx| {
4915 w.go_forward(w.active_pane().downgrade(), window, cx)
4916 })
4917 .await
4918 .unwrap();
4919 assert_eq!(
4920 active_location(&workspace, cx),
4921 (file3.clone(), DisplayPoint::new(DisplayRow(16), 0), 12.5)
4922 );
4923
4924 workspace
4925 .update_in(cx, |w, window, cx| {
4926 w.go_back(w.active_pane().downgrade(), window, cx)
4927 })
4928 .await
4929 .unwrap();
4930 assert_eq!(
4931 active_location(&workspace, cx),
4932 (file3.clone(), DisplayPoint::new(DisplayRow(0), 0), 0.)
4933 );
4934
4935 // Go back to an item that has been closed and removed from disk
4936 workspace
4937 .update_in(cx, |_, window, cx| {
4938 pane.update(cx, |pane, cx| {
4939 let editor2_id = editor2.entity_id();
4940 drop(editor2);
4941 pane.close_item_by_id(editor2_id, SaveIntent::Close, window, cx)
4942 })
4943 })
4944 .await
4945 .unwrap();
4946 app_state
4947 .fs
4948 .remove_file(Path::new(path!("/root/a/file2")), Default::default())
4949 .await
4950 .unwrap();
4951 cx.background_executor.run_until_parked();
4952
4953 workspace
4954 .update_in(cx, |w, window, cx| {
4955 w.go_back(w.active_pane().downgrade(), window, cx)
4956 })
4957 .await
4958 .unwrap();
4959 assert_eq!(
4960 active_location(&workspace, cx),
4961 (file2.clone(), DisplayPoint::new(DisplayRow(0), 0), 0.)
4962 );
4963 workspace
4964 .update_in(cx, |w, window, cx| {
4965 w.go_forward(w.active_pane().downgrade(), window, cx)
4966 })
4967 .await
4968 .unwrap();
4969 assert_eq!(
4970 active_location(&workspace, cx),
4971 (file3.clone(), DisplayPoint::new(DisplayRow(0), 0), 0.)
4972 );
4973
4974 // Modify file to collapse multiple nav history entries into the same location.
4975 // Ensure we don't visit the same location twice when navigating.
4976 workspace.update_in(cx, |_, window, cx| {
4977 editor1.update(cx, |editor, cx| {
4978 editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
4979 s.select_display_ranges([
4980 DisplayPoint::new(DisplayRow(15), 0)..DisplayPoint::new(DisplayRow(15), 0)
4981 ])
4982 })
4983 });
4984 });
4985 for _ in 0..5 {
4986 workspace.update_in(cx, |_, window, cx| {
4987 editor1.update(cx, |editor, cx| {
4988 editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
4989 s.select_display_ranges([DisplayPoint::new(DisplayRow(3), 0)
4990 ..DisplayPoint::new(DisplayRow(3), 0)])
4991 });
4992 });
4993 });
4994
4995 workspace.update_in(cx, |_, window, cx| {
4996 editor1.update(cx, |editor, cx| {
4997 editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
4998 s.select_display_ranges([DisplayPoint::new(DisplayRow(13), 0)
4999 ..DisplayPoint::new(DisplayRow(13), 0)])
5000 });
5001 });
5002 });
5003 }
5004 workspace.update_in(cx, |_, window, cx| {
5005 editor1.update(cx, |editor, cx| {
5006 editor.transact(window, cx, |editor, window, cx| {
5007 editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
5008 s.select_display_ranges([DisplayPoint::new(DisplayRow(2), 0)
5009 ..DisplayPoint::new(DisplayRow(14), 0)])
5010 });
5011 editor.insert("", window, cx);
5012 })
5013 });
5014 });
5015
5016 workspace.update_in(cx, |_, window, cx| {
5017 editor1.update(cx, |editor, cx| {
5018 editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
5019 s.select_display_ranges([
5020 DisplayPoint::new(DisplayRow(1), 0)..DisplayPoint::new(DisplayRow(1), 0)
5021 ])
5022 })
5023 });
5024 });
5025 workspace
5026 .update_in(cx, |w, window, cx| {
5027 w.go_back(w.active_pane().downgrade(), window, cx)
5028 })
5029 .await
5030 .unwrap();
5031 assert_eq!(
5032 active_location(&workspace, cx),
5033 (file1.clone(), DisplayPoint::new(DisplayRow(2), 0), 0.)
5034 );
5035 workspace
5036 .update_in(cx, |w, window, cx| {
5037 w.go_back(w.active_pane().downgrade(), window, cx)
5038 })
5039 .await
5040 .unwrap();
5041 assert_eq!(
5042 active_location(&workspace, cx),
5043 (file1.clone(), DisplayPoint::new(DisplayRow(3), 0), 0.)
5044 );
5045
5046 fn active_location(
5047 workspace: &Entity<Workspace>,
5048 cx: &mut VisualTestContext,
5049 ) -> (ProjectPath, DisplayPoint, f64) {
5050 workspace.update(cx, |workspace, cx| {
5051 let item = workspace.active_item(cx).unwrap();
5052 let editor = item.downcast::<Editor>().unwrap();
5053
5054 editor.update(cx, |editor_ref, cx| {
5055 let selections = editor_ref
5056 .selections
5057 .display_ranges(&editor_ref.display_snapshot(cx));
5058 let scroll_position = editor_ref.scroll_position(cx);
5059
5060 (
5061 editor_ref.active_project_path(cx).unwrap(),
5062 selections[0].start,
5063 scroll_position.y,
5064 )
5065 })
5066 })
5067 }
5068 }
5069
5070 #[gpui::test]
5071 async fn test_reopening_closed_items(cx: &mut TestAppContext) {
5072 let app_state = init_test(cx);
5073 app_state
5074 .fs
5075 .as_fake()
5076 .insert_tree(
5077 path!("/root"),
5078 json!({
5079 "a": {
5080 "file1": "",
5081 "file2": "",
5082 "file3": "",
5083 "file4": "",
5084 },
5085 }),
5086 )
5087 .await;
5088
5089 let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
5090 project.update(cx, |project, _cx| project.languages().add(markdown_lang()));
5091 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx));
5092 let workspace = window
5093 .read_with(cx, |mw, _| mw.workspace().clone())
5094 .unwrap();
5095 let cx = &mut VisualTestContext::from_window(*window, cx);
5096 let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone());
5097
5098 let entries = cx.read(|cx| workspace.file_project_paths(cx));
5099 let file1 = entries[0].clone();
5100 let file2 = entries[1].clone();
5101 let file3 = entries[2].clone();
5102 let file4 = entries[3].clone();
5103
5104 let file1_item_id = workspace
5105 .update_in(cx, |w, window, cx| {
5106 w.open_path(file1.clone(), None, true, window, cx)
5107 })
5108 .await
5109 .unwrap()
5110 .item_id();
5111 let file2_item_id = workspace
5112 .update_in(cx, |w, window, cx| {
5113 w.open_path(file2.clone(), None, true, window, cx)
5114 })
5115 .await
5116 .unwrap()
5117 .item_id();
5118 let file3_item_id = workspace
5119 .update_in(cx, |w, window, cx| {
5120 w.open_path(file3.clone(), None, true, window, cx)
5121 })
5122 .await
5123 .unwrap()
5124 .item_id();
5125 let file4_item_id = workspace
5126 .update_in(cx, |w, window, cx| {
5127 w.open_path(file4.clone(), None, true, window, cx)
5128 })
5129 .await
5130 .unwrap()
5131 .item_id();
5132 assert_eq!(active_path(&workspace, cx), Some(file4.clone()));
5133
5134 // Close all the pane items in some arbitrary order.
5135 workspace
5136 .update_in(cx, |_, window, cx| {
5137 pane.update(cx, |pane, cx| {
5138 pane.close_item_by_id(file1_item_id, SaveIntent::Close, window, cx)
5139 })
5140 })
5141 .await
5142 .unwrap();
5143 assert_eq!(active_path(&workspace, cx), Some(file4.clone()));
5144
5145 workspace
5146 .update_in(cx, |_, window, cx| {
5147 pane.update(cx, |pane, cx| {
5148 pane.close_item_by_id(file4_item_id, SaveIntent::Close, window, cx)
5149 })
5150 })
5151 .await
5152 .unwrap();
5153 assert_eq!(active_path(&workspace, cx), Some(file3.clone()));
5154
5155 workspace
5156 .update_in(cx, |_, window, cx| {
5157 pane.update(cx, |pane, cx| {
5158 pane.close_item_by_id(file2_item_id, SaveIntent::Close, window, cx)
5159 })
5160 })
5161 .await
5162 .unwrap();
5163 assert_eq!(active_path(&workspace, cx), Some(file3.clone()));
5164 workspace
5165 .update_in(cx, |_, window, cx| {
5166 pane.update(cx, |pane, cx| {
5167 pane.close_item_by_id(file3_item_id, SaveIntent::Close, window, cx)
5168 })
5169 })
5170 .await
5171 .unwrap();
5172
5173 assert_eq!(active_path(&workspace, cx), None);
5174
5175 // Reopen all the closed items, ensuring they are reopened in the same order
5176 // in which they were closed.
5177 workspace
5178 .update_in(cx, Workspace::reopen_closed_item)
5179 .await
5180 .unwrap();
5181 assert_eq!(active_path(&workspace, cx), Some(file3.clone()));
5182
5183 workspace
5184 .update_in(cx, Workspace::reopen_closed_item)
5185 .await
5186 .unwrap();
5187 assert_eq!(active_path(&workspace, cx), Some(file2.clone()));
5188
5189 workspace
5190 .update_in(cx, Workspace::reopen_closed_item)
5191 .await
5192 .unwrap();
5193 assert_eq!(active_path(&workspace, cx), Some(file4.clone()));
5194
5195 workspace
5196 .update_in(cx, Workspace::reopen_closed_item)
5197 .await
5198 .unwrap();
5199 assert_eq!(active_path(&workspace, cx), Some(file1.clone()));
5200
5201 // Reopening past the last closed item is a no-op.
5202 workspace
5203 .update_in(cx, Workspace::reopen_closed_item)
5204 .await
5205 .unwrap();
5206 assert_eq!(active_path(&workspace, cx), Some(file1.clone()));
5207
5208 // Reopening closed items doesn't interfere with navigation history.
5209 // Verify we can navigate back through the history after reopening items.
5210 workspace
5211 .update_in(cx, |workspace, window, cx| {
5212 workspace.go_back(workspace.active_pane().downgrade(), window, cx)
5213 })
5214 .await
5215 .unwrap();
5216
5217 // After go_back, we should be at a different file than file1
5218 let after_go_back = active_path(&workspace, cx);
5219 assert!(
5220 after_go_back.is_some() && after_go_back != Some(file1.clone()),
5221 "After go_back from file1, should be at a different file"
5222 );
5223
5224 pane.read_with(cx, |pane, _| {
5225 assert!(pane.can_navigate_forward(), "Should be able to go forward");
5226 });
5227
5228 fn active_path(
5229 workspace: &Entity<Workspace>,
5230 cx: &VisualTestContext,
5231 ) -> Option<ProjectPath> {
5232 workspace.read_with(cx, |workspace, cx| {
5233 let item = workspace.active_item(cx)?;
5234 item.project_path(cx)
5235 })
5236 }
5237 }
5238
5239 fn init_keymap_test(cx: &mut TestAppContext) -> Arc<AppState> {
5240 cx.update(|cx| {
5241 let app_state = AppState::test(cx);
5242
5243 theme_settings::init(theme::LoadThemes::JustBase, cx);
5244 client::init(&app_state.client, cx);
5245 workspace::init(app_state.clone(), cx);
5246 onboarding::init(cx);
5247 app_state
5248 })
5249 }
5250
5251 actions!(test_only, [ActionA, ActionB]);
5252
5253 /// The actions the emacs keymap resolves for `keystroke` in `context`.
5254 fn emacs_bindings_for(keystroke: &str, context: &str, cx: &mut TestAppContext) -> Vec<String> {
5255 cx.update(|cx| {
5256 let mut bindings = settings::KeymapFile::load_asset_allow_partial_failure(
5257 "keymaps/default-linux.json",
5258 cx,
5259 )
5260 .unwrap();
5261 for binding in &mut bindings {
5262 binding.set_meta(settings::KeybindSource::Default.meta());
5263 }
5264 let mut emacs_bindings = settings::KeymapFile::load_asset_allow_partial_failure(
5265 "keymaps/linux/emacs.json",
5266 cx,
5267 )
5268 .unwrap();
5269 for binding in &mut emacs_bindings {
5270 binding.set_meta(settings::KeybindSource::Base.meta());
5271 }
5272 bindings.extend(emacs_bindings);
5273
5274 gpui::Keymap::new(bindings)
5275 .bindings_for_input(
5276 &[gpui::Keystroke::parse(keystroke).unwrap()],
5277 &[gpui::KeyContext::parse(context).unwrap()],
5278 )
5279 .0
5280 .iter()
5281 .map(|binding| binding.action().name().to_string())
5282 .collect()
5283 })
5284 }
5285
5286 /// `editor::MoveDown` and `editor::MoveUp` propagate when the cursor doesn't move, which at the
5287 /// ends of a buffer let `ctrl-n` and `ctrl-p` fall through to the default bindings and open a
5288 /// new file / the file finder.
5289 #[gpui::test]
5290 fn test_emacs_cursor_keys_do_not_fall_back_to_default_bindings(cx: &mut TestAppContext) {
5291 init_keymap_test(cx);
5292
5293 let ctrl_n = emacs_bindings_for("ctrl-n", "Workspace Editor", cx);
5294 assert!(
5295 ctrl_n.contains(&"editor::MoveDown".to_string()),
5296 "ctrl-n should still move down, got {ctrl_n:?}"
5297 );
5298 assert!(
5299 !ctrl_n.contains(&"workspace::NewFile".to_string()),
5300 "ctrl-n should not fall through to workspace::NewFile, got {ctrl_n:?}"
5301 );
5302
5303 let ctrl_p = emacs_bindings_for("ctrl-p", "Workspace Editor", cx);
5304 assert!(
5305 ctrl_p.contains(&"editor::MoveUp".to_string()),
5306 "ctrl-p should still move up, got {ctrl_p:?}"
5307 );
5308 assert!(
5309 !ctrl_p.contains(&"file_finder::Toggle".to_string()),
5310 "ctrl-p should not fall through to file_finder::Toggle, got {ctrl_p:?}"
5311 );
5312 }
5313
5314 /// The unbind above only targets `workspace::NewFile` / `file_finder::Toggle`, so the narrower
5315 /// `ctrl-n` and `ctrl-p` bindings still win where they apply.
5316 #[gpui::test]
5317 fn test_emacs_cursor_keys_keep_narrower_bindings(cx: &mut TestAppContext) {
5318 init_keymap_test(cx);
5319
5320 let completions = "Workspace Editor showing_completions";
5321 assert_eq!(
5322 emacs_bindings_for("ctrl-n", completions, cx).first(),
5323 Some(&"editor::ContextMenuNext".to_string())
5324 );
5325 assert_eq!(
5326 emacs_bindings_for("ctrl-p", completions, cx).first(),
5327 Some(&"editor::ContextMenuPrevious".to_string())
5328 );
5329
5330 let selection_mode = "Workspace Editor selection_mode";
5331 assert_eq!(
5332 emacs_bindings_for("ctrl-n", selection_mode, cx).first(),
5333 Some(&"editor::SelectDown".to_string())
5334 );
5335 assert_eq!(
5336 emacs_bindings_for("ctrl-p", selection_mode, cx).first(),
5337 Some(&"editor::SelectUp".to_string())
5338 );
5339 }
5340
5341 #[gpui::test]
5342 async fn test_base_keymap(cx: &mut gpui::TestAppContext) {
5343 let executor = cx.executor();
5344 let app_state = init_keymap_test(cx);
5345 let project = Project::test(app_state.fs.clone(), [], cx).await;
5346 let window =
5347 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5348 let workspace = window
5349 .read_with(cx, |mw, _| mw.workspace().clone())
5350 .unwrap();
5351
5352 // From the Atom keymap
5353 use workspace::ActivatePreviousPane;
5354 // From the JetBrains keymap
5355 use workspace::ActivatePreviousItem;
5356 // From the VSCode keymap
5357 use debugger_ui::Start;
5358
5359 app_state
5360 .fs
5361 .save(
5362 paths::settings_file(),
5363 &r#"{"base_keymap": "Atom"}"#.into(),
5364 Default::default(),
5365 )
5366 .await
5367 .unwrap();
5368
5369 app_state
5370 .fs
5371 .save(
5372 "/keymap.json".as_ref(),
5373 &r#"[{"bindings": {"backspace": "test_only::ActionA"}}]"#.into(),
5374 Default::default(),
5375 )
5376 .await
5377 .unwrap();
5378 executor.run_until_parked();
5379 cx.update(|cx| {
5380 let (keymap_rx, keymap_watcher) = watch_config_file(
5381 &executor,
5382 app_state.fs.clone(),
5383 PathBuf::from("/keymap.json"),
5384 );
5385 watch_settings_files(app_state.fs.clone(), cx);
5386 handle_keymap_file_changes(keymap_rx, keymap_watcher, cx);
5387 });
5388 window
5389 .update(cx, |_, _, cx| {
5390 workspace.update(cx, |workspace, cx| {
5391 workspace.register_action(|_, _: &ActionA, _window, _cx| {});
5392 workspace.register_action(|_, _: &ActionB, _window, _cx| {});
5393 workspace.register_action(|_, _: &ActivatePreviousPane, _window, _cx| {});
5394 workspace.register_action(|_, _: &ActivatePreviousItem, _window, _cx| {});
5395 cx.notify();
5396 });
5397 })
5398 .unwrap();
5399 executor.run_until_parked();
5400 // Test loading the keymap base at all
5401 assert_key_bindings_for(
5402 window.into(),
5403 cx,
5404 vec![("backspace", &ActionA), ("k", &ActivatePreviousPane)],
5405 line!(),
5406 );
5407
5408 // Test modifying the users keymap, while retaining the base keymap
5409 app_state
5410 .fs
5411 .save(
5412 "/keymap.json".as_ref(),
5413 &r#"[{"bindings": {"backspace": "test_only::ActionB"}}]"#.into(),
5414 Default::default(),
5415 )
5416 .await
5417 .unwrap();
5418
5419 executor.run_until_parked();
5420
5421 assert_key_bindings_for(
5422 window.into(),
5423 cx,
5424 vec![("backspace", &ActionB), ("k", &ActivatePreviousPane)],
5425 line!(),
5426 );
5427
5428 // Test modifying the base, while retaining the users keymap
5429 app_state
5430 .fs
5431 .save(
5432 paths::settings_file(),
5433 &r#"{"base_keymap": "JetBrains"}"#.into(),
5434 Default::default(),
5435 )
5436 .await
5437 .unwrap();
5438
5439 executor.run_until_parked();
5440
5441 assert_key_bindings_for(
5442 window.into(),
5443 cx,
5444 vec![
5445 ("backspace", &ActionB),
5446 ("{", &ActivatePreviousItem::default()),
5447 ],
5448 line!(),
5449 );
5450
5451 // Test the VSCode keymap overlay
5452 app_state
5453 .fs
5454 .save(
5455 paths::settings_file(),
5456 &r#"{"base_keymap": "VSCode"}"#.into(),
5457 Default::default(),
5458 )
5459 .await
5460 .unwrap();
5461
5462 executor.run_until_parked();
5463
5464 window
5465 .update(cx, |_, _, cx| {
5466 workspace.update(cx, |workspace, cx| {
5467 workspace.register_action(|_, _: &Start, _window, _cx| {});
5468 cx.notify();
5469 });
5470 })
5471 .unwrap();
5472 executor.run_until_parked();
5473
5474 assert_key_bindings_for(
5475 window.into(),
5476 cx,
5477 vec![("backspace", &ActionB), ("f5", &Start)],
5478 line!(),
5479 );
5480 }
5481
5482 #[gpui::test]
5483 async fn test_disabled_keymap_binding(cx: &mut gpui::TestAppContext) {
5484 let executor = cx.executor();
5485 let app_state = init_keymap_test(cx);
5486 let project = Project::test(app_state.fs.clone(), [], cx).await;
5487 let window =
5488 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
5489 let workspace = window
5490 .read_with(cx, |mw, _| mw.workspace().clone())
5491 .unwrap();
5492
5493 // From the Atom keymap
5494 use workspace::ActivatePreviousPane;
5495 // From the JetBrains keymap
5496 use diagnostics::Deploy;
5497
5498 window
5499 .update(cx, |_, _, cx| {
5500 workspace.update(cx, |workspace, cx| {
5501 workspace.register_action(|_, _: &ActionA, _window, _cx| {});
5502 workspace.register_action(|_, _: &ActionB, _window, _cx| {});
5503 workspace.register_action(|_, _: &Deploy, _window, _cx| {});
5504 cx.notify();
5505 });
5506 })
5507 .unwrap();
5508 app_state
5509 .fs
5510 .save(
5511 paths::settings_file(),
5512 &r#"{"base_keymap": "Atom"}"#.into(),
5513 Default::default(),
5514 )
5515 .await
5516 .unwrap();
5517 app_state
5518 .fs
5519 .save(
5520 "/keymap.json".as_ref(),
5521 &r#"[{"bindings": {"backspace": "test_only::ActionA"}}]"#.into(),
5522 Default::default(),
5523 )
5524 .await
5525 .unwrap();
5526
5527 cx.update(|cx| {
5528 let (keymap_rx, keymap_watcher) = watch_config_file(
5529 &executor,
5530 app_state.fs.clone(),
5531 PathBuf::from("/keymap.json"),
5532 );
5533
5534 watch_settings_files(app_state.fs.clone(), cx);
5535 handle_keymap_file_changes(keymap_rx, keymap_watcher, cx);
5536 });
5537
5538 cx.background_executor.run_until_parked();
5539
5540 cx.background_executor.run_until_parked();
5541 // Test loading the keymap base at all
5542 assert_key_bindings_for(
5543 window.into(),
5544 cx,
5545 vec![("backspace", &ActionA), ("k", &ActivatePreviousPane)],
5546 line!(),
5547 );
5548
5549 // Test disabling the key binding for the base keymap
5550 app_state
5551 .fs
5552 .save(
5553 "/keymap.json".as_ref(),
5554 &r#"[{"bindings": {"backspace": null}}]"#.into(),
5555 Default::default(),
5556 )
5557 .await
5558 .unwrap();
5559
5560 cx.background_executor.run_until_parked();
5561
5562 assert_key_bindings_for(
5563 window.into(),
5564 cx,
5565 vec![("k", &ActivatePreviousPane)],
5566 line!(),
5567 );
5568
5569 // Test modifying the base, while retaining the users keymap
5570 app_state
5571 .fs
5572 .save(
5573 paths::settings_file(),
5574 &r#"{"base_keymap": "JetBrains"}"#.into(),
5575 Default::default(),
5576 )
5577 .await
5578 .unwrap();
5579
5580 cx.background_executor.run_until_parked();
5581
5582 assert_key_bindings_for(window.into(), cx, vec![("6", &Deploy)], line!());
5583 }
5584
5585 #[gpui::test]
5586 async fn test_generate_keymap_json_schema_for_registered_actions(
5587 cx: &mut gpui::TestAppContext,
5588 ) {
5589 init_keymap_test(cx);
5590 cx.update(|cx| {
5591 // Make sure it doesn't panic.
5592 KeymapFile::generate_json_schema_for_registered_actions(cx);
5593 });
5594 }
5595
5596 /// Checks that action namespaces are the expected set. The purpose of this is to prevent typos
5597 /// and let you know when introducing a new namespace.
5598 #[gpui::test]
5599 async fn test_action_namespaces(cx: &mut gpui::TestAppContext) {
5600 use itertools::Itertools;
5601
5602 init_keymap_test(cx);
5603 cx.update(|cx| {
5604 let all_actions = cx.all_action_names();
5605
5606 let mut actions_without_namespace = Vec::new();
5607 let all_namespaces = all_actions
5608 .iter()
5609 .filter_map(|action_name| {
5610 let namespace = action_name
5611 .split("::")
5612 .collect::<Vec<_>>()
5613 .into_iter()
5614 .rev()
5615 .skip(1)
5616 .rev()
5617 .join("::");
5618 if namespace.is_empty() {
5619 actions_without_namespace.push(*action_name);
5620 }
5621 if &namespace == "test_only" || &namespace == "stories" {
5622 None
5623 } else {
5624 Some(namespace)
5625 }
5626 })
5627 .sorted()
5628 .dedup()
5629 .collect::<Vec<_>>();
5630 assert_eq!(actions_without_namespace, Vec::<&str>::new());
5631
5632 let expected_namespaces = vec![
5633 "action",
5634 "activity_indicator",
5635 "agent",
5636 "agents_sidebar",
5637 "app_menu",
5638 "assistant",
5639 "assistant2",
5640 "auto_update",
5641 "branch_picker",
5642 "bedrock",
5643 "branches",
5644 "buffer_search",
5645 "channel_modal",
5646 "cli",
5647 "client",
5648 "collab",
5649 "collab_panel",
5650 "command_palette",
5651 "console",
5652 "context_server",
5653 "copilot",
5654 "csv",
5655 "debug_panel",
5656 "debugger",
5657 "dev",
5658 "diagnostics",
5659 "edit_prediction",
5660 "editor",
5661 "encoding_selector",
5662 "feedback",
5663 "file_finder",
5664 "git",
5665 "git_graph",
5666 "git_onboarding",
5667 "git_panel",
5668 "git_picker",
5669 "go_to_line",
5670 "highlights_tree_view",
5671 "icon_theme_selector",
5672 "image_viewer",
5673 "inline_assistant",
5674 "journal",
5675 "keymap_editor",
5676 "keystroke_input",
5677 "language_selector",
5678 "welcome",
5679 "line_ending_selector",
5680 "lsp_tool",
5681 "markdown",
5682 "menu",
5683 "multi_workspace",
5684 "new_process_modal",
5685 "notebook",
5686 "onboarding",
5687 "outline",
5688 "outline_panel",
5689 "pane",
5690 "panel",
5691 "picker",
5692 "project_panel",
5693 "project_search",
5694 "project_symbols",
5695 "projects",
5696 "recent_projects",
5697 "remote_debug",
5698 "repl",
5699 "search",
5700 "settings_editor",
5701 "settings_profile_selector",
5702 "skill_creator",
5703 "snippets",
5704 "stash_picker",
5705 "svg",
5706 "syntax_tree_view",
5707 "tab_switcher",
5708 "task",
5709 "terminal",
5710 "terminal_panel",
5711 "text_finder",
5712 "theme",
5713 "theme_selector",
5714 "toast",
5715 "toolchain",
5716 "variable_list",
5717 "vim",
5718 "window",
5719 "workspace",
5720 "worktree_picker",
5721 "zed",
5722 "zed_actions",
5723 "omega_predict_onboarding",
5724 "zeta",
5725 ];
5726 assert_eq!(
5727 all_namespaces,
5728 expected_namespaces
5729 .into_iter()
5730 .map(|namespace| namespace.to_string())
5731 .sorted()
5732 .collect::<Vec<_>>()
5733 );
5734 });
5735 }
5736
5737 #[gpui::test]
5738 fn test_bundled_settings_and_themes(cx: &mut App) {
5739 cx.text_system()
5740 .add_fonts(vec![
5741 Assets
5742 .load("fonts/lilex/Lilex-Regular.ttf")
5743 .unwrap()
5744 .unwrap(),
5745 Assets
5746 .load("fonts/ibm-plex-sans/IBMPlexSans-Regular.ttf")
5747 .unwrap()
5748 .unwrap(),
5749 ])
5750 .unwrap();
5751 let themes = ThemeRegistry::default();
5752 settings::init(cx);
5753 theme_settings::init(theme::LoadThemes::JustBase, cx);
5754
5755 let mut has_default_theme = false;
5756 for theme_name in themes.list().into_iter().map(|meta| meta.name) {
5757 let theme = themes.get(&theme_name).unwrap();
5758 assert_eq!(theme.name, theme_name);
5759 if theme.name.as_ref() == "Aiur" {
5760 has_default_theme = true;
5761 }
5762 }
5763 assert!(has_default_theme);
5764 }
5765
5766 #[gpui::test]
5767 async fn test_bundled_files_editor(cx: &mut TestAppContext) {
5768 let app_state = init_test(cx);
5769 cx.update(init);
5770
5771 let project = Project::test(app_state.fs.clone(), [], cx).await;
5772 let _window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx));
5773
5774 cx.update(|cx| {
5775 cx.dispatch_action(&OpenDefaultSettings);
5776 });
5777 cx.run_until_parked();
5778
5779 assert_eq!(cx.read(|cx| cx.windows().len()), 1);
5780
5781 let multi_workspace = cx.windows()[0].downcast::<MultiWorkspace>().unwrap();
5782 let active_editor = multi_workspace
5783 .update(cx, |multi_workspace, _, cx| {
5784 multi_workspace
5785 .workspace()
5786 .update(cx, |workspace, cx| workspace.active_item_as::<Editor>(cx))
5787 })
5788 .unwrap();
5789 assert!(
5790 active_editor.is_some(),
5791 "Settings action should have opened an editor with the default file contents"
5792 );
5793
5794 let active_editor = active_editor.unwrap();
5795 assert!(
5796 active_editor.read_with(cx, |editor, cx| editor.read_only(cx)),
5797 "Default settings should be readonly"
5798 );
5799 assert!(
5800 active_editor.read_with(cx, |editor, cx| editor.buffer().read(cx).read_only()),
5801 "The underlying buffer should also be readonly for the shipped default settings"
5802 );
5803 }
5804
5805 #[gpui::test]
5806 async fn test_bundled_files_reuse_existing_editor(cx: &mut TestAppContext) {
5807 let app_state = init_test(cx);
5808 cx.update(init);
5809
5810 let project = Project::test(app_state.fs.clone(), [], cx).await;
5811 let _window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx));
5812
5813 cx.update(|cx| {
5814 cx.dispatch_action(&OpenDefaultSettings);
5815 });
5816 cx.run_until_parked();
5817
5818 let multi_workspace = cx.windows()[0].downcast::<MultiWorkspace>().unwrap();
5819 let first_item_id = multi_workspace
5820 .update(cx, |multi_workspace, _, cx| {
5821 multi_workspace.workspace().update(cx, |workspace, cx| {
5822 workspace
5823 .active_item(cx)
5824 .expect("default settings should be open")
5825 .item_id()
5826 })
5827 })
5828 .unwrap();
5829
5830 cx.update(|cx| {
5831 cx.dispatch_action(&OpenDefaultSettings);
5832 });
5833 cx.run_until_parked();
5834
5835 let (second_item_id, item_count) = multi_workspace
5836 .update(cx, |multi_workspace, _, cx| {
5837 multi_workspace.workspace().update(cx, |workspace, cx| {
5838 let pane = workspace.active_pane().read(cx);
5839 (
5840 pane.active_item()
5841 .expect("default settings should still be open")
5842 .item_id(),
5843 pane.items_len(),
5844 )
5845 })
5846 })
5847 .unwrap();
5848
5849 assert_eq!(first_item_id, second_item_id);
5850 assert_eq!(item_count, 1);
5851 }
5852
5853 #[gpui::test]
5854 async fn test_bundled_languages(cx: &mut TestAppContext) {
5855 let fs = fs::FakeFs::new(cx.background_executor.clone());
5856 env_logger::builder().is_test(true).try_init().ok();
5857 let settings = cx.update(SettingsStore::test);
5858 cx.set_global(settings);
5859 let languages = LanguageRegistry::test(cx.executor());
5860 let languages = Arc::new(languages);
5861 let node_runtime = node_runtime::NodeRuntime::unavailable();
5862 cx.update(|cx| {
5863 languages::init(languages.clone(), fs, node_runtime, cx);
5864 });
5865 for name in languages.language_names() {
5866 languages
5867 .language_for_name(name.as_ref())
5868 .await
5869 .with_context(|| format!("language name {name}"))
5870 .unwrap();
5871 }
5872 cx.run_until_parked();
5873 }
5874
5875 pub(crate) fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
5876 init_test_with_state(cx, cx.update(AppState::test))
5877 }
5878
5879 fn init_test_with_state(
5880 cx: &mut TestAppContext,
5881 mut app_state: Arc<AppState>,
5882 ) -> Arc<AppState> {
5883 cx.update(move |cx| {
5884 env_logger::builder().is_test(true).try_init().ok();
5885
5886 let state = Arc::get_mut(&mut app_state).unwrap();
5887 state.build_window_options = build_window_options;
5888 app_state.languages.add(markdown_lang());
5889
5890 gpui_tokio::init(cx);
5891 AppState::set_global(app_state.clone(), cx);
5892 theme_settings::init(theme::LoadThemes::JustBase, cx);
5893 audio::init(cx);
5894 channel::init(&app_state.client, app_state.user_store.clone(), cx);
5895 call::init(app_state.client.clone(), app_state.user_store.clone(), cx);
5896 notifications::init(app_state.client.clone(), app_state.user_store.clone(), cx);
5897 workspace::init(app_state.clone(), cx);
5898 release_channel::init(Version::new(0, 0, 0), cx);
5899 command_palette::init(cx);
5900 editor::init(cx);
5901 git_ui::init(cx);
5902 project_panel::init(cx);
5903 outline_panel::init(cx);
5904 terminal_view::init(cx);
5905 copilot_chat::init(
5906 app_state.fs.clone(),
5907 app_state.client.http_client(),
5908 copilot_chat::CopilotChatConfiguration::default(),
5909 cx,
5910 );
5911 image_viewer::init(cx);
5912 language_model::init(cx);
5913 client::RefreshLlmTokenListener::register(
5914 app_state.client.clone(),
5915 app_state.user_store.clone(),
5916 cx,
5917 );
5918 language_models::init(app_state.user_store.clone(), app_state.client.clone(), cx);
5919 web_search::init(cx);
5920 web_search_providers::init(app_state.client.clone(), app_state.user_store.clone(), cx);
5921 let prompt_builder = PromptBuilder::load(app_state.fs.clone(), false, cx);
5922 project::AgentRegistryStore::init_global(
5923 cx,
5924 app_state.fs.clone(),
5925 app_state.client.http_client(),
5926 );
5927 agent_ui::init(
5928 app_state.fs.clone(),
5929 prompt_builder,
5930 app_state.languages.clone(),
5931 true,
5932 false,
5933 cx,
5934 );
5935
5936 repl::init(app_state.fs.clone(), cx);
5937 repl::notebook::init(cx);
5938 tasks_ui::init(cx);
5939 project::debugger::breakpoint_store::BreakpointStore::init(
5940 &app_state.client.clone().into(),
5941 );
5942 project::debugger::dap_store::DapStore::init(&app_state.client.clone().into(), cx);
5943 debugger_ui::init(cx);
5944 omega_effectd::init_openagents_session(cx);
5945 omega_effectd::init_openagents_binding(cx);
5946 omega_effectd::init_with_host_handler(
5947 Some(agent_ui::omega_effectd_host_handler(cx)),
5948 cx,
5949 );
5950 agent_computer_ui::init(cx);
5951 workroom_ui::init(cx);
5952 initialize_workspace(app_state.clone(), cx);
5953 search::init(cx);
5954 lsp_locations::init(cx);
5955 cx.set_global(workspace::PaneSearchBarCallbacks {
5956 setup_search_bar: |languages, toolbar, window, cx| {
5957 let search_bar =
5958 cx.new(|cx| search::BufferSearchBar::new(languages, window, cx));
5959 toolbar.update(cx, |toolbar, cx| {
5960 toolbar.add_item(search_bar, window, cx);
5961 });
5962 },
5963 wrap_div_with_search_actions: search::buffer_search::register_pane_search_actions,
5964 });
5965 app_state
5966 })
5967 }
5968
5969 #[track_caller]
5970 fn assert_key_bindings_for(
5971 window: AnyWindowHandle,
5972 cx: &TestAppContext,
5973 actions: Vec<(&'static str, &dyn Action)>,
5974 line: u32,
5975 ) {
5976 let available_actions = cx
5977 .update(|cx| window.update(cx, |_, window, cx| window.available_actions(cx)))
5978 .unwrap();
5979 for (key, action) in actions {
5980 let bindings = cx
5981 .update(|cx| window.update(cx, |_, window, _| window.bindings_for_action(action)))
5982 .unwrap();
5983 // assert that...
5984 assert!(
5985 available_actions.iter().any(|bound_action| {
5986 // actions match...
5987 bound_action.partial_eq(action)
5988 }),
5989 "On {} Failed to find {}",
5990 line,
5991 action.name(),
5992 );
5993 assert!(
5994 // and key strokes contain the given key
5995 bindings
5996 .into_iter()
5997 .any(|binding| binding.keystrokes().iter().any(|k| k.key() == key)),
5998 "On {} Failed to find {} with key binding {}",
5999 line,
6000 action.name(),
6001 key
6002 );
6003 }
6004 }
6005
6006 #[gpui::test]
6007 async fn test_opening_project_settings_when_excluded(cx: &mut gpui::TestAppContext) {
6008 // Use the proper initialization for runtime state
6009 let app_state = init_keymap_test(cx);
6010
6011 eprintln!("Running test_opening_project_settings_when_excluded");
6012
6013 // 1. Set up a project with some project settings
6014 let settings_init =
6015 r#"{ "UNIQUEVALUE": true, "git": { "inline_blame": { "enabled": false } } }"#;
6016 app_state
6017 .fs
6018 .as_fake()
6019 .insert_tree(
6020 Path::new("/root"),
6021 json!({
6022 ".zed": {
6023 "settings.json": settings_init
6024 }
6025 }),
6026 )
6027 .await;
6028
6029 eprintln!("Created project with .zed/settings.json containing UNIQUEVALUE");
6030
6031 // 2. Create a project with the file system and load it
6032 let project = Project::test(app_state.fs.clone(), [Path::new("/root")], cx).await;
6033
6034 // Save original settings content for comparison
6035 let original_settings = app_state
6036 .fs
6037 .load(Path::new("/root/.zed/settings.json"))
6038 .await
6039 .unwrap();
6040
6041 let original_settings_str = original_settings.clone();
6042
6043 // Verify settings exist on disk and have expected content
6044 eprintln!("Original settings content: {}", original_settings_str);
6045 assert!(
6046 original_settings_str.contains("UNIQUEVALUE"),
6047 "Test setup failed - settings file doesn't contain our marker"
6048 );
6049
6050 // 3. Add .zed to file scan exclusions in user settings
6051 cx.update_global::<SettingsStore, _>(|store, cx| {
6052 store.update_user_settings(cx, |worktree_settings| {
6053 worktree_settings.project.worktree.file_scan_exclusions =
6054 Some(vec![".zed".to_string()]);
6055 });
6056 });
6057
6058 eprintln!("Added .zed to file_scan_exclusions in settings");
6059
6060 // 4. Run tasks to apply settings
6061 cx.background_executor.run_until_parked();
6062
6063 // 5. Critical: Verify .zed is actually excluded from worktree
6064 let worktree = cx.update(|cx| project.read(cx).worktrees(cx).next().unwrap());
6065
6066 let has_zed_entry =
6067 cx.update(|cx| worktree.read(cx).entry_for_path(rel_path(".zed")).is_some());
6068
6069 eprintln!(
6070 "Is .zed directory visible in worktree after exclusion: {}",
6071 has_zed_entry
6072 );
6073
6074 // This assertion verifies the test is set up correctly to show the bug
6075 // If .zed is not excluded, the test will fail here
6076 assert!(
6077 !has_zed_entry,
6078 "Test precondition failed: .zed directory should be excluded but was found in worktree"
6079 );
6080
6081 // 6. Create workspace and trigger the actual function that causes the bug
6082 let window =
6083 cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
6084 let workspace = window
6085 .read_with(cx, |mw, _| mw.workspace().clone())
6086 .unwrap();
6087 window
6088 .update(cx, |_, window, cx| {
6089 workspace.update(cx, |workspace, cx| {
6090 // Call the exact function that contains the bug
6091 eprintln!("About to call open_project_settings_file");
6092 open_project_settings_file(workspace, &OpenProjectSettingsFile, window, cx);
6093 });
6094 })
6095 .unwrap();
6096
6097 // 7. Run background tasks until completion
6098 cx.background_executor.run_until_parked();
6099
6100 // 8. Verify file contents after calling function
6101 let new_content = app_state
6102 .fs
6103 .load(Path::new("/root/.zed/settings.json"))
6104 .await
6105 .unwrap();
6106
6107 let new_content_str = new_content;
6108 eprintln!("New settings content: {}", new_content_str);
6109
6110 // The bug causes the settings to be overwritten with empty settings
6111 // So if the unique value is no longer present, the bug has been reproduced
6112 let bug_exists = !new_content_str.contains("UNIQUEVALUE");
6113 eprintln!("Bug reproduced: {}", bug_exists);
6114
6115 // This assertion should fail if the bug exists - showing the bug is real
6116 assert!(
6117 new_content_str.contains("UNIQUEVALUE"),
6118 "BUG FOUND: Project settings were overwritten when opening via command - original custom content was lost"
6119 );
6120 }
6121
6122 #[gpui::test]
6123 async fn test_disable_ai_crash(cx: &mut gpui::TestAppContext) {
6124 let app_state = init_test(cx);
6125 cx.update(init);
6126 let project = Project::test(app_state.fs.clone(), [], cx).await;
6127 let _window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx));
6128
6129 cx.run_until_parked();
6130
6131 cx.update(|cx| {
6132 SettingsStore::update_global(cx, |settings_store, cx| {
6133 settings_store.update_user_settings(cx, |settings| {
6134 settings.project.disable_ai = Some(SaturatingBool(true));
6135 });
6136 });
6137 });
6138
6139 cx.run_until_parked();
6140
6141 // If this panics, the test has failed
6142 }
6143
6144 #[gpui::test]
6145 async fn test_disable_ai_filters_keybindings(cx: &mut gpui::TestAppContext) {
6146 let _app_state = init_keymap_test(cx);
6147
6148 // With AI enabled, the default keymap should include the assistant
6149 // bindings that intercept e.g. ctrl-enter in the editor.
6150 cx.update(load_default_keymap);
6151 cx.update(|cx| {
6152 let keymap = cx.key_bindings();
6153 let keymap = keymap.borrow();
6154 let has_ai_binding = keymap.bindings().any(|binding| is_ai_keybinding(binding));
6155 assert!(
6156 has_ai_binding,
6157 "expected AI-namespaced bindings in the default keymap before disabling AI"
6158 );
6159 });
6160
6161 cx.update(|cx| {
6162 SettingsStore::update_global(cx, |settings_store, cx| {
6163 settings_store.update_user_settings(cx, |settings| {
6164 settings.project.disable_ai = Some(SaturatingBool(true));
6165 });
6166 });
6167 });
6168
6169 // The default keymap should drop every AI-namespaced binding so that
6170 // lower-precedence editor defaults can run instead.
6171 cx.update(|cx| {
6172 cx.clear_key_bindings();
6173 load_default_keymap(cx);
6174 });
6175 cx.update(|cx| {
6176 let keymap = cx.key_bindings();
6177 let keymap = keymap.borrow();
6178 if let Some(binding) = keymap.bindings().find(|b| is_ai_keybinding(b)) {
6179 panic!(
6180 "expected no AI-namespaced bindings after disabling AI, but found `{}`",
6181 binding.action().name()
6182 );
6183 }
6184 });
6185
6186 // User-defined bindings to AI actions should also be filtered.
6187 let user_binding = KeyBinding::new(
6188 "ctrl-enter",
6189 zed_actions::assistant::InlineAssist { prompt: None },
6190 None,
6191 );
6192 cx.update(|cx| reload_keymaps(cx, vec![user_binding]));
6193 cx.update(|cx| {
6194 let keymap = cx.key_bindings();
6195 let keymap = keymap.borrow();
6196 if let Some(binding) = keymap.bindings().find(|b| is_ai_keybinding(b)) {
6197 panic!(
6198 "expected user binding `{}` to be filtered when AI is disabled",
6199 binding.action().name()
6200 );
6201 }
6202 });
6203 }
6204
6205 #[gpui::test]
6206 async fn test_prefer_focused_window(cx: &mut gpui::TestAppContext) {
6207 let app_state = init_test(cx);
6208 let paths = [PathBuf::from(path!("/dir/document.txt"))];
6209
6210 app_state
6211 .fs
6212 .as_fake()
6213 .insert_tree(
6214 path!("/dir"),
6215 json!({
6216 "document.txt": "Some of the documentation's content."
6217 }),
6218 )
6219 .await;
6220
6221 let project_a = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
6222 let window_a = cx.add_window({
6223 let project = project_a.clone();
6224 |window, cx| MultiWorkspace::test_new(project, window, cx)
6225 });
6226
6227 let project_b = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
6228 let window_b = cx.add_window({
6229 let project = project_b.clone();
6230 |window, cx| MultiWorkspace::test_new(project, window, cx)
6231 });
6232
6233 let project_c = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
6234 let window_c = cx.add_window({
6235 let project = project_c.clone();
6236 |window, cx| MultiWorkspace::test_new(project, window, cx)
6237 });
6238
6239 for window in [window_a, window_b, window_c] {
6240 let _ = cx.update_window(*window, |_, window, _| {
6241 window.activate_window();
6242 });
6243
6244 cx.update(|cx| {
6245 let open_options = OpenOptions {
6246 wait: true,
6247 ..Default::default()
6248 };
6249
6250 workspace::open_paths(&paths, app_state.clone(), open_options, cx)
6251 })
6252 .await
6253 .unwrap();
6254
6255 cx.update_window(*window, |_, window, _| assert!(window.is_window_active()))
6256 .unwrap();
6257
6258 let _ = window.read_with(cx, |multi_workspace, cx| {
6259 let pane = multi_workspace.workspace().read(cx).active_pane().read(cx);
6260 let project_path = pane.active_item().unwrap().project_path(cx).unwrap();
6261
6262 assert_eq!(
6263 project_path.path.as_ref().as_std_path().to_str().unwrap(),
6264 path!("document.txt")
6265 )
6266 });
6267 }
6268 }
6269
6270 #[gpui::test]
6271 async fn test_open_paths_switches_to_best_workspace(cx: &mut TestAppContext) {
6272 let app_state = init_test(cx);
6273
6274 app_state
6275 .fs
6276 .as_fake()
6277 .insert_tree(
6278 path!("/"),
6279 json!({
6280 "dir1": {
6281 "a.txt": "content a"
6282 },
6283 "dir2": {
6284 "b.txt": "content b"
6285 },
6286 "dir3": {
6287 "c.txt": "content c"
6288 }
6289 }),
6290 )
6291 .await;
6292
6293 // Create a window with workspace 0 containing /dir1
6294 let project1 = Project::test(app_state.fs.clone(), [path!("/dir1").as_ref()], cx).await;
6295
6296 let window = cx.add_window({
6297 let project = project1.clone();
6298 |window, cx| MultiWorkspace::test_new(project, window, cx)
6299 });
6300 window
6301 .update(cx, |multi_workspace, _, cx| {
6302 multi_workspace.open_sidebar(cx);
6303 })
6304 .unwrap();
6305
6306 cx.run_until_parked();
6307 assert_eq!(cx.windows().len(), 1, "Should start with 1 window");
6308
6309 // Create workspace 2 with /dir2
6310 let project2 = Project::test(app_state.fs.clone(), [path!("/dir2").as_ref()], cx).await;
6311 let workspace2 = window
6312 .update(cx, |multi_workspace, window, cx| {
6313 multi_workspace.test_add_workspace(project2.clone(), window, cx)
6314 })
6315 .unwrap();
6316
6317 // Create workspace 3 with /dir3
6318 let project3 = Project::test(app_state.fs.clone(), [path!("/dir3").as_ref()], cx).await;
6319 let workspace3 = window
6320 .update(cx, |multi_workspace, window, cx| {
6321 multi_workspace.test_add_workspace(project3.clone(), window, cx)
6322 })
6323 .unwrap();
6324
6325 let workspace1 = window
6326 .read_with(cx, |multi_workspace, _| {
6327 multi_workspace.workspaces().next().unwrap().clone()
6328 })
6329 .unwrap();
6330
6331 window
6332 .update(cx, |multi_workspace, window, cx| {
6333 multi_workspace.activate(workspace2.clone(), None, window, cx);
6334 multi_workspace.activate(workspace3.clone(), None, window, cx);
6335 // Switch back to workspace1 for test setup
6336 multi_workspace.activate(workspace1.clone(), None, window, cx);
6337 assert_eq!(multi_workspace.workspace(), &workspace1);
6338 })
6339 .unwrap();
6340
6341 cx.run_until_parked();
6342
6343 // Verify setup: 3 workspaces, workspace 0 active, still 1 window
6344 window
6345 .read_with(cx, |multi_workspace, _| {
6346 assert_eq!(multi_workspace.workspaces().count(), 3);
6347 assert_eq!(multi_workspace.workspace(), &workspace1);
6348 })
6349 .unwrap();
6350 assert_eq!(cx.windows().len(), 1);
6351
6352 // Open a file in /dir3 - should switch to workspace 3 (not just "the other one")
6353 cx.update(|cx| {
6354 open_paths(
6355 &[PathBuf::from(path!("/dir3/c.txt"))],
6356 app_state.clone(),
6357 OpenOptions::default(),
6358 cx,
6359 )
6360 })
6361 .await
6362 .unwrap();
6363
6364 cx.run_until_parked();
6365
6366 // Verify workspace 2 is active and file opened there
6367 window
6368 .read_with(cx, |multi_workspace, cx| {
6369 assert_eq!(
6370 multi_workspace.workspace(),
6371 &workspace3,
6372 "Should have switched to workspace 3 which contains /dir3"
6373 );
6374 let active_item = multi_workspace
6375 .workspace()
6376 .read(cx)
6377 .active_pane()
6378 .read(cx)
6379 .active_item()
6380 .expect("Should have an active item");
6381 assert_eq!(active_item.tab_content_text(0, cx), "c.txt");
6382 })
6383 .unwrap();
6384 assert_eq!(cx.windows().len(), 1, "Should reuse existing window");
6385
6386 // Open a file in /dir2 - should switch to workspace 2
6387 cx.update(|cx| {
6388 open_paths(
6389 &[PathBuf::from(path!("/dir2/b.txt"))],
6390 app_state.clone(),
6391 OpenOptions::default(),
6392 cx,
6393 )
6394 })
6395 .await
6396 .unwrap();
6397
6398 cx.run_until_parked();
6399
6400 // Verify workspace 1 is active and file opened there
6401 window
6402 .read_with(cx, |multi_workspace, cx| {
6403 assert_eq!(
6404 multi_workspace.workspace(),
6405 &workspace2,
6406 "Should have switched to workspace 2 which contains /dir2"
6407 );
6408 let active_item = multi_workspace
6409 .workspace()
6410 .read(cx)
6411 .active_pane()
6412 .read(cx)
6413 .active_item()
6414 .expect("Should have an active item");
6415 assert_eq!(active_item.tab_content_text(0, cx), "b.txt");
6416 })
6417 .unwrap();
6418
6419 // Verify c.txt is still in workspace 3 (file opened in correct workspace, not active one)
6420 workspace3.read_with(cx, |workspace, cx| {
6421 let active_item = workspace
6422 .active_pane()
6423 .read(cx)
6424 .active_item()
6425 .expect("Workspace 2 should have an active item");
6426 assert_eq!(
6427 active_item.tab_content_text(0, cx),
6428 "c.txt",
6429 "c.txt should have been opened in workspace 3, not the active workspace"
6430 );
6431 });
6432
6433 assert_eq!(cx.windows().len(), 1, "Should still have only 1 window");
6434
6435 // Open a file in /dir1 - should switch back to workspace 0
6436 cx.update(|cx| {
6437 open_paths(
6438 &[PathBuf::from(path!("/dir1/a.txt"))],
6439 app_state.clone(),
6440 OpenOptions::default(),
6441 cx,
6442 )
6443 })
6444 .await
6445 .unwrap();
6446
6447 cx.run_until_parked();
6448
6449 // Verify workspace 0 is active and file opened there
6450 window
6451 .read_with(cx, |multi_workspace, cx| {
6452 assert_eq!(
6453 multi_workspace.workspace(),
6454 &workspace1,
6455 "Should have switched back to workspace 0 which contains /dir1"
6456 );
6457 let active_item = multi_workspace
6458 .workspace()
6459 .read(cx)
6460 .active_pane()
6461 .read(cx)
6462 .active_item()
6463 .expect("Should have an active item");
6464 assert_eq!(active_item.tab_content_text(0, cx), "a.txt");
6465 })
6466 .unwrap();
6467 assert_eq!(cx.windows().len(), 1, "Should still have only 1 window");
6468 }
6469
6470 #[gpui::test]
6471 async fn test_open_paths_in_gitignored_dir_opens_new_workspace(cx: &mut TestAppContext) {
6472 let app_state = init_test(cx);
6473
6474 app_state
6475 .fs
6476 .as_fake()
6477 .insert_tree(
6478 path!("/project"),
6479 json!({
6480 ".git": {},
6481 ".gitignore": ".checkouts/\n",
6482 "src": {
6483 "main.rs": "fn main() {}"
6484 },
6485 ".checkouts": {
6486 "worktrees": {
6487 "foo": {
6488 "README.md": "hello"
6489 }
6490 }
6491 }
6492 }),
6493 )
6494 .await;
6495
6496 cx.update(|cx| {
6497 open_paths(
6498 &[PathBuf::from(path!("/project"))],
6499 app_state.clone(),
6500 workspace::OpenOptions::default(),
6501 cx,
6502 )
6503 })
6504 .await
6505 .unwrap();
6506 cx.run_until_parked();
6507 assert_eq!(cx.update(|cx| cx.windows().len()), 1);
6508
6509 // Opening a directory inside a gitignored folder must not be treated
6510 // as contained by the open project: its contents were never scanned,
6511 // and it may be an independent checkout (e.g. a git worktree kept in
6512 // an ignored directory). It should become its own workspace root
6513 // instead.
6514 cx.update(|cx| {
6515 open_paths(
6516 &[PathBuf::from(path!("/project/.checkouts/worktrees/foo"))],
6517 app_state.clone(),
6518 workspace::OpenOptions::default(),
6519 cx,
6520 )
6521 })
6522 .await
6523 .unwrap();
6524 cx.run_until_parked();
6525
6526 let workspace_roots = cx.update(|cx| {
6527 cx.windows()
6528 .into_iter()
6529 .filter_map(|window| window.downcast::<MultiWorkspace>())
6530 .flat_map(|window| {
6531 let mut roots = Vec::new();
6532 if let Ok(multi_workspace) = window.read(cx) {
6533 for workspace in multi_workspace.workspaces() {
6534 roots.push(
6535 workspace
6536 .read(cx)
6537 .worktrees(cx)
6538 .map(|worktree| worktree.read(cx).abs_path().to_path_buf())
6539 .collect::<Vec<_>>(),
6540 );
6541 }
6542 }
6543 roots
6544 })
6545 .collect::<Vec<_>>()
6546 });
6547 assert!(
6548 workspace_roots.contains(&vec![PathBuf::from(path!(
6549 "/project/.checkouts/worktrees/foo"
6550 ))]),
6551 "the gitignored directory should be the root of its own workspace, got {workspace_roots:?}"
6552 );
6553 assert!(
6554 workspace_roots.contains(&vec![PathBuf::from(path!("/project"))]),
6555 "the original project workspace should be unchanged, got {workspace_roots:?}"
6556 );
6557 }
6558
6559 #[gpui::test]
6560 async fn test_quit_checks_all_workspaces_for_dirty_items(cx: &mut TestAppContext) {
6561 let app_state = init_test(cx);
6562 cx.update(init);
6563
6564 app_state
6565 .fs
6566 .as_fake()
6567 .insert_tree(
6568 path!("/"),
6569 json!({
6570 "dir1": {
6571 "a.txt": "content a"
6572 },
6573 "dir2": {
6574 "b.txt": "content b"
6575 },
6576 "dir3": {
6577 "c.txt": "content c"
6578 }
6579 }),
6580 )
6581 .await;
6582
6583 // === Setup Window 1 with two workspaces ===
6584 let project1 = Project::test(app_state.fs.clone(), [path!("/dir1").as_ref()], cx).await;
6585 let window1 = cx.add_window({
6586 let project = project1.clone();
6587 |window, cx| MultiWorkspace::test_new(project, window, cx)
6588 });
6589 window1
6590 .update(cx, |multi_workspace, _, cx| {
6591 multi_workspace.open_sidebar(cx);
6592 })
6593 .unwrap();
6594
6595 cx.run_until_parked();
6596
6597 let project2 = Project::test(app_state.fs.clone(), [path!("/dir2").as_ref()], cx).await;
6598 let workspace1_1 = window1
6599 .read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone())
6600 .unwrap();
6601 let workspace1_2 = window1
6602 .update(cx, |multi_workspace, window, cx| {
6603 multi_workspace.test_add_workspace(project2.clone(), window, cx)
6604 })
6605 .unwrap();
6606
6607 window1
6608 .update(cx, |multi_workspace, window, cx| {
6609 multi_workspace.activate(workspace1_2.clone(), None, window, cx);
6610 multi_workspace.activate(workspace1_1.clone(), None, window, cx);
6611 })
6612 .unwrap();
6613
6614 // === Setup Window 2 with one workspace ===
6615 let project3 = Project::test(app_state.fs.clone(), [path!("/dir3").as_ref()], cx).await;
6616 let window2 = cx.add_window({
6617 let project = project3.clone();
6618 |window, cx| MultiWorkspace::test_new(project, window, cx)
6619 });
6620 window2
6621 .update(cx, |multi_workspace, _, cx| {
6622 multi_workspace.open_sidebar(cx);
6623 })
6624 .unwrap();
6625
6626 cx.run_until_parked();
6627 assert_eq!(cx.windows().len(), 2);
6628
6629 // === Case 1: Active workspace has dirty item, quit can be cancelled ===
6630 let worktree1_id = project1.update(cx, |project, cx| {
6631 project.worktrees(cx).next().unwrap().read(cx).id()
6632 });
6633
6634 let editor1 = window1
6635 .update(cx, |_, window, cx| {
6636 workspace1_1.update(cx, |workspace, cx| {
6637 workspace.open_path((worktree1_id, rel_path("a.txt")), None, true, window, cx)
6638 })
6639 })
6640 .unwrap()
6641 .await
6642 .unwrap()
6643 .downcast::<Editor>()
6644 .unwrap();
6645
6646 window1
6647 .update(cx, |_, window, cx| {
6648 editor1.update(cx, |editor, cx| {
6649 editor.insert("dirty in active workspace", window, cx);
6650 });
6651 })
6652 .unwrap();
6653
6654 cx.run_until_parked();
6655
6656 // Verify workspace1_1 is active
6657 window1
6658 .read_with(cx, |multi_workspace, _| {
6659 assert_eq!(multi_workspace.workspace(), &workspace1_1);
6660 })
6661 .unwrap();
6662
6663 cx.dispatch_action(*window1, Quit);
6664 cx.run_until_parked();
6665
6666 assert!(
6667 cx.has_pending_prompt(),
6668 "Case 1: Should prompt to save dirty item in active workspace"
6669 );
6670
6671 cx.simulate_prompt_answer("Cancel");
6672 cx.run_until_parked();
6673
6674 assert_eq!(
6675 cx.windows().len(),
6676 2,
6677 "Case 1: Windows should still exist after cancelling quit"
6678 );
6679
6680 // Clean up Case 1: Close the dirty item without saving
6681 let close_task = window1
6682 .update(cx, |_, window, cx| {
6683 workspace1_1.update(cx, |workspace, cx| {
6684 workspace.active_pane().update(cx, |pane, cx| {
6685 pane.close_active_item(&Default::default(), window, cx)
6686 })
6687 })
6688 })
6689 .unwrap();
6690 cx.run_until_parked();
6691 cx.simulate_prompt_answer("Don't Save");
6692 close_task.await.ok();
6693 cx.run_until_parked();
6694
6695 // === Case 2: Non-active workspace (same window) has dirty item ===
6696 let worktree2_id = project2.update(cx, |project, cx| {
6697 project.worktrees(cx).next().unwrap().read(cx).id()
6698 });
6699
6700 let editor2 = window1
6701 .update(cx, |_, window, cx| {
6702 workspace1_2.update(cx, |workspace, cx| {
6703 workspace.open_path((worktree2_id, rel_path("b.txt")), None, true, window, cx)
6704 })
6705 })
6706 .unwrap()
6707 .await
6708 .unwrap()
6709 .downcast::<Editor>()
6710 .unwrap();
6711
6712 window1
6713 .update(cx, |_, window, cx| {
6714 editor2.update(cx, |editor, cx| {
6715 editor.insert("dirty in non-active workspace", window, cx);
6716 });
6717 })
6718 .unwrap();
6719
6720 cx.run_until_parked();
6721
6722 // Verify workspace1_1 is still active (not workspace1_2 with dirty item)
6723 window1
6724 .read_with(cx, |multi_workspace, _| {
6725 assert_eq!(multi_workspace.workspace(), &workspace1_1);
6726 })
6727 .unwrap();
6728
6729 cx.dispatch_action(*window1, Quit);
6730 cx.run_until_parked();
6731
6732 // Verify the non-active workspace got activated to show the dirty item
6733 window1
6734 .read_with(cx, |multi_workspace, _| {
6735 assert_eq!(
6736 multi_workspace.workspace(),
6737 &workspace1_2,
6738 "Case 2: Non-active workspace should be activated when it has dirty item"
6739 );
6740 })
6741 .unwrap();
6742
6743 assert!(
6744 cx.has_pending_prompt(),
6745 "Case 2: Should prompt to save dirty item in non-active workspace"
6746 );
6747
6748 cx.simulate_prompt_answer("Cancel");
6749 cx.run_until_parked();
6750
6751 assert_eq!(
6752 cx.windows().len(),
6753 2,
6754 "Case 2: Windows should still exist after cancelling quit"
6755 );
6756
6757 // Clean up Case 2: Close the dirty item without saving
6758 let close_task = window1
6759 .update(cx, |_, window, cx| {
6760 workspace1_2.update(cx, |workspace, cx| {
6761 workspace.active_pane().update(cx, |pane, cx| {
6762 pane.close_active_item(&Default::default(), window, cx)
6763 })
6764 })
6765 })
6766 .unwrap();
6767 cx.run_until_parked();
6768 cx.simulate_prompt_answer("Don't Save");
6769 close_task.await.ok();
6770 cx.run_until_parked();
6771
6772 // === Case 3: Non-active window has dirty item ===
6773 let workspace3 = window2
6774 .read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone())
6775 .unwrap();
6776
6777 let worktree3_id = project3.update(cx, |project, cx| {
6778 project.worktrees(cx).next().unwrap().read(cx).id()
6779 });
6780
6781 let editor3 = window2
6782 .update(cx, |_, window, cx| {
6783 workspace3.update(cx, |workspace, cx| {
6784 workspace.open_path((worktree3_id, rel_path("c.txt")), None, true, window, cx)
6785 })
6786 })
6787 .unwrap()
6788 .await
6789 .unwrap()
6790 .downcast::<Editor>()
6791 .unwrap();
6792
6793 window2
6794 .update(cx, |_, window, cx| {
6795 editor3.update(cx, |editor, cx| {
6796 editor.insert("dirty in other window", window, cx);
6797 });
6798 })
6799 .unwrap();
6800
6801 cx.run_until_parked();
6802
6803 // Activate window1 explicitly (editing in window2 may have activated it)
6804 window1
6805 .update(cx, |_, window, _| window.activate_window())
6806 .unwrap();
6807 cx.run_until_parked();
6808
6809 // Verify window2 is not active (window1 should still be active)
6810 assert_eq!(
6811 cx.update(|cx| window2.is_active(cx)),
6812 Some(false),
6813 "Case 3: window2 should not be active before quit"
6814 );
6815
6816 // Dispatch quit from window1 (window2 has the dirty item)
6817 cx.dispatch_action(*window1, Quit);
6818 cx.run_until_parked();
6819
6820 // Verify window2 is now active (quit handler activated it to show dirty item)
6821 assert_eq!(
6822 cx.update(|cx| window2.is_active(cx)),
6823 Some(true),
6824 "Case 3: window2 should be activated when it has dirty item"
6825 );
6826
6827 assert!(
6828 cx.has_pending_prompt(),
6829 "Case 3: Should prompt to save dirty item in non-active window"
6830 );
6831
6832 cx.simulate_prompt_answer("Cancel");
6833 cx.run_until_parked();
6834
6835 assert_eq!(
6836 cx.windows().len(),
6837 2,
6838 "Case 3: Windows should still exist after cancelling quit"
6839 );
6840 }
6841
6842 #[gpui::test]
6843 async fn test_multi_workspace_session_restore(cx: &mut TestAppContext) {
6844 use collections::HashMap;
6845 use session::Session;
6846 use util::path_list::PathList;
6847 use workspace::{OpenMode, ProjectGroupKey, Workspace, WorkspaceId};
6848
6849 let app_state = init_test(cx);
6850
6851 let dir1 = path!("/dir1");
6852 let dir2 = path!("/dir2");
6853 let dir3 = path!("/dir3");
6854
6855 let fs = app_state.fs.clone();
6856 let fake_fs = fs.as_fake();
6857 fake_fs.insert_tree(dir1, json!({})).await;
6858 fake_fs.insert_tree(dir2, json!({})).await;
6859 fake_fs.insert_tree(dir3, json!({})).await;
6860
6861 let session_id = cx.read(|cx| app_state.session.read(cx).id().to_owned());
6862
6863 // --- Create 3 workspaces in 2 windows ---
6864 //
6865 // Window A: workspace for dir1, workspace for dir2
6866 // Window B: workspace for dir3
6867 let workspace::OpenResult {
6868 window: window_a, ..
6869 } = cx
6870 .update(|cx| {
6871 Workspace::new_local(
6872 vec![dir1.into()],
6873 app_state.clone(),
6874 None,
6875 None,
6876 None,
6877 OpenMode::Activate,
6878 cx,
6879 )
6880 })
6881 .await
6882 .expect("failed to open first workspace");
6883
6884 window_a
6885 .update(cx, |multi_workspace, _, cx| {
6886 multi_workspace.open_sidebar(cx);
6887 })
6888 .unwrap();
6889
6890 window_a
6891 .update(cx, |multi_workspace, window, cx| {
6892 multi_workspace.open_project(vec![dir2.into()], OpenMode::Activate, window, cx)
6893 })
6894 .unwrap()
6895 .await
6896 .expect("failed to open second workspace into window A");
6897 cx.run_until_parked();
6898
6899 let workspace::OpenResult {
6900 window: window_b, ..
6901 } = cx
6902 .update(|cx| {
6903 Workspace::new_local(
6904 vec![dir3.into()],
6905 app_state.clone(),
6906 None,
6907 None,
6908 None,
6909 OpenMode::Activate,
6910 cx,
6911 )
6912 })
6913 .await
6914 .expect("failed to open third workspace");
6915
6916 window_b
6917 .update(cx, |multi_workspace, _, cx| {
6918 multi_workspace.open_sidebar(cx);
6919 })
6920 .unwrap();
6921
6922 // Currently dir2 is active because it was added last.
6923 // So, switch window_a's active workspace to dir1 (index 0).
6924 // This sets up a non-trivial assertion: after restore, dir1 should
6925 // still be active rather than whichever workspace happened to restore last.
6926 window_a
6927 .update(cx, |multi_workspace, window, cx| {
6928 let workspace = multi_workspace.workspaces().next().unwrap().clone();
6929 multi_workspace.activate(workspace, None, window, cx);
6930 })
6931 .unwrap();
6932
6933 cx.run_until_parked();
6934 flush_workspace_serialization(&window_a, cx).await;
6935 flush_workspace_serialization(&window_b, cx).await;
6936 cx.run_until_parked();
6937
6938 // Verify all workspaces retained their session_ids.
6939 let db = cx.update(|cx| workspace::WorkspaceDb::global(cx));
6940 let locations =
6941 workspace::last_session_workspace_locations(&db, &session_id, None, fs.as_ref())
6942 .await
6943 .expect("expected session workspace locations");
6944 assert_eq!(
6945 locations.len(),
6946 3,
6947 "all 3 workspaces should have session_ids in the DB"
6948 );
6949
6950 // Close the original windows.
6951 window_a
6952 .update(cx, |_, window, _| window.remove_window())
6953 .unwrap();
6954 window_b
6955 .update(cx, |_, window, _| window.remove_window())
6956 .unwrap();
6957 cx.run_until_parked();
6958
6959 // Simulate a new session launch: replace the session so that
6960 // `last_session_id()` returns the ID used during workspace creation.
6961 // `restore_on_startup` defaults to `LastSession`, which is what we need.
6962 cx.update(|cx| {
6963 app_state.session.update(cx, |app_session, _cx| {
6964 app_session
6965 .replace_session_for_test(Session::test_with_old_session(session_id.clone()));
6966 });
6967 });
6968
6969 // --- Read back from DB and verify grouping ---
6970 let locations =
6971 workspace::last_session_workspace_locations(&db, &session_id, None, fs.as_ref())
6972 .await
6973 .expect("expected session workspace locations");
6974
6975 assert_eq!(locations.len(), 3, "expected 3 session workspaces");
6976
6977 let mut groups_by_window: HashMap<gpui::WindowId, Vec<WorkspaceId>> = HashMap::default();
6978 for session_workspace in &locations {
6979 if let Some(window_id) = session_workspace.window_id {
6980 groups_by_window
6981 .entry(window_id)
6982 .or_default()
6983 .push(session_workspace.workspace_id);
6984 }
6985 }
6986 assert_eq!(
6987 groups_by_window.len(),
6988 2,
6989 "expected 2 window groups, got {groups_by_window:?}"
6990 );
6991 assert!(
6992 groups_by_window.values().any(|g| g.len() == 2),
6993 "expected one group with 2 workspaces"
6994 );
6995 assert!(
6996 groups_by_window.values().any(|g| g.len() == 1),
6997 "expected one group with 1 workspace"
6998 );
6999
7000 let mut async_cx = cx.to_async();
7001 crate::restore_or_create_workspace(app_state.clone(), &mut async_cx)
7002 .await
7003 .expect("failed to restore workspaces");
7004 cx.run_until_parked();
7005
7006 // --- Verify the restored windows ---
7007 let restored_windows: Vec<WindowHandle<MultiWorkspace>> = cx.read(|cx| {
7008 cx.windows()
7009 .into_iter()
7010 .filter_map(|window| window.downcast::<MultiWorkspace>())
7011 .collect()
7012 });
7013 assert_eq!(restored_windows.len(), 2,);
7014
7015 // Identify restored windows by their active workspace root paths.
7016 let (restored_a, restored_b) = {
7017 let (mut with_dir1, mut with_dir3) = (None, None);
7018 for window in &restored_windows {
7019 let active_paths = window
7020 .read_with(cx, |mw, cx| mw.workspace().read(cx).root_paths(cx))
7021 .unwrap();
7022 if active_paths.iter().any(|p| p.as_ref() == Path::new(dir1)) {
7023 with_dir1 = Some(window);
7024 } else {
7025 with_dir3 = Some(window);
7026 }
7027 }
7028 (
7029 with_dir1.expect("expected a window with dir1 active"),
7030 with_dir3.expect("expected a window with dir3 active"),
7031 )
7032 };
7033
7034 // Window A (dir1+dir2): 1 workspace restored, but 2 project group keys.
7035 restored_a
7036 .read_with(cx, |mw, _| {
7037 assert_eq!(
7038 mw.project_group_keys(),
7039 vec![
7040 ProjectGroupKey::new(None, PathList::new(&[dir2])),
7041 ProjectGroupKey::new(None, PathList::new(&[dir1])),
7042 ]
7043 );
7044 assert_eq!(mw.workspaces().count(), 1);
7045 })
7046 .unwrap();
7047
7048 // Window B (dir3): 1 workspace, 1 project group key.
7049 restored_b
7050 .read_with(cx, |mw, _| {
7051 assert_eq!(
7052 mw.project_group_keys(),
7053 vec![ProjectGroupKey::new(None, PathList::new(&[dir3]))]
7054 );
7055 assert_eq!(mw.workspaces().count(), 1);
7056 })
7057 .unwrap();
7058 }
7059
7060 #[gpui::test]
7061 async fn test_quit_preserves_focused_workspace_for_restore(cx: &mut TestAppContext) {
7062 use session::Session;
7063 use workspace::{OpenMode, Workspace};
7064
7065 let app_state = init_test(cx);
7066 cx.update(init);
7067
7068 let dir1 = path!("/dir1");
7069 let dir2 = path!("/dir2");
7070
7071 let fs = app_state.fs.clone();
7072 let fake_fs = fs.as_fake();
7073 fake_fs.insert_tree(dir1, json!({})).await;
7074 fake_fs.insert_tree(dir2, json!({})).await;
7075
7076 let session_id = cx.read(|cx| app_state.session.read(cx).id().to_owned());
7077
7078 // Window with two retained workspaces: dir1 added first, dir2 second.
7079 let workspace::OpenResult { window, .. } = cx
7080 .update(|cx| {
7081 Workspace::new_local(
7082 vec![dir1.into()],
7083 app_state.clone(),
7084 None,
7085 None,
7086 None,
7087 OpenMode::Activate,
7088 cx,
7089 )
7090 })
7091 .await
7092 .expect("failed to open first workspace");
7093
7094 window
7095 .update(cx, |multi_workspace, _, cx| {
7096 multi_workspace.open_sidebar(cx);
7097 })
7098 .unwrap();
7099
7100 window
7101 .update(cx, |multi_workspace, window, cx| {
7102 multi_workspace.open_project(vec![dir2.into()], OpenMode::Activate, window, cx)
7103 })
7104 .unwrap()
7105 .await
7106 .expect("failed to open second workspace");
7107 cx.run_until_parked();
7108
7109 // Focus dir1 (the first workspace). dir2 was activated last when it was
7110 // opened and is iterated last by the quit-time close-prompt loop, so
7111 // without the fix the persisted active workspace gets clobbered to dir2.
7112 window
7113 .update(cx, |multi_workspace, window, cx| {
7114 let workspace = multi_workspace.workspaces().next().unwrap().clone();
7115 multi_workspace.activate(workspace, None, window, cx);
7116 })
7117 .unwrap();
7118 cx.run_until_parked();
7119
7120 window
7121 .read_with(cx, |mw, cx| {
7122 assert!(
7123 mw.workspace()
7124 .read(cx)
7125 .root_paths(cx)
7126 .iter()
7127 .any(|p| p.as_ref() == Path::new(dir1)),
7128 "dir1 should be the focused workspace before quitting"
7129 );
7130 })
7131 .unwrap();
7132
7133 // Quit. With no dirty items there are no save prompts, so the quit flow
7134 // runs the prepare_to_close loop (which activates every workspace in
7135 // turn to surface prompts) and then flushes serialization. cx.quit() is
7136 // a no-op in tests, so the window stays around for inspection.
7137 cx.dispatch_action(*window, Quit);
7138 cx.run_until_parked();
7139
7140 // The fix re-activates the originally-focused workspace after the loop,
7141 // so the window must still be focused on dir1, not dir2.
7142 window
7143 .read_with(cx, |mw, cx| {
7144 let active = mw.workspace().read(cx).root_paths(cx);
7145 assert!(
7146 active.iter().any(|p| p.as_ref() == Path::new(dir1)),
7147 "quitting must not change which workspace is focused"
7148 );
7149 assert!(
7150 !active.iter().any(|p| p.as_ref() == Path::new(dir2)),
7151 "dir2 must not become the focused workspace after quitting"
7152 );
7153 })
7154 .unwrap();
7155
7156 // Simulate a fresh launch and verify dir1 is restored as the active
7157 // workspace rather than dir2 (or an empty window).
7158 window
7159 .update(cx, |_, window, _| window.remove_window())
7160 .unwrap();
7161 cx.run_until_parked();
7162
7163 cx.update(|cx| {
7164 app_state.session.update(cx, |app_session, _cx| {
7165 app_session
7166 .replace_session_for_test(Session::test_with_old_session(session_id.clone()));
7167 });
7168 });
7169
7170 let mut async_cx = cx.to_async();
7171 crate::restore_or_create_workspace(app_state.clone(), &mut async_cx)
7172 .await
7173 .expect("failed to restore workspaces");
7174 cx.run_until_parked();
7175
7176 let restored_windows: Vec<WindowHandle<MultiWorkspace>> = cx.read(|cx| {
7177 cx.windows()
7178 .into_iter()
7179 .filter_map(|window| window.downcast::<MultiWorkspace>())
7180 .collect()
7181 });
7182 assert_eq!(restored_windows.len(), 1);
7183
7184 restored_windows[0]
7185 .read_with(cx, |mw, cx| {
7186 let active = mw.workspace().read(cx).root_paths(cx);
7187 assert!(
7188 active.iter().any(|p| p.as_ref() == Path::new(dir1)),
7189 "the focused workspace (dir1) must be restored as active"
7190 );
7191 assert!(
7192 !active.iter().any(|p| p.as_ref() == Path::new(dir2)),
7193 "dir2 must not be restored as the active workspace"
7194 );
7195 })
7196 .unwrap();
7197 }
7198
7199 #[gpui::test]
7200 async fn test_restored_project_groups_survive_workspace_key_change(cx: &mut TestAppContext) {
7201 use session::Session;
7202 use util::path_list::PathList;
7203 use workspace::{OpenMode, ProjectGroupKey};
7204
7205 let app_state = init_test(cx);
7206
7207 let fs = app_state.fs.clone();
7208 let fake_fs = fs.as_fake();
7209 fake_fs
7210 .insert_tree(path!("/root_a"), json!({ "file.txt": "" }))
7211 .await;
7212 fake_fs
7213 .insert_tree(path!("/root_b"), json!({ "file.txt": "" }))
7214 .await;
7215 fake_fs
7216 .insert_tree(path!("/root_c"), json!({ "file.txt": "" }))
7217 .await;
7218 fake_fs
7219 .insert_tree(path!("/root_d"), json!({ "other.txt": "" }))
7220 .await;
7221
7222 let session_id = cx.read(|cx| app_state.session.read(cx).id().to_owned());
7223
7224 // --- Phase 1: Build a multi-workspace with 3 project groups ---
7225
7226 let workspace::OpenResult { window, .. } = cx
7227 .update(|cx| {
7228 workspace::Workspace::new_local(
7229 vec![path!("/root_a").into()],
7230 app_state.clone(),
7231 None,
7232 None,
7233 None,
7234 OpenMode::Activate,
7235 cx,
7236 )
7237 })
7238 .await
7239 .expect("failed to open workspace");
7240
7241 window.update(cx, |mw, _, cx| mw.open_sidebar(cx)).unwrap();
7242
7243 window
7244 .update(cx, |mw, window, cx| {
7245 mw.open_project(vec![path!("/root_b").into()], OpenMode::Add, window, cx)
7246 })
7247 .unwrap()
7248 .await
7249 .expect("failed to add root_b");
7250
7251 window
7252 .update(cx, |mw, window, cx| {
7253 mw.open_project(vec![path!("/root_c").into()], OpenMode::Add, window, cx)
7254 })
7255 .unwrap()
7256 .await
7257 .expect("failed to add root_c");
7258 cx.run_until_parked();
7259
7260 let key_b = ProjectGroupKey::new(None, PathList::new(&[path!("/root_b")]));
7261 let key_c = ProjectGroupKey::new(None, PathList::new(&[path!("/root_c")]));
7262
7263 // Make root_a the active workspace so it's the one eagerly restored.
7264 window
7265 .update(cx, |mw, window, cx| {
7266 let workspace_a = mw
7267 .workspaces()
7268 .find(|ws| {
7269 ws.read(cx)
7270 .root_paths(cx)
7271 .iter()
7272 .any(|p| p.as_ref() == Path::new(path!("/root_a")))
7273 })
7274 .expect("workspace_a should exist")
7275 .clone();
7276 mw.activate(workspace_a, None, window, cx);
7277 })
7278 .unwrap();
7279 cx.run_until_parked();
7280
7281 // --- Phase 2: Serialize, close, and restore ---
7282
7283 flush_workspace_serialization(&window, cx).await;
7284 cx.run_until_parked();
7285
7286 window
7287 .update(cx, |_, window, _| window.remove_window())
7288 .unwrap();
7289 cx.run_until_parked();
7290
7291 cx.update(|cx| {
7292 app_state.session.update(cx, |app_session, _cx| {
7293 app_session
7294 .replace_session_for_test(Session::test_with_old_session(session_id.clone()));
7295 });
7296 });
7297
7298 let mut async_cx = cx.to_async();
7299 crate::restore_or_create_workspace(app_state.clone(), &mut async_cx)
7300 .await
7301 .expect("failed to restore workspace");
7302 cx.run_until_parked();
7303
7304 let restored_windows: Vec<WindowHandle<MultiWorkspace>> = cx.read(|cx| {
7305 cx.windows()
7306 .into_iter()
7307 .filter_map(|w| w.downcast::<MultiWorkspace>())
7308 .collect()
7309 });
7310 assert_eq!(restored_windows.len(), 1);
7311 let restored = &restored_windows[0];
7312
7313 // Verify the restored window has all 3 project groups.
7314 restored
7315 .read_with(cx, |mw, _cx| {
7316 let keys = mw.project_group_keys();
7317 assert_eq!(
7318 keys.len(),
7319 3,
7320 "restored window should have 3 groups; got {keys:?}"
7321 );
7322 assert!(keys.contains(&key_b), "should contain key_b");
7323 assert!(keys.contains(&key_c), "should contain key_c");
7324 })
7325 .unwrap();
7326
7327 // --- Phase 3: Trigger a workspace key change and verify survival ---
7328
7329 let active_project = restored
7330 .read_with(cx, |mw, cx| mw.workspace().read(cx).project().clone())
7331 .unwrap();
7332
7333 active_project
7334 .update(cx, |project, cx| {
7335 project.find_or_create_worktree(path!("/root_d"), true, cx)
7336 })
7337 .await
7338 .expect("adding worktree should succeed");
7339 cx.run_until_parked();
7340
7341 restored
7342 .read_with(cx, |mw, _cx| {
7343 let keys = mw.project_group_keys();
7344 assert!(
7345 keys.contains(&key_b),
7346 "restored group key_b should survive a workspace key change; got {keys:?}"
7347 );
7348 assert!(
7349 keys.contains(&key_c),
7350 "restored group key_c should survive a workspace key change; got {keys:?}"
7351 );
7352 })
7353 .unwrap();
7354 }
7355
7356 #[gpui::test]
7357 async fn test_close_project_removes_project_group(cx: &mut TestAppContext) {
7358 use util::path_list::PathList;
7359 use workspace::{OpenMode, ProjectGroupKey};
7360
7361 let app_state = init_test(cx);
7362 app_state
7363 .fs
7364 .as_fake()
7365 .insert_tree(path!("/my-project"), json!({}))
7366 .await;
7367
7368 let workspace::OpenResult { window, .. } = cx
7369 .update(|cx| {
7370 workspace::Workspace::new_local(
7371 vec![path!("/my-project").into()],
7372 app_state.clone(),
7373 None,
7374 None,
7375 None,
7376 OpenMode::Activate,
7377 cx,
7378 )
7379 })
7380 .await
7381 .unwrap();
7382
7383 window.update(cx, |mw, _, cx| mw.open_sidebar(cx)).unwrap();
7384 cx.background_executor.run_until_parked();
7385
7386 let project_key = ProjectGroupKey::new(None, PathList::new(&[path!("/my-project")]));
7387 let keys = window
7388 .read_with(cx, |mw, _| mw.project_group_keys())
7389 .unwrap();
7390 assert_eq!(
7391 keys,
7392 vec![project_key],
7393 "project group should exist before CloseProject: {keys:?}"
7394 );
7395
7396 cx.dispatch_action(window.into(), CloseProject);
7397
7398 let keys = window
7399 .read_with(cx, |mw, _| mw.project_group_keys())
7400 .unwrap();
7401 assert!(
7402 keys.is_empty(),
7403 "project group should be removed after CloseProject: {keys:?}"
7404 );
7405 }
7406
7407 #[gpui::test]
7408 async fn test_close_project_switches_to_neighbor_in_multi_project(cx: &mut TestAppContext) {
7409 use workspace::OpenMode;
7410
7411 let app_state = init_test(cx);
7412 app_state
7413 .fs
7414 .as_fake()
7415 .insert_tree(path!("/project-a"), json!({}))
7416 .await;
7417 app_state
7418 .fs
7419 .as_fake()
7420 .insert_tree(path!("/project-b"), json!({}))
7421 .await;
7422
7423 let workspace::OpenResult {
7424 window,
7425 workspace: workspace_a,
7426 ..
7427 } = cx
7428 .update(|cx| {
7429 workspace::Workspace::new_local(
7430 vec![path!("/project-a").into()],
7431 app_state.clone(),
7432 None,
7433 None,
7434 None,
7435 OpenMode::Activate,
7436 cx,
7437 )
7438 })
7439 .await
7440 .unwrap();
7441
7442 let project_b = Project::test(app_state.fs.clone(), [Path::new("/project-b")], cx).await;
7443
7444 window
7445 .update(cx, |multi_workspace, window, cx| {
7446 multi_workspace.test_add_workspace(project_b, window, cx);
7447 })
7448 .unwrap();
7449 cx.background_executor.run_until_parked();
7450
7451 // Reactivate workspace A so we close it via CloseProject.
7452 window
7453 .update(cx, |multi_workspace, window, cx| {
7454 multi_workspace.activate(workspace_a, None, window, cx);
7455 })
7456 .unwrap();
7457 cx.background_executor.run_until_parked();
7458
7459 let keys_before = window
7460 .read_with(cx, |multi_workspace, _| {
7461 multi_workspace.project_group_keys()
7462 })
7463 .unwrap();
7464 assert_eq!(
7465 keys_before.len(),
7466 2,
7467 "should have 2 project groups before CloseProject: {keys_before:?}"
7468 );
7469
7470 cx.dispatch_action(window.into(), CloseProject);
7471 cx.background_executor.run_until_parked();
7472
7473 let keys_after = window
7474 .read_with(cx, |multi_workspace, _| {
7475 multi_workspace.project_group_keys()
7476 })
7477 .unwrap();
7478 assert_eq!(
7479 keys_after.len(),
7480 1,
7481 "one project group should remain after CloseProject: {keys_after:?}"
7482 );
7483
7484 let active_paths = window
7485 .read_with(cx, |multi_workspace, cx| {
7486 multi_workspace.workspace().read(cx).root_paths(cx)
7487 })
7488 .unwrap();
7489 assert!(
7490 !active_paths.is_empty(),
7491 "active workspace should contain the remaining project, not be empty: {active_paths:?}"
7492 );
7493 }
7494}
7495