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