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