1pub mod languages;
2pub mod menus;
3#[cfg(any(test, feature = "test-support"))]
4pub mod test;
5use anyhow::{anyhow, Context, Result};
6use assets::Assets;
7use breadcrumbs::Breadcrumbs;
8pub use client;
9use collab_ui::{CollabTitlebarItem, ToggleCollaborationMenu};
10use collections::VecDeque;
11pub use editor;
12use editor::{Editor, MultiBuffer};
13
14use futures::StreamExt;
15use gpui::{
16 actions,
17 geometry::{
18 rect::RectF,
19 vector::{vec2f, Vector2F},
20 },
21 impl_actions,
22 platform::{WindowBounds, WindowOptions},
23 AssetSource, AsyncAppContext, Platform, PromptLevel, TitlebarOptions, ViewContext, WindowKind,
24};
25use language::Rope;
26use lazy_static::lazy_static;
27pub use lsp;
28pub use project;
29use project_panel::ProjectPanel;
30use search::{BufferSearchBar, ProjectSearchBar};
31use serde::Deserialize;
32use serde_json::to_string_pretty;
33use settings::{keymap_file_json_schema, settings_file_json_schema, Settings};
34use std::{borrow::Cow, env, path::Path, str, sync::Arc};
35use util::{channel::ReleaseChannel, paths, ResultExt, StaffMode};
36use uuid::Uuid;
37pub use workspace;
38use workspace::{sidebar::SidebarSide, AppState, Workspace};
39
40#[derive(Deserialize, Clone, PartialEq)]
41pub struct OpenBrowser {
42 url: Arc<str>,
43}
44
45impl_actions!(zed, [OpenBrowser]);
46
47actions!(
48 zed,
49 [
50 About,
51 Hide,
52 HideOthers,
53 ShowAll,
54 Minimize,
55 Zoom,
56 ToggleFullScreen,
57 Quit,
58 DebugElements,
59 OpenSettings,
60 OpenLog,
61 OpenLicenses,
62 OpenTelemetryLog,
63 OpenKeymap,
64 OpenDefaultSettings,
65 OpenDefaultKeymap,
66 IncreaseBufferFontSize,
67 DecreaseBufferFontSize,
68 ResetBufferFontSize,
69 InstallCommandLineInterface,
70 ResetDatabase,
71 ]
72);
73
74const MIN_FONT_SIZE: f32 = 6.0;
75
76lazy_static! {
77 static ref ZED_WINDOW_SIZE: Option<Vector2F> = env::var("ZED_WINDOW_SIZE")
78 .ok()
79 .as_deref()
80 .and_then(parse_pixel_position_env_var);
81 static ref ZED_WINDOW_POSITION: Option<Vector2F> = env::var("ZED_WINDOW_POSITION")
82 .ok()
83 .as_deref()
84 .and_then(parse_pixel_position_env_var);
85}
86
87pub fn init(app_state: &Arc<AppState>, cx: &mut gpui::MutableAppContext) {
88 cx.add_action(about);
89 cx.add_global_action(|_: &Hide, cx: &mut gpui::MutableAppContext| {
90 cx.platform().hide();
91 });
92 cx.add_global_action(|_: &HideOthers, cx: &mut gpui::MutableAppContext| {
93 cx.platform().hide_other_apps();
94 });
95 cx.add_global_action(|_: &ShowAll, cx: &mut gpui::MutableAppContext| {
96 cx.platform().unhide_other_apps();
97 });
98 cx.add_action(
99 |_: &mut Workspace, _: &Minimize, cx: &mut ViewContext<Workspace>| {
100 cx.minimize_window();
101 },
102 );
103 cx.add_action(
104 |_: &mut Workspace, _: &Zoom, cx: &mut ViewContext<Workspace>| {
105 cx.zoom_window();
106 },
107 );
108 cx.add_action(
109 |_: &mut Workspace, _: &ToggleFullScreen, cx: &mut ViewContext<Workspace>| {
110 cx.toggle_full_screen();
111 },
112 );
113 cx.add_action(
114 |workspace: &mut Workspace,
115 _: &ToggleCollaborationMenu,
116 cx: &mut ViewContext<Workspace>| {
117 if let Some(item) = workspace
118 .titlebar_item()
119 .and_then(|item| item.downcast::<CollabTitlebarItem>())
120 {
121 cx.as_mut().defer(move |cx| {
122 item.update(cx, |item, cx| {
123 item.toggle_contacts_popover(&Default::default(), cx);
124 });
125 });
126 }
127 },
128 );
129 cx.add_global_action(quit);
130 cx.add_global_action(move |action: &OpenBrowser, cx| cx.platform().open_url(&action.url));
131 cx.add_global_action(move |_: &IncreaseBufferFontSize, cx| {
132 cx.update_global::<Settings, _, _>(|settings, cx| {
133 settings.buffer_font_size = (settings.buffer_font_size + 1.0).max(MIN_FONT_SIZE);
134 if let Some(terminal_font_size) = settings.terminal_overrides.font_size.as_mut() {
135 *terminal_font_size = (*terminal_font_size + 1.0).max(MIN_FONT_SIZE);
136 }
137 cx.refresh_windows();
138 });
139 });
140 cx.add_global_action(move |_: &DecreaseBufferFontSize, cx| {
141 cx.update_global::<Settings, _, _>(|settings, cx| {
142 settings.buffer_font_size = (settings.buffer_font_size - 1.0).max(MIN_FONT_SIZE);
143 if let Some(terminal_font_size) = settings.terminal_overrides.font_size.as_mut() {
144 *terminal_font_size = (*terminal_font_size - 1.0).max(MIN_FONT_SIZE);
145 }
146 cx.refresh_windows();
147 });
148 });
149 cx.add_global_action(move |_: &ResetBufferFontSize, cx| {
150 cx.update_global::<Settings, _, _>(|settings, cx| {
151 settings.buffer_font_size = settings.default_buffer_font_size;
152 settings.terminal_overrides.font_size = settings.terminal_defaults.font_size;
153 cx.refresh_windows();
154 });
155 });
156 cx.add_global_action(move |_: &InstallCommandLineInterface, cx| {
157 cx.spawn(|cx| async move { install_cli(&cx).await.context("error creating CLI symlink") })
158 .detach_and_log_err(cx);
159 });
160 cx.add_action({
161 let app_state = app_state.clone();
162 move |_: &mut Workspace, _: &OpenSettings, cx: &mut ViewContext<Workspace>| {
163 open_config_file(&paths::SETTINGS, app_state.clone(), cx, || {
164 str::from_utf8(
165 Assets
166 .load("settings/initial_user_settings.json")
167 .unwrap()
168 .as_ref(),
169 )
170 .unwrap()
171 .into()
172 });
173 }
174 });
175 cx.add_action({
176 let app_state = app_state.clone();
177 move |workspace: &mut Workspace, _: &OpenLog, cx: &mut ViewContext<Workspace>| {
178 open_log_file(workspace, app_state.clone(), cx);
179 }
180 });
181 cx.add_action({
182 let app_state = app_state.clone();
183 move |workspace: &mut Workspace, _: &OpenLicenses, cx: &mut ViewContext<Workspace>| {
184 open_bundled_file(
185 workspace,
186 app_state.clone(),
187 "licenses.md",
188 "Open Source License Attribution",
189 "Markdown",
190 cx,
191 );
192 }
193 });
194 cx.add_action({
195 let app_state = app_state.clone();
196 move |workspace: &mut Workspace, _: &OpenTelemetryLog, cx: &mut ViewContext<Workspace>| {
197 open_telemetry_log_file(workspace, app_state.clone(), cx);
198 }
199 });
200 cx.add_action({
201 let app_state = app_state.clone();
202 move |_: &mut Workspace, _: &OpenKeymap, cx: &mut ViewContext<Workspace>| {
203 open_config_file(&paths::KEYMAP, app_state.clone(), cx, Default::default);
204 }
205 });
206 cx.add_action({
207 let app_state = app_state.clone();
208 move |workspace: &mut Workspace, _: &OpenDefaultKeymap, cx: &mut ViewContext<Workspace>| {
209 open_bundled_file(
210 workspace,
211 app_state.clone(),
212 "keymaps/default.json",
213 "Default Key Bindings",
214 "JSON",
215 cx,
216 );
217 }
218 });
219 cx.add_action({
220 let app_state = app_state.clone();
221 move |workspace: &mut Workspace,
222 _: &OpenDefaultSettings,
223 cx: &mut ViewContext<Workspace>| {
224 open_bundled_file(
225 workspace,
226 app_state.clone(),
227 "settings/default.json",
228 "Default Settings",
229 "JSON",
230 cx,
231 );
232 }
233 });
234 cx.add_action(
235 |workspace: &mut Workspace, _: &DebugElements, cx: &mut ViewContext<Workspace>| {
236 let content = to_string_pretty(&cx.debug_elements()).unwrap();
237 let project = workspace.project().clone();
238 let json_language = project
239 .read(cx)
240 .languages()
241 .language_for_name("JSON")
242 .unwrap();
243 if project.read(cx).is_remote() {
244 cx.propagate_action();
245 } else if let Some(buffer) = project
246 .update(cx, |project, cx| {
247 project.create_buffer(&content, Some(json_language), cx)
248 })
249 .log_err()
250 {
251 workspace.add_item(
252 Box::new(
253 cx.add_view(|cx| Editor::for_buffer(buffer, Some(project.clone()), cx)),
254 ),
255 cx,
256 );
257 }
258 },
259 );
260 cx.add_action(
261 |workspace: &mut Workspace,
262 _: &project_panel::ToggleFocus,
263 cx: &mut ViewContext<Workspace>| {
264 workspace.toggle_sidebar_item_focus(SidebarSide::Left, 0, cx);
265 },
266 );
267
268 activity_indicator::init(cx);
269 call::init(app_state.client.clone(), app_state.user_store.clone(), cx);
270 settings::KeymapFileContent::load_defaults(cx);
271}
272
273pub fn initialize_workspace(
274 workspace: &mut Workspace,
275 app_state: &Arc<AppState>,
276 cx: &mut ViewContext<Workspace>,
277) {
278 let workspace_handle = cx.handle();
279 cx.subscribe(&workspace_handle, {
280 move |_, _, event, cx| {
281 if let workspace::Event::PaneAdded(pane) = event {
282 pane.update(cx, |pane, cx| {
283 pane.toolbar().update(cx, |toolbar, cx| {
284 let breadcrumbs = cx.add_view(|_| Breadcrumbs::new());
285 toolbar.add_item(breadcrumbs, cx);
286 let buffer_search_bar = cx.add_view(BufferSearchBar::new);
287 toolbar.add_item(buffer_search_bar, cx);
288 let project_search_bar = cx.add_view(|_| ProjectSearchBar::new());
289 toolbar.add_item(project_search_bar, cx);
290 })
291 });
292 }
293 }
294 })
295 .detach();
296
297 cx.emit(workspace::Event::PaneAdded(workspace.active_pane().clone()));
298 cx.emit(workspace::Event::PaneAdded(workspace.dock_pane().clone()));
299
300 let theme_names = app_state
301 .themes
302 .list(**cx.default_global::<StaffMode>())
303 .map(|meta| meta.name)
304 .collect();
305 let language_names = app_state.languages.language_names();
306
307 workspace.project().update(cx, |project, cx| {
308 let action_names = cx.all_action_names().collect::<Vec<_>>();
309 project.set_language_server_settings(serde_json::json!({
310 "json": {
311 "format": {
312 "enable": true,
313 },
314 "schemas": [
315 {
316 "fileMatch": [schema_file_match(&paths::SETTINGS)],
317 "schema": settings_file_json_schema(theme_names, &language_names),
318 },
319 {
320 "fileMatch": [schema_file_match(&paths::KEYMAP)],
321 "schema": keymap_file_json_schema(&action_names),
322 }
323 ]
324 }
325 }));
326 });
327
328 let collab_titlebar_item =
329 cx.add_view(|cx| CollabTitlebarItem::new(&workspace_handle, &app_state.user_store, cx));
330 workspace.set_titlebar_item(collab_titlebar_item, cx);
331
332 let project_panel = ProjectPanel::new(workspace.project().clone(), cx);
333 workspace.left_sidebar().update(cx, |sidebar, cx| {
334 sidebar.add_item(
335 "icons/folder_tree_16.svg",
336 "Project Panel".to_string(),
337 project_panel,
338 cx,
339 )
340 });
341
342 let diagnostic_summary =
343 cx.add_view(|cx| diagnostics::items::DiagnosticIndicator::new(workspace.project(), cx));
344 let activity_indicator =
345 activity_indicator::ActivityIndicator::new(workspace, app_state.languages.clone(), cx);
346 let cursor_position = cx.add_view(|_| editor::items::CursorPosition::new());
347 let feedback_button = cx.add_view(|_| feedback::feedback_editor::FeedbackButton {});
348 workspace.status_bar().update(cx, |status_bar, cx| {
349 status_bar.add_left_item(diagnostic_summary, cx);
350 status_bar.add_left_item(activity_indicator, cx);
351 status_bar.add_right_item(cursor_position, cx);
352 status_bar.add_right_item(feedback_button, cx);
353 });
354
355 auto_update::notify_of_any_new_update(cx.weak_handle(), cx);
356
357 let window_id = cx.window_id();
358 vim::observe_keypresses(window_id, cx);
359
360 cx.on_window_should_close(|workspace, cx| {
361 if let Some(task) = workspace.close(&Default::default(), cx) {
362 task.detach_and_log_err(cx);
363 }
364 false
365 });
366}
367
368pub fn build_window_options(
369 bounds: Option<WindowBounds>,
370 display: Option<Uuid>,
371 platform: &dyn Platform,
372) -> WindowOptions<'static> {
373 let bounds = bounds
374 .or_else(|| {
375 ZED_WINDOW_POSITION
376 .zip(*ZED_WINDOW_SIZE)
377 .map(|(position, size)| WindowBounds::Fixed(RectF::new(position, size)))
378 })
379 .unwrap_or(WindowBounds::Maximized);
380
381 let screen = display.and_then(|display| platform.screen_by_id(display));
382
383 WindowOptions {
384 titlebar: Some(TitlebarOptions {
385 title: None,
386 appears_transparent: true,
387 traffic_light_position: Some(vec2f(8., 8.)),
388 }),
389 center: false,
390 focus: true,
391 kind: WindowKind::Normal,
392 is_movable: true,
393 bounds,
394 screen,
395 }
396}
397
398fn quit(_: &Quit, cx: &mut gpui::MutableAppContext) {
399 let mut workspaces = cx
400 .window_ids()
401 .filter_map(|window_id| cx.root_view::<Workspace>(window_id))
402 .collect::<Vec<_>>();
403
404 // If multiple windows have unsaved changes, and need a save prompt,
405 // prompt in the active window before switching to a different window.
406 workspaces.sort_by_key(|workspace| !cx.window_is_active(workspace.window_id()));
407
408 let should_confirm = cx.global::<Settings>().confirm_quit;
409 cx.spawn(|mut cx| async move {
410 if let (true, Some(workspace)) = (should_confirm, workspaces.first()) {
411 let answer = cx
412 .prompt(
413 workspace.window_id(),
414 PromptLevel::Info,
415 "Are you sure you want to quit?",
416 &["Quit", "Cancel"],
417 )
418 .next()
419 .await;
420 if answer != Some(0) {
421 return Ok(());
422 }
423 }
424
425 // If the user cancels any save prompt, then keep the app open.
426 for workspace in workspaces {
427 if !workspace
428 .update(&mut cx, |workspace, cx| {
429 workspace.prepare_to_close(true, cx)
430 })
431 .await?
432 {
433 return Ok(());
434 }
435 }
436 cx.platform().quit();
437 anyhow::Ok(())
438 })
439 .detach_and_log_err(cx);
440}
441
442fn about(_: &mut Workspace, _: &About, cx: &mut gpui::ViewContext<Workspace>) {
443 let app_name = cx.global::<ReleaseChannel>().display_name();
444 let version = env!("CARGO_PKG_VERSION");
445 cx.prompt(
446 gpui::PromptLevel::Info,
447 &format!("{app_name} {version}"),
448 &["OK"],
449 );
450}
451
452async fn install_cli(cx: &AsyncAppContext) -> Result<()> {
453 let cli_path = cx.platform().path_for_auxiliary_executable("cli")?;
454 let link_path = Path::new("/usr/local/bin/zed");
455 let bin_dir_path = link_path.parent().unwrap();
456
457 // Don't re-create symlink if it points to the same CLI binary.
458 if smol::fs::read_link(link_path).await.ok().as_ref() == Some(&cli_path) {
459 return Ok(());
460 }
461
462 // If the symlink is not there or is outdated, first try replacing it
463 // without escalating.
464 smol::fs::remove_file(link_path).await.log_err();
465 if smol::fs::unix::symlink(&cli_path, link_path)
466 .await
467 .log_err()
468 .is_some()
469 {
470 return Ok(());
471 }
472
473 // The symlink could not be created, so use osascript with admin privileges
474 // to create it.
475 let status = smol::process::Command::new("osascript")
476 .args([
477 "-e",
478 &format!(
479 "do shell script \" \
480 mkdir -p \'{}\' && \
481 ln -sf \'{}\' \'{}\' \
482 \" with administrator privileges",
483 bin_dir_path.to_string_lossy(),
484 cli_path.to_string_lossy(),
485 link_path.to_string_lossy(),
486 ),
487 ])
488 .stdout(smol::process::Stdio::inherit())
489 .stderr(smol::process::Stdio::inherit())
490 .output()
491 .await?
492 .status;
493 if status.success() {
494 Ok(())
495 } else {
496 Err(anyhow!("error running osascript"))
497 }
498}
499
500fn open_config_file(
501 path: &'static Path,
502 app_state: Arc<AppState>,
503 cx: &mut ViewContext<Workspace>,
504 default_content: impl 'static + Send + FnOnce() -> Rope,
505) {
506 cx.spawn(|workspace, mut cx| async move {
507 let fs = &app_state.fs;
508 if !fs.is_file(path).await {
509 fs.create_file(path, Default::default()).await?;
510 fs.save(path, &default_content(), Default::default())
511 .await?;
512 }
513
514 workspace
515 .update(&mut cx, |workspace, cx| {
516 workspace.with_local_workspace(&app_state, cx, |workspace, cx| {
517 workspace.open_paths(vec![path.to_path_buf()], false, cx)
518 })
519 })
520 .await
521 .await;
522 Ok::<_, anyhow::Error>(())
523 })
524 .detach_and_log_err(cx)
525}
526
527fn open_log_file(
528 workspace: &mut Workspace,
529 app_state: Arc<AppState>,
530 cx: &mut ViewContext<Workspace>,
531) {
532 const MAX_LINES: usize = 1000;
533
534 workspace
535 .with_local_workspace(&app_state.clone(), cx, move |_, cx| {
536 cx.spawn_weak(|workspace, mut cx| async move {
537 let (old_log, new_log) = futures::join!(
538 app_state.fs.load(&paths::OLD_LOG),
539 app_state.fs.load(&paths::LOG)
540 );
541
542 if let Some(workspace) = workspace.upgrade(&cx) {
543 let mut lines = VecDeque::with_capacity(MAX_LINES);
544 for line in old_log
545 .iter()
546 .flat_map(|log| log.lines())
547 .chain(new_log.iter().flat_map(|log| log.lines()))
548 {
549 if lines.len() == MAX_LINES {
550 lines.pop_front();
551 }
552 lines.push_back(line);
553 }
554 let log = lines
555 .into_iter()
556 .flat_map(|line| [line, "\n"])
557 .collect::<String>();
558
559 workspace.update(&mut cx, |workspace, cx| {
560 let project = workspace.project().clone();
561 let buffer = project
562 .update(cx, |project, cx| project.create_buffer("", None, cx))
563 .expect("creating buffers on a local workspace always succeeds");
564 buffer.update(cx, |buffer, cx| buffer.edit([(0..0, log)], None, cx));
565
566 let buffer = cx.add_model(|cx| {
567 MultiBuffer::singleton(buffer, cx).with_title("Log".into())
568 });
569 workspace.add_item(
570 Box::new(
571 cx.add_view(|cx| {
572 Editor::for_multibuffer(buffer, Some(project), cx)
573 }),
574 ),
575 cx,
576 );
577 });
578 }
579 })
580 .detach();
581 })
582 .detach();
583}
584
585fn open_telemetry_log_file(
586 workspace: &mut Workspace,
587 app_state: Arc<AppState>,
588 cx: &mut ViewContext<Workspace>,
589) {
590 workspace.with_local_workspace(&app_state.clone(), cx, move |_, cx| {
591 cx.spawn_weak(|workspace, mut cx| async move {
592 let workspace = workspace.upgrade(&cx)?;
593 let path = app_state.client.telemetry_log_file_path()?;
594 let log = app_state.fs.load(&path).await.log_err()?;
595
596 const MAX_TELEMETRY_LOG_LEN: usize = 5 * 1024 * 1024;
597 let mut start_offset = log.len().saturating_sub(MAX_TELEMETRY_LOG_LEN);
598 if let Some(newline_offset) = log[start_offset..].find('\n') {
599 start_offset += newline_offset + 1;
600 }
601 let log_suffix = &log[start_offset..];
602
603 workspace.update(&mut cx, |workspace, cx| {
604 let project = workspace.project().clone();
605 let buffer = project
606 .update(cx, |project, cx| project.create_buffer("", None, cx))
607 .expect("creating buffers on a local workspace always succeeds");
608 buffer.update(cx, |buffer, cx| {
609 buffer.set_language(app_state.languages.language_for_name("JSON"), cx);
610 buffer.edit(
611 [(
612 0..0,
613 concat!(
614 "// Zed collects anonymous usage data to help us understand how people are using the app.\n",
615 "// Telemetry can be disabled via the `settings.json` file.\n",
616 "// Here is the data that has been reported for the current session:\n",
617 "\n"
618 ),
619 )],
620 None,
621 cx,
622 );
623 buffer.edit([(buffer.len()..buffer.len(), log_suffix)], None, cx);
624 });
625
626 let buffer = cx.add_model(|cx| {
627 MultiBuffer::singleton(buffer, cx).with_title("Telemetry Log".into())
628 });
629 workspace.add_item(
630 Box::new(cx.add_view(|cx| Editor::for_multibuffer(buffer, Some(project), cx))),
631 cx,
632 );
633 });
634
635 Some(())
636 })
637 .detach();
638 }).detach();
639}
640
641fn open_bundled_file(
642 workspace: &mut Workspace,
643 app_state: Arc<AppState>,
644 asset_path: &'static str,
645 title: &'static str,
646 language: &'static str,
647 cx: &mut ViewContext<Workspace>,
648) {
649 workspace
650 .with_local_workspace(&app_state, cx, |workspace, cx| {
651 let project = workspace.project().clone();
652 let buffer = project.update(cx, |project, cx| {
653 let text = Assets::get(asset_path)
654 .map(|f| f.data)
655 .unwrap_or_else(|| Cow::Borrowed(b"File not found"));
656 let text = str::from_utf8(text.as_ref()).unwrap();
657 project
658 .create_buffer(text, project.languages().language_for_name(language), cx)
659 .expect("creating buffers on a local workspace always succeeds")
660 });
661 let buffer =
662 cx.add_model(|cx| MultiBuffer::singleton(buffer, cx).with_title(title.into()));
663 workspace.add_item(
664 Box::new(
665 cx.add_view(|cx| Editor::for_multibuffer(buffer, Some(project.clone()), cx)),
666 ),
667 cx,
668 );
669 })
670 .detach();
671}
672
673fn schema_file_match(path: &Path) -> &Path {
674 path.strip_prefix(path.parent().unwrap().parent().unwrap())
675 .unwrap()
676}
677
678fn parse_pixel_position_env_var(value: &str) -> Option<Vector2F> {
679 let mut parts = value.split(',');
680 let width: usize = parts.next()?.parse().ok()?;
681 let height: usize = parts.next()?.parse().ok()?;
682 Some(vec2f(width as f32, height as f32))
683}
684
685#[cfg(test)]
686mod tests {
687 use super::*;
688 use assets::Assets;
689 use editor::{scroll::autoscroll::Autoscroll, DisplayPoint, Editor};
690 use gpui::{
691 executor::Deterministic, AssetSource, MutableAppContext, TestAppContext, ViewHandle,
692 };
693 use project::{Project, ProjectPath};
694 use serde_json::json;
695 use std::{
696 collections::HashSet,
697 path::{Path, PathBuf},
698 };
699 use theme::ThemeRegistry;
700 use workspace::{
701 item::{Item, ItemHandle},
702 open_new, open_paths, pane, NewFile, Pane, SplitDirection, WorkspaceHandle,
703 };
704
705 #[gpui::test]
706 async fn test_open_paths_action(cx: &mut TestAppContext) {
707 let app_state = init(cx);
708 app_state
709 .fs
710 .as_fake()
711 .insert_tree(
712 "/root",
713 json!({
714 "a": {
715 "aa": null,
716 "ab": null,
717 },
718 "b": {
719 "ba": null,
720 "bb": null,
721 },
722 "c": {
723 "ca": null,
724 "cb": null,
725 },
726 }),
727 )
728 .await;
729
730 cx.update(|cx| {
731 open_paths(
732 &[PathBuf::from("/root/a"), PathBuf::from("/root/b")],
733 &app_state,
734 cx,
735 )
736 })
737 .await;
738 assert_eq!(cx.window_ids().len(), 1);
739
740 cx.update(|cx| open_paths(&[PathBuf::from("/root/a")], &app_state, cx))
741 .await;
742 assert_eq!(cx.window_ids().len(), 1);
743 let workspace_1 = cx.root_view::<Workspace>(cx.window_ids()[0]).unwrap();
744 workspace_1.update(cx, |workspace, cx| {
745 assert_eq!(workspace.worktrees(cx).count(), 2);
746 assert!(workspace.left_sidebar().read(cx).is_open());
747 assert!(workspace.active_pane().is_focused(cx));
748 });
749
750 cx.update(|cx| {
751 open_paths(
752 &[PathBuf::from("/root/b"), PathBuf::from("/root/c")],
753 &app_state,
754 cx,
755 )
756 })
757 .await;
758 assert_eq!(cx.window_ids().len(), 2);
759 }
760
761 #[gpui::test]
762 async fn test_window_edit_state(executor: Arc<Deterministic>, cx: &mut TestAppContext) {
763 let app_state = init(cx);
764 app_state
765 .fs
766 .as_fake()
767 .insert_tree("/root", json!({"a": "hey"}))
768 .await;
769
770 cx.update(|cx| open_paths(&[PathBuf::from("/root/a")], &app_state, cx))
771 .await;
772 assert_eq!(cx.window_ids().len(), 1);
773
774 // When opening the workspace, the window is not in a edited state.
775 let workspace = cx.root_view::<Workspace>(cx.window_ids()[0]).unwrap();
776 let editor = workspace.read_with(cx, |workspace, cx| {
777 workspace
778 .active_item(cx)
779 .unwrap()
780 .downcast::<Editor>()
781 .unwrap()
782 });
783 assert!(!cx.is_window_edited(workspace.window_id()));
784
785 // Editing a buffer marks the window as edited.
786 editor.update(cx, |editor, cx| editor.insert("EDIT", cx));
787 assert!(cx.is_window_edited(workspace.window_id()));
788
789 // Undoing the edit restores the window's edited state.
790 editor.update(cx, |editor, cx| editor.undo(&Default::default(), cx));
791 assert!(!cx.is_window_edited(workspace.window_id()));
792
793 // Redoing the edit marks the window as edited again.
794 editor.update(cx, |editor, cx| editor.redo(&Default::default(), cx));
795 assert!(cx.is_window_edited(workspace.window_id()));
796
797 // Closing the item restores the window's edited state.
798 let close = workspace.update(cx, |workspace, cx| {
799 drop(editor);
800 Pane::close_active_item(workspace, &Default::default(), cx).unwrap()
801 });
802 executor.run_until_parked();
803 cx.simulate_prompt_answer(workspace.window_id(), 1);
804 close.await.unwrap();
805 assert!(!cx.is_window_edited(workspace.window_id()));
806
807 // Opening the buffer again doesn't impact the window's edited state.
808 cx.update(|cx| open_paths(&[PathBuf::from("/root/a")], &app_state, cx))
809 .await;
810 let editor = workspace.read_with(cx, |workspace, cx| {
811 workspace
812 .active_item(cx)
813 .unwrap()
814 .downcast::<Editor>()
815 .unwrap()
816 });
817 assert!(!cx.is_window_edited(workspace.window_id()));
818
819 // Editing the buffer marks the window as edited.
820 editor.update(cx, |editor, cx| editor.insert("EDIT", cx));
821 assert!(cx.is_window_edited(workspace.window_id()));
822
823 // Ensure closing the window via the mouse gets preempted due to the
824 // buffer having unsaved changes.
825 assert!(!cx.simulate_window_close(workspace.window_id()));
826 executor.run_until_parked();
827 assert_eq!(cx.window_ids().len(), 1);
828
829 // The window is successfully closed after the user dismisses the prompt.
830 cx.simulate_prompt_answer(workspace.window_id(), 1);
831 executor.run_until_parked();
832 assert_eq!(cx.window_ids().len(), 0);
833 }
834
835 #[gpui::test]
836 async fn test_new_empty_workspace(cx: &mut TestAppContext) {
837 let app_state = init(cx);
838 cx.update(|cx| open_new(&app_state, cx)).await;
839
840 let window_id = *cx.window_ids().first().unwrap();
841 let workspace = cx.root_view::<Workspace>(window_id).unwrap();
842 let editor = workspace.update(cx, |workspace, cx| {
843 workspace
844 .active_item(cx)
845 .unwrap()
846 .downcast::<editor::Editor>()
847 .unwrap()
848 });
849
850 editor.update(cx, |editor, cx| {
851 assert!(editor.text(cx).is_empty());
852 });
853
854 let save_task = workspace.update(cx, |workspace, cx| workspace.save_active_item(false, cx));
855 app_state.fs.create_dir(Path::new("/root")).await.unwrap();
856 cx.simulate_new_path_selection(|_| Some(PathBuf::from("/root/the-new-name")));
857 save_task.await.unwrap();
858 editor.read_with(cx, |editor, cx| {
859 assert!(!editor.is_dirty(cx));
860 assert_eq!(editor.title(cx), "the-new-name");
861 });
862 }
863
864 #[gpui::test]
865 async fn test_open_entry(cx: &mut TestAppContext) {
866 let app_state = init(cx);
867 app_state
868 .fs
869 .as_fake()
870 .insert_tree(
871 "/root",
872 json!({
873 "a": {
874 "file1": "contents 1",
875 "file2": "contents 2",
876 "file3": "contents 3",
877 },
878 }),
879 )
880 .await;
881
882 let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
883 let (_, workspace) = cx.add_window(|cx| {
884 Workspace::new(Default::default(), 0, project, |_, _| unimplemented!(), cx)
885 });
886
887 let entries = cx.read(|cx| workspace.file_project_paths(cx));
888 let file1 = entries[0].clone();
889 let file2 = entries[1].clone();
890 let file3 = entries[2].clone();
891
892 // Open the first entry
893 let entry_1 = workspace
894 .update(cx, |w, cx| w.open_path(file1.clone(), None, true, cx))
895 .await
896 .unwrap();
897 cx.read(|cx| {
898 let pane = workspace.read(cx).active_pane().read(cx);
899 assert_eq!(
900 pane.active_item().unwrap().project_path(cx),
901 Some(file1.clone())
902 );
903 assert_eq!(pane.items_len(), 1);
904 });
905
906 // Open the second entry
907 workspace
908 .update(cx, |w, cx| w.open_path(file2.clone(), None, true, cx))
909 .await
910 .unwrap();
911 cx.read(|cx| {
912 let pane = workspace.read(cx).active_pane().read(cx);
913 assert_eq!(
914 pane.active_item().unwrap().project_path(cx),
915 Some(file2.clone())
916 );
917 assert_eq!(pane.items_len(), 2);
918 });
919
920 // Open the first entry again. The existing pane item is activated.
921 let entry_1b = workspace
922 .update(cx, |w, cx| w.open_path(file1.clone(), None, true, cx))
923 .await
924 .unwrap();
925 assert_eq!(entry_1.id(), entry_1b.id());
926
927 cx.read(|cx| {
928 let pane = workspace.read(cx).active_pane().read(cx);
929 assert_eq!(
930 pane.active_item().unwrap().project_path(cx),
931 Some(file1.clone())
932 );
933 assert_eq!(pane.items_len(), 2);
934 });
935
936 // Split the pane with the first entry, then open the second entry again.
937 workspace
938 .update(cx, |w, cx| {
939 w.split_pane(w.active_pane().clone(), SplitDirection::Right, cx);
940 w.open_path(file2.clone(), None, true, cx)
941 })
942 .await
943 .unwrap();
944
945 workspace.read_with(cx, |w, cx| {
946 assert_eq!(
947 w.active_pane()
948 .read(cx)
949 .active_item()
950 .unwrap()
951 .project_path(cx.as_ref()),
952 Some(file2.clone())
953 );
954 });
955
956 // Open the third entry twice concurrently. Only one pane item is added.
957 let (t1, t2) = workspace.update(cx, |w, cx| {
958 (
959 w.open_path(file3.clone(), None, true, cx),
960 w.open_path(file3.clone(), None, true, cx),
961 )
962 });
963 t1.await.unwrap();
964 t2.await.unwrap();
965 cx.read(|cx| {
966 let pane = workspace.read(cx).active_pane().read(cx);
967 assert_eq!(
968 pane.active_item().unwrap().project_path(cx),
969 Some(file3.clone())
970 );
971 let pane_entries = pane
972 .items()
973 .map(|i| i.project_path(cx).unwrap())
974 .collect::<Vec<_>>();
975 assert_eq!(pane_entries, &[file1, file2, file3]);
976 });
977 }
978
979 #[gpui::test]
980 async fn test_open_paths(cx: &mut TestAppContext) {
981 let app_state = init(cx);
982
983 app_state
984 .fs
985 .as_fake()
986 .insert_tree(
987 "/",
988 json!({
989 "dir1": {
990 "a.txt": ""
991 },
992 "dir2": {
993 "b.txt": ""
994 },
995 "dir3": {
996 "c.txt": ""
997 },
998 "d.txt": ""
999 }),
1000 )
1001 .await;
1002
1003 let project = Project::test(app_state.fs.clone(), ["/dir1".as_ref()], cx).await;
1004 let (_, workspace) = cx.add_window(|cx| {
1005 Workspace::new(Default::default(), 0, project, |_, _| unimplemented!(), cx)
1006 });
1007
1008 // Open a file within an existing worktree.
1009 cx.update(|cx| {
1010 workspace.update(cx, |view, cx| {
1011 view.open_paths(vec!["/dir1/a.txt".into()], true, cx)
1012 })
1013 })
1014 .await;
1015 cx.read(|cx| {
1016 assert_eq!(
1017 workspace
1018 .read(cx)
1019 .active_pane()
1020 .read(cx)
1021 .active_item()
1022 .unwrap()
1023 .to_any()
1024 .downcast::<Editor>()
1025 .unwrap()
1026 .read(cx)
1027 .title(cx),
1028 "a.txt"
1029 );
1030 });
1031
1032 // Open a file outside of any existing worktree.
1033 cx.update(|cx| {
1034 workspace.update(cx, |view, cx| {
1035 view.open_paths(vec!["/dir2/b.txt".into()], true, cx)
1036 })
1037 })
1038 .await;
1039 cx.read(|cx| {
1040 let worktree_roots = workspace
1041 .read(cx)
1042 .worktrees(cx)
1043 .map(|w| w.read(cx).as_local().unwrap().abs_path().as_ref())
1044 .collect::<HashSet<_>>();
1045 assert_eq!(
1046 worktree_roots,
1047 vec!["/dir1", "/dir2/b.txt"]
1048 .into_iter()
1049 .map(Path::new)
1050 .collect(),
1051 );
1052 assert_eq!(
1053 workspace
1054 .read(cx)
1055 .active_pane()
1056 .read(cx)
1057 .active_item()
1058 .unwrap()
1059 .to_any()
1060 .downcast::<Editor>()
1061 .unwrap()
1062 .read(cx)
1063 .title(cx),
1064 "b.txt"
1065 );
1066 });
1067
1068 // Ensure opening a directory and one of its children only adds one worktree.
1069 cx.update(|cx| {
1070 workspace.update(cx, |view, cx| {
1071 view.open_paths(vec!["/dir3".into(), "/dir3/c.txt".into()], true, cx)
1072 })
1073 })
1074 .await;
1075 cx.read(|cx| {
1076 let worktree_roots = workspace
1077 .read(cx)
1078 .worktrees(cx)
1079 .map(|w| w.read(cx).as_local().unwrap().abs_path().as_ref())
1080 .collect::<HashSet<_>>();
1081 assert_eq!(
1082 worktree_roots,
1083 vec!["/dir1", "/dir2/b.txt", "/dir3"]
1084 .into_iter()
1085 .map(Path::new)
1086 .collect(),
1087 );
1088 assert_eq!(
1089 workspace
1090 .read(cx)
1091 .active_pane()
1092 .read(cx)
1093 .active_item()
1094 .unwrap()
1095 .to_any()
1096 .downcast::<Editor>()
1097 .unwrap()
1098 .read(cx)
1099 .title(cx),
1100 "c.txt"
1101 );
1102 });
1103
1104 // Ensure opening invisibly a file outside an existing worktree adds a new, invisible worktree.
1105 cx.update(|cx| {
1106 workspace.update(cx, |view, cx| {
1107 view.open_paths(vec!["/d.txt".into()], false, cx)
1108 })
1109 })
1110 .await;
1111 cx.read(|cx| {
1112 let worktree_roots = workspace
1113 .read(cx)
1114 .worktrees(cx)
1115 .map(|w| w.read(cx).as_local().unwrap().abs_path().as_ref())
1116 .collect::<HashSet<_>>();
1117 assert_eq!(
1118 worktree_roots,
1119 vec!["/dir1", "/dir2/b.txt", "/dir3", "/d.txt"]
1120 .into_iter()
1121 .map(Path::new)
1122 .collect(),
1123 );
1124
1125 let visible_worktree_roots = workspace
1126 .read(cx)
1127 .visible_worktrees(cx)
1128 .map(|w| w.read(cx).as_local().unwrap().abs_path().as_ref())
1129 .collect::<HashSet<_>>();
1130 assert_eq!(
1131 visible_worktree_roots,
1132 vec!["/dir1", "/dir2/b.txt", "/dir3"]
1133 .into_iter()
1134 .map(Path::new)
1135 .collect(),
1136 );
1137
1138 assert_eq!(
1139 workspace
1140 .read(cx)
1141 .active_pane()
1142 .read(cx)
1143 .active_item()
1144 .unwrap()
1145 .to_any()
1146 .downcast::<Editor>()
1147 .unwrap()
1148 .read(cx)
1149 .title(cx),
1150 "d.txt"
1151 );
1152 });
1153 }
1154
1155 #[gpui::test]
1156 async fn test_save_conflicting_item(cx: &mut TestAppContext) {
1157 let app_state = init(cx);
1158 app_state
1159 .fs
1160 .as_fake()
1161 .insert_tree("/root", json!({ "a.txt": "" }))
1162 .await;
1163
1164 let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
1165 let (window_id, workspace) = cx.add_window(|cx| {
1166 Workspace::new(Default::default(), 0, project, |_, _| unimplemented!(), cx)
1167 });
1168
1169 // Open a file within an existing worktree.
1170 cx.update(|cx| {
1171 workspace.update(cx, |view, cx| {
1172 view.open_paths(vec![PathBuf::from("/root/a.txt")], true, cx)
1173 })
1174 })
1175 .await;
1176 let editor = cx.read(|cx| {
1177 let pane = workspace.read(cx).active_pane().read(cx);
1178 let item = pane.active_item().unwrap();
1179 item.downcast::<Editor>().unwrap()
1180 });
1181
1182 cx.update(|cx| editor.update(cx, |editor, cx| editor.handle_input("x", cx)));
1183 app_state
1184 .fs
1185 .as_fake()
1186 .insert_file("/root/a.txt", "changed".to_string())
1187 .await;
1188 editor
1189 .condition(cx, |editor, cx| editor.has_conflict(cx))
1190 .await;
1191 cx.read(|cx| assert!(editor.is_dirty(cx)));
1192
1193 let save_task = workspace.update(cx, |workspace, cx| workspace.save_active_item(false, cx));
1194 cx.simulate_prompt_answer(window_id, 0);
1195 save_task.await.unwrap();
1196 editor.read_with(cx, |editor, cx| {
1197 assert!(!editor.is_dirty(cx));
1198 assert!(!editor.has_conflict(cx));
1199 });
1200 }
1201
1202 #[gpui::test]
1203 async fn test_open_and_save_new_file(cx: &mut TestAppContext) {
1204 let app_state = init(cx);
1205 app_state.fs.create_dir(Path::new("/root")).await.unwrap();
1206
1207 let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
1208 project.update(cx, |project, _| project.languages().add(rust_lang()));
1209 let (window_id, workspace) = cx.add_window(|cx| {
1210 Workspace::new(Default::default(), 0, project, |_, _| unimplemented!(), cx)
1211 });
1212 let worktree = cx.read(|cx| workspace.read(cx).worktrees(cx).next().unwrap());
1213
1214 // Create a new untitled buffer
1215 cx.dispatch_action(window_id, NewFile);
1216 let editor = workspace.read_with(cx, |workspace, cx| {
1217 workspace
1218 .active_item(cx)
1219 .unwrap()
1220 .downcast::<Editor>()
1221 .unwrap()
1222 });
1223
1224 editor.update(cx, |editor, cx| {
1225 assert!(!editor.is_dirty(cx));
1226 assert_eq!(editor.title(cx), "untitled");
1227 assert!(Arc::ptr_eq(
1228 &editor.language_at(0, cx).unwrap(),
1229 &languages::PLAIN_TEXT
1230 ));
1231 editor.handle_input("hi", cx);
1232 assert!(editor.is_dirty(cx));
1233 });
1234
1235 // Save the buffer. This prompts for a filename.
1236 let save_task = workspace.update(cx, |workspace, cx| workspace.save_active_item(false, cx));
1237 cx.simulate_new_path_selection(|parent_dir| {
1238 assert_eq!(parent_dir, Path::new("/root"));
1239 Some(parent_dir.join("the-new-name.rs"))
1240 });
1241 cx.read(|cx| {
1242 assert!(editor.is_dirty(cx));
1243 assert_eq!(editor.read(cx).title(cx), "untitled");
1244 });
1245
1246 // When the save completes, the buffer's title is updated and the language is assigned based
1247 // on the path.
1248 save_task.await.unwrap();
1249 editor.read_with(cx, |editor, cx| {
1250 assert!(!editor.is_dirty(cx));
1251 assert_eq!(editor.title(cx), "the-new-name.rs");
1252 assert_eq!(editor.language_at(0, cx).unwrap().name().as_ref(), "Rust");
1253 });
1254
1255 // Edit the file and save it again. This time, there is no filename prompt.
1256 editor.update(cx, |editor, cx| {
1257 editor.handle_input(" there", cx);
1258 assert!(editor.is_dirty(cx.as_ref()));
1259 });
1260 let save_task = workspace.update(cx, |workspace, cx| workspace.save_active_item(false, cx));
1261 save_task.await.unwrap();
1262 assert!(!cx.did_prompt_for_new_path());
1263 editor.read_with(cx, |editor, cx| {
1264 assert!(!editor.is_dirty(cx));
1265 assert_eq!(editor.title(cx), "the-new-name.rs")
1266 });
1267
1268 // Open the same newly-created file in another pane item. The new editor should reuse
1269 // the same buffer.
1270 cx.dispatch_action(window_id, NewFile);
1271 workspace
1272 .update(cx, |workspace, cx| {
1273 workspace.split_pane(workspace.active_pane().clone(), SplitDirection::Right, cx);
1274 workspace.open_path((worktree.read(cx).id(), "the-new-name.rs"), None, true, cx)
1275 })
1276 .await
1277 .unwrap();
1278 let editor2 = workspace.update(cx, |workspace, cx| {
1279 workspace
1280 .active_item(cx)
1281 .unwrap()
1282 .downcast::<Editor>()
1283 .unwrap()
1284 });
1285 cx.read(|cx| {
1286 assert_eq!(
1287 editor2.read(cx).buffer().read(cx).as_singleton().unwrap(),
1288 editor.read(cx).buffer().read(cx).as_singleton().unwrap()
1289 );
1290 })
1291 }
1292
1293 #[gpui::test]
1294 async fn test_setting_language_when_saving_as_single_file_worktree(cx: &mut TestAppContext) {
1295 let app_state = init(cx);
1296 app_state.fs.create_dir(Path::new("/root")).await.unwrap();
1297
1298 let project = Project::test(app_state.fs.clone(), [], cx).await;
1299 project.update(cx, |project, _| project.languages().add(rust_lang()));
1300 let (window_id, workspace) = cx.add_window(|cx| {
1301 Workspace::new(Default::default(), 0, project, |_, _| unimplemented!(), cx)
1302 });
1303
1304 // Create a new untitled buffer
1305 cx.dispatch_action(window_id, NewFile);
1306 let editor = workspace.read_with(cx, |workspace, cx| {
1307 workspace
1308 .active_item(cx)
1309 .unwrap()
1310 .downcast::<Editor>()
1311 .unwrap()
1312 });
1313
1314 editor.update(cx, |editor, cx| {
1315 assert!(Arc::ptr_eq(
1316 &editor.language_at(0, cx).unwrap(),
1317 &languages::PLAIN_TEXT
1318 ));
1319 editor.handle_input("hi", cx);
1320 assert!(editor.is_dirty(cx.as_ref()));
1321 });
1322
1323 // Save the buffer. This prompts for a filename.
1324 let save_task = workspace.update(cx, |workspace, cx| workspace.save_active_item(false, cx));
1325 cx.simulate_new_path_selection(|_| Some(PathBuf::from("/root/the-new-name.rs")));
1326 save_task.await.unwrap();
1327 // The buffer is not dirty anymore and the language is assigned based on the path.
1328 editor.read_with(cx, |editor, cx| {
1329 assert!(!editor.is_dirty(cx));
1330 assert_eq!(editor.language_at(0, cx).unwrap().name().as_ref(), "Rust")
1331 });
1332 }
1333
1334 #[gpui::test]
1335 async fn test_pane_actions(cx: &mut TestAppContext) {
1336 init(cx);
1337
1338 let app_state = cx.update(AppState::test);
1339 app_state
1340 .fs
1341 .as_fake()
1342 .insert_tree(
1343 "/root",
1344 json!({
1345 "a": {
1346 "file1": "contents 1",
1347 "file2": "contents 2",
1348 "file3": "contents 3",
1349 },
1350 }),
1351 )
1352 .await;
1353
1354 let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
1355 let (window_id, workspace) = cx.add_window(|cx| {
1356 Workspace::new(Default::default(), 0, project, |_, _| unimplemented!(), cx)
1357 });
1358
1359 let entries = cx.read(|cx| workspace.file_project_paths(cx));
1360 let file1 = entries[0].clone();
1361
1362 let pane_1 = cx.read(|cx| workspace.read(cx).active_pane().clone());
1363
1364 workspace
1365 .update(cx, |w, cx| w.open_path(file1.clone(), None, true, cx))
1366 .await
1367 .unwrap();
1368
1369 let (editor_1, buffer) = pane_1.update(cx, |pane_1, cx| {
1370 let editor = pane_1.active_item().unwrap().downcast::<Editor>().unwrap();
1371 assert_eq!(editor.project_path(cx), Some(file1.clone()));
1372 let buffer = editor.update(cx, |editor, cx| {
1373 editor.insert("dirt", cx);
1374 editor.buffer().downgrade()
1375 });
1376 (editor.downgrade(), buffer)
1377 });
1378
1379 cx.dispatch_action(window_id, pane::SplitRight);
1380 let editor_2 = cx.update(|cx| {
1381 let pane_2 = workspace.read(cx).active_pane().clone();
1382 assert_ne!(pane_1, pane_2);
1383
1384 let pane2_item = pane_2.read(cx).active_item().unwrap();
1385 assert_eq!(pane2_item.project_path(cx.as_ref()), Some(file1.clone()));
1386
1387 pane2_item.downcast::<Editor>().unwrap().downgrade()
1388 });
1389 cx.dispatch_action(window_id, workspace::CloseActiveItem);
1390
1391 cx.foreground().run_until_parked();
1392 workspace.read_with(cx, |workspace, _| {
1393 assert_eq!(workspace.panes().len(), 2); //Center pane + Dock pane
1394 assert_eq!(workspace.active_pane(), &pane_1);
1395 });
1396
1397 cx.dispatch_action(window_id, workspace::CloseActiveItem);
1398 cx.foreground().run_until_parked();
1399 cx.simulate_prompt_answer(window_id, 1);
1400 cx.foreground().run_until_parked();
1401
1402 workspace.read_with(cx, |workspace, cx| {
1403 assert_eq!(workspace.panes().len(), 2);
1404 assert!(workspace.active_item(cx).is_none());
1405 });
1406
1407 cx.assert_dropped(editor_1);
1408 cx.assert_dropped(editor_2);
1409 cx.assert_dropped(buffer);
1410 }
1411
1412 #[gpui::test]
1413 async fn test_navigation(cx: &mut TestAppContext) {
1414 let app_state = init(cx);
1415 app_state
1416 .fs
1417 .as_fake()
1418 .insert_tree(
1419 "/root",
1420 json!({
1421 "a": {
1422 "file1": "contents 1\n".repeat(20),
1423 "file2": "contents 2\n".repeat(20),
1424 "file3": "contents 3\n".repeat(20),
1425 },
1426 }),
1427 )
1428 .await;
1429
1430 let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
1431 let (_, workspace) = cx.add_window(|cx| {
1432 Workspace::new(
1433 Default::default(),
1434 0,
1435 project.clone(),
1436 |_, _| unimplemented!(),
1437 cx,
1438 )
1439 });
1440
1441 let entries = cx.read(|cx| workspace.file_project_paths(cx));
1442 let file1 = entries[0].clone();
1443 let file2 = entries[1].clone();
1444 let file3 = entries[2].clone();
1445
1446 let editor1 = workspace
1447 .update(cx, |w, cx| w.open_path(file1.clone(), None, true, cx))
1448 .await
1449 .unwrap()
1450 .downcast::<Editor>()
1451 .unwrap();
1452 editor1.update(cx, |editor, cx| {
1453 editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
1454 s.select_display_ranges([DisplayPoint::new(10, 0)..DisplayPoint::new(10, 0)])
1455 });
1456 });
1457 let editor2 = workspace
1458 .update(cx, |w, cx| w.open_path(file2.clone(), None, true, cx))
1459 .await
1460 .unwrap()
1461 .downcast::<Editor>()
1462 .unwrap();
1463 let editor3 = workspace
1464 .update(cx, |w, cx| w.open_path(file3.clone(), None, true, cx))
1465 .await
1466 .unwrap()
1467 .downcast::<Editor>()
1468 .unwrap();
1469
1470 editor3
1471 .update(cx, |editor, cx| {
1472 editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
1473 s.select_display_ranges([DisplayPoint::new(12, 0)..DisplayPoint::new(12, 0)])
1474 });
1475 editor.newline(&Default::default(), cx);
1476 editor.newline(&Default::default(), cx);
1477 editor.move_down(&Default::default(), cx);
1478 editor.move_down(&Default::default(), cx);
1479 editor.save(project.clone(), cx)
1480 })
1481 .await
1482 .unwrap();
1483 editor3.update(cx, |editor, cx| {
1484 editor.set_scroll_position(vec2f(0., 12.5), cx)
1485 });
1486 assert_eq!(
1487 active_location(&workspace, cx),
1488 (file3.clone(), DisplayPoint::new(16, 0), 12.5)
1489 );
1490
1491 workspace
1492 .update(cx, |w, cx| Pane::go_back(w, None, cx))
1493 .await;
1494 assert_eq!(
1495 active_location(&workspace, cx),
1496 (file3.clone(), DisplayPoint::new(0, 0), 0.)
1497 );
1498
1499 workspace
1500 .update(cx, |w, cx| Pane::go_back(w, None, cx))
1501 .await;
1502 assert_eq!(
1503 active_location(&workspace, cx),
1504 (file2.clone(), DisplayPoint::new(0, 0), 0.)
1505 );
1506
1507 workspace
1508 .update(cx, |w, cx| Pane::go_back(w, None, cx))
1509 .await;
1510 assert_eq!(
1511 active_location(&workspace, cx),
1512 (file1.clone(), DisplayPoint::new(10, 0), 0.)
1513 );
1514
1515 workspace
1516 .update(cx, |w, cx| Pane::go_back(w, None, cx))
1517 .await;
1518 assert_eq!(
1519 active_location(&workspace, cx),
1520 (file1.clone(), DisplayPoint::new(0, 0), 0.)
1521 );
1522
1523 // Go back one more time and ensure we don't navigate past the first item in the history.
1524 workspace
1525 .update(cx, |w, cx| Pane::go_back(w, None, cx))
1526 .await;
1527 assert_eq!(
1528 active_location(&workspace, cx),
1529 (file1.clone(), DisplayPoint::new(0, 0), 0.)
1530 );
1531
1532 workspace
1533 .update(cx, |w, cx| Pane::go_forward(w, None, cx))
1534 .await;
1535 assert_eq!(
1536 active_location(&workspace, cx),
1537 (file1.clone(), DisplayPoint::new(10, 0), 0.)
1538 );
1539
1540 workspace
1541 .update(cx, |w, cx| Pane::go_forward(w, None, cx))
1542 .await;
1543 assert_eq!(
1544 active_location(&workspace, cx),
1545 (file2.clone(), DisplayPoint::new(0, 0), 0.)
1546 );
1547
1548 // Go forward to an item that has been closed, ensuring it gets re-opened at the same
1549 // location.
1550 workspace
1551 .update(cx, |workspace, cx| {
1552 let editor3_id = editor3.id();
1553 drop(editor3);
1554 Pane::close_item(workspace, workspace.active_pane().clone(), editor3_id, cx)
1555 })
1556 .await
1557 .unwrap();
1558 workspace
1559 .update(cx, |w, cx| Pane::go_forward(w, None, cx))
1560 .await;
1561 assert_eq!(
1562 active_location(&workspace, cx),
1563 (file3.clone(), DisplayPoint::new(0, 0), 0.)
1564 );
1565
1566 workspace
1567 .update(cx, |w, cx| Pane::go_forward(w, None, cx))
1568 .await;
1569 assert_eq!(
1570 active_location(&workspace, cx),
1571 (file3.clone(), DisplayPoint::new(16, 0), 12.5)
1572 );
1573
1574 workspace
1575 .update(cx, |w, cx| Pane::go_back(w, None, cx))
1576 .await;
1577 assert_eq!(
1578 active_location(&workspace, cx),
1579 (file3.clone(), DisplayPoint::new(0, 0), 0.)
1580 );
1581
1582 // Go back to an item that has been closed and removed from disk, ensuring it gets skipped.
1583 workspace
1584 .update(cx, |workspace, cx| {
1585 let editor2_id = editor2.id();
1586 drop(editor2);
1587 Pane::close_item(workspace, workspace.active_pane().clone(), editor2_id, cx)
1588 })
1589 .await
1590 .unwrap();
1591 app_state
1592 .fs
1593 .remove_file(Path::new("/root/a/file2"), Default::default())
1594 .await
1595 .unwrap();
1596 workspace
1597 .update(cx, |w, cx| Pane::go_back(w, None, cx))
1598 .await;
1599 assert_eq!(
1600 active_location(&workspace, cx),
1601 (file1.clone(), DisplayPoint::new(10, 0), 0.)
1602 );
1603 workspace
1604 .update(cx, |w, cx| Pane::go_forward(w, None, cx))
1605 .await;
1606 assert_eq!(
1607 active_location(&workspace, cx),
1608 (file3.clone(), DisplayPoint::new(0, 0), 0.)
1609 );
1610
1611 // Modify file to collapse multiple nav history entries into the same location.
1612 // Ensure we don't visit the same location twice when navigating.
1613 editor1.update(cx, |editor, cx| {
1614 editor.change_selections(None, cx, |s| {
1615 s.select_display_ranges([DisplayPoint::new(15, 0)..DisplayPoint::new(15, 0)])
1616 })
1617 });
1618
1619 for _ in 0..5 {
1620 editor1.update(cx, |editor, cx| {
1621 editor.change_selections(None, cx, |s| {
1622 s.select_display_ranges([DisplayPoint::new(3, 0)..DisplayPoint::new(3, 0)])
1623 });
1624 });
1625 editor1.update(cx, |editor, cx| {
1626 editor.change_selections(None, cx, |s| {
1627 s.select_display_ranges([DisplayPoint::new(13, 0)..DisplayPoint::new(13, 0)])
1628 })
1629 });
1630 }
1631
1632 editor1.update(cx, |editor, cx| {
1633 editor.transact(cx, |editor, cx| {
1634 editor.change_selections(None, cx, |s| {
1635 s.select_display_ranges([DisplayPoint::new(2, 0)..DisplayPoint::new(14, 0)])
1636 });
1637 editor.insert("", cx);
1638 })
1639 });
1640
1641 editor1.update(cx, |editor, cx| {
1642 editor.change_selections(None, cx, |s| {
1643 s.select_display_ranges([DisplayPoint::new(1, 0)..DisplayPoint::new(1, 0)])
1644 })
1645 });
1646 workspace
1647 .update(cx, |w, cx| Pane::go_back(w, None, cx))
1648 .await;
1649 assert_eq!(
1650 active_location(&workspace, cx),
1651 (file1.clone(), DisplayPoint::new(2, 0), 0.)
1652 );
1653 workspace
1654 .update(cx, |w, cx| Pane::go_back(w, None, cx))
1655 .await;
1656 assert_eq!(
1657 active_location(&workspace, cx),
1658 (file1.clone(), DisplayPoint::new(3, 0), 0.)
1659 );
1660
1661 fn active_location(
1662 workspace: &ViewHandle<Workspace>,
1663 cx: &mut TestAppContext,
1664 ) -> (ProjectPath, DisplayPoint, f32) {
1665 workspace.update(cx, |workspace, cx| {
1666 let item = workspace.active_item(cx).unwrap();
1667 let editor = item.downcast::<Editor>().unwrap();
1668 let (selections, scroll_position) = editor.update(cx, |editor, cx| {
1669 (
1670 editor.selections.display_ranges(cx),
1671 editor.scroll_position(cx),
1672 )
1673 });
1674 (
1675 item.project_path(cx).unwrap(),
1676 selections[0].start,
1677 scroll_position.y(),
1678 )
1679 })
1680 }
1681 }
1682
1683 #[gpui::test]
1684 async fn test_reopening_closed_items(cx: &mut TestAppContext) {
1685 let app_state = init(cx);
1686 app_state
1687 .fs
1688 .as_fake()
1689 .insert_tree(
1690 "/root",
1691 json!({
1692 "a": {
1693 "file1": "",
1694 "file2": "",
1695 "file3": "",
1696 "file4": "",
1697 },
1698 }),
1699 )
1700 .await;
1701
1702 let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
1703 let (_, workspace) = cx.add_window(|cx| {
1704 Workspace::new(
1705 Default::default(),
1706 0,
1707 project.clone(),
1708 |_, _| unimplemented!(),
1709 cx,
1710 )
1711 });
1712 let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone());
1713
1714 let entries = cx.read(|cx| workspace.file_project_paths(cx));
1715 let file1 = entries[0].clone();
1716 let file2 = entries[1].clone();
1717 let file3 = entries[2].clone();
1718 let file4 = entries[3].clone();
1719
1720 let file1_item_id = workspace
1721 .update(cx, |w, cx| w.open_path(file1.clone(), None, true, cx))
1722 .await
1723 .unwrap()
1724 .id();
1725 let file2_item_id = workspace
1726 .update(cx, |w, cx| w.open_path(file2.clone(), None, true, cx))
1727 .await
1728 .unwrap()
1729 .id();
1730 let file3_item_id = workspace
1731 .update(cx, |w, cx| w.open_path(file3.clone(), None, true, cx))
1732 .await
1733 .unwrap()
1734 .id();
1735 let file4_item_id = workspace
1736 .update(cx, |w, cx| w.open_path(file4.clone(), None, true, cx))
1737 .await
1738 .unwrap()
1739 .id();
1740 assert_eq!(active_path(&workspace, cx), Some(file4.clone()));
1741
1742 // Close all the pane items in some arbitrary order.
1743 workspace
1744 .update(cx, |workspace, cx| {
1745 Pane::close_item(workspace, pane.clone(), file1_item_id, cx)
1746 })
1747 .await
1748 .unwrap();
1749 assert_eq!(active_path(&workspace, cx), Some(file4.clone()));
1750
1751 workspace
1752 .update(cx, |workspace, cx| {
1753 Pane::close_item(workspace, pane.clone(), file4_item_id, cx)
1754 })
1755 .await
1756 .unwrap();
1757 assert_eq!(active_path(&workspace, cx), Some(file3.clone()));
1758
1759 workspace
1760 .update(cx, |workspace, cx| {
1761 Pane::close_item(workspace, pane.clone(), file2_item_id, cx)
1762 })
1763 .await
1764 .unwrap();
1765 assert_eq!(active_path(&workspace, cx), Some(file3.clone()));
1766
1767 workspace
1768 .update(cx, |workspace, cx| {
1769 Pane::close_item(workspace, pane.clone(), file3_item_id, cx)
1770 })
1771 .await
1772 .unwrap();
1773 assert_eq!(active_path(&workspace, cx), None);
1774
1775 // Reopen all the closed items, ensuring they are reopened in the same order
1776 // in which they were closed.
1777 workspace.update(cx, Pane::reopen_closed_item).await;
1778 assert_eq!(active_path(&workspace, cx), Some(file3.clone()));
1779
1780 workspace.update(cx, Pane::reopen_closed_item).await;
1781 assert_eq!(active_path(&workspace, cx), Some(file2.clone()));
1782
1783 workspace.update(cx, Pane::reopen_closed_item).await;
1784 assert_eq!(active_path(&workspace, cx), Some(file4.clone()));
1785
1786 workspace.update(cx, Pane::reopen_closed_item).await;
1787 assert_eq!(active_path(&workspace, cx), Some(file1.clone()));
1788
1789 // Reopening past the last closed item is a no-op.
1790 workspace.update(cx, Pane::reopen_closed_item).await;
1791 assert_eq!(active_path(&workspace, cx), Some(file1.clone()));
1792
1793 // Reopening closed items doesn't interfere with navigation history.
1794 workspace
1795 .update(cx, |workspace, cx| Pane::go_back(workspace, None, cx))
1796 .await;
1797 assert_eq!(active_path(&workspace, cx), Some(file4.clone()));
1798
1799 workspace
1800 .update(cx, |workspace, cx| Pane::go_back(workspace, None, cx))
1801 .await;
1802 assert_eq!(active_path(&workspace, cx), Some(file2.clone()));
1803
1804 workspace
1805 .update(cx, |workspace, cx| Pane::go_back(workspace, None, cx))
1806 .await;
1807 assert_eq!(active_path(&workspace, cx), Some(file3.clone()));
1808
1809 workspace
1810 .update(cx, |workspace, cx| Pane::go_back(workspace, None, cx))
1811 .await;
1812 assert_eq!(active_path(&workspace, cx), Some(file4.clone()));
1813
1814 workspace
1815 .update(cx, |workspace, cx| Pane::go_back(workspace, None, cx))
1816 .await;
1817 assert_eq!(active_path(&workspace, cx), Some(file3.clone()));
1818
1819 workspace
1820 .update(cx, |workspace, cx| Pane::go_back(workspace, None, cx))
1821 .await;
1822 assert_eq!(active_path(&workspace, cx), Some(file2.clone()));
1823
1824 workspace
1825 .update(cx, |workspace, cx| Pane::go_back(workspace, None, cx))
1826 .await;
1827 assert_eq!(active_path(&workspace, cx), Some(file1.clone()));
1828
1829 workspace
1830 .update(cx, |workspace, cx| Pane::go_back(workspace, None, cx))
1831 .await;
1832 assert_eq!(active_path(&workspace, cx), Some(file1.clone()));
1833
1834 fn active_path(
1835 workspace: &ViewHandle<Workspace>,
1836 cx: &TestAppContext,
1837 ) -> Option<ProjectPath> {
1838 workspace.read_with(cx, |workspace, cx| {
1839 let item = workspace.active_item(cx)?;
1840 item.project_path(cx)
1841 })
1842 }
1843 }
1844
1845 #[gpui::test]
1846 fn test_bundled_settings_and_themes(cx: &mut MutableAppContext) {
1847 cx.platform()
1848 .fonts()
1849 .add_fonts(&[
1850 Assets
1851 .load("fonts/zed-sans/zed-sans-extended.ttf")
1852 .unwrap()
1853 .to_vec()
1854 .into(),
1855 Assets
1856 .load("fonts/zed-mono/zed-mono-extended.ttf")
1857 .unwrap()
1858 .to_vec()
1859 .into(),
1860 ])
1861 .unwrap();
1862 let themes = ThemeRegistry::new(Assets, cx.font_cache().clone());
1863 let settings = Settings::defaults(Assets, cx.font_cache(), &themes);
1864
1865 let mut has_default_theme = false;
1866 for theme_name in themes.list(false).map(|meta| meta.name) {
1867 let theme = themes.get(&theme_name).unwrap();
1868 if theme.meta.name == settings.theme.meta.name {
1869 has_default_theme = true;
1870 }
1871 assert_eq!(theme.meta.name, theme_name);
1872 }
1873 assert!(has_default_theme);
1874 }
1875
1876 fn init(cx: &mut TestAppContext) -> Arc<AppState> {
1877 cx.foreground().forbid_parking();
1878 cx.update(|cx| {
1879 let mut app_state = AppState::test(cx);
1880 let state = Arc::get_mut(&mut app_state).unwrap();
1881 state.initialize_workspace = initialize_workspace;
1882 state.build_window_options = build_window_options;
1883 call::init(app_state.client.clone(), app_state.user_store.clone(), cx);
1884 workspace::init(app_state.clone(), cx);
1885 editor::init(cx);
1886 pane::init(cx);
1887 app_state
1888 })
1889 }
1890
1891 fn rust_lang() -> Arc<language::Language> {
1892 Arc::new(language::Language::new(
1893 language::LanguageConfig {
1894 name: "Rust".into(),
1895 path_suffixes: vec!["rs".to_string()],
1896 ..Default::default()
1897 },
1898 Some(tree_sitter_rust::language()),
1899 ))
1900 }
1901}