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