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