1mod feedback;
2pub mod languages;
3pub mod menus;
4pub mod settings_file;
5#[cfg(any(test, feature = "test-support"))]
6pub mod test;
7
8use anyhow::{anyhow, Context, Result};
9use breadcrumbs::Breadcrumbs;
10pub use client;
11pub use contacts_panel;
12use contacts_panel::ContactsPanel;
13pub use editor;
14use editor::Editor;
15use gpui::{
16 actions,
17 geometry::vector::vec2f,
18 impl_actions,
19 platform::{WindowBounds, WindowOptions},
20 AsyncAppContext, ViewContext,
21};
22use lazy_static::lazy_static;
23pub use lsp;
24use project::Project;
25pub use project::{self, fs};
26use project_panel::ProjectPanel;
27use search::{BufferSearchBar, ProjectSearchBar};
28use serde::Deserialize;
29use serde_json::to_string_pretty;
30use settings::{keymap_file_json_schema, settings_file_json_schema, Settings};
31use std::{
32 path::{Path, PathBuf},
33 sync::Arc,
34};
35use util::ResultExt;
36pub use workspace;
37use workspace::{AppState, Workspace};
38
39#[derive(Deserialize, Clone, PartialEq)]
40struct OpenBrowser {
41 url: Arc<str>,
42}
43
44impl_actions!(zed, [OpenBrowser]);
45
46actions!(
47 zed,
48 [
49 About,
50 Quit,
51 DebugElements,
52 OpenSettings,
53 OpenKeymap,
54 IncreaseBufferFontSize,
55 DecreaseBufferFontSize,
56 ResetBufferFontSize,
57 InstallCommandLineInterface,
58 ]
59);
60
61const MIN_FONT_SIZE: f32 = 6.0;
62
63lazy_static! {
64 pub static ref ROOT_PATH: PathBuf = dirs::home_dir()
65 .expect("failed to determine home directory")
66 .join(".zed");
67 pub static ref SETTINGS_PATH: PathBuf = ROOT_PATH.join("settings.json");
68 pub static ref KEYMAP_PATH: PathBuf = ROOT_PATH.join("keymap.json");
69}
70
71pub fn init(app_state: &Arc<AppState>, cx: &mut gpui::MutableAppContext) {
72 cx.add_action(about);
73 cx.add_global_action(quit);
74 cx.add_global_action(move |action: &OpenBrowser, cx| cx.platform().open_url(&action.url));
75 cx.add_global_action(move |_: &IncreaseBufferFontSize, cx| {
76 cx.update_global::<Settings, _, _>(|settings, cx| {
77 settings.buffer_font_size = (settings.buffer_font_size + 1.0).max(MIN_FONT_SIZE);
78 cx.refresh_windows();
79 });
80 });
81 cx.add_global_action(move |_: &DecreaseBufferFontSize, cx| {
82 cx.update_global::<Settings, _, _>(|settings, cx| {
83 settings.buffer_font_size = (settings.buffer_font_size - 1.0).max(MIN_FONT_SIZE);
84 cx.refresh_windows();
85 });
86 });
87 cx.add_global_action(move |_: &ResetBufferFontSize, cx| {
88 cx.update_global::<Settings, _, _>(|settings, cx| {
89 settings.buffer_font_size = settings.default_buffer_font_size;
90 cx.refresh_windows();
91 });
92 });
93 cx.add_global_action(move |_: &InstallCommandLineInterface, cx| {
94 cx.spawn(|cx| async move { install_cli(&cx).await.context("error creating CLI symlink") })
95 .detach_and_log_err(cx);
96 });
97 cx.add_action({
98 let app_state = app_state.clone();
99 move |_: &mut Workspace, _: &OpenSettings, cx: &mut ViewContext<Workspace>| {
100 open_config_file(&SETTINGS_PATH, app_state.clone(), cx);
101 }
102 });
103 cx.add_action({
104 let app_state = app_state.clone();
105 move |_: &mut Workspace, _: &OpenKeymap, cx: &mut ViewContext<Workspace>| {
106 open_config_file(&KEYMAP_PATH, app_state.clone(), cx);
107 }
108 });
109 cx.add_action(
110 |workspace: &mut Workspace, _: &DebugElements, cx: &mut ViewContext<Workspace>| {
111 let content = to_string_pretty(&cx.debug_elements()).unwrap();
112 let project = workspace.project().clone();
113 let json_language = project.read(cx).languages().get_language("JSON").unwrap();
114 if project.read(cx).is_remote() {
115 cx.propagate_action();
116 } else if let Some(buffer) = project
117 .update(cx, |project, cx| {
118 project.create_buffer(&content, Some(json_language), cx)
119 })
120 .log_err()
121 {
122 workspace.add_item(
123 Box::new(
124 cx.add_view(|cx| Editor::for_buffer(buffer, Some(project.clone()), cx)),
125 ),
126 cx,
127 );
128 }
129 },
130 );
131
132 lsp_status::init(cx);
133 settings::KeymapFileContent::load_defaults(cx);
134}
135
136pub fn initialize_workspace(
137 workspace: &mut Workspace,
138 app_state: &Arc<AppState>,
139 cx: &mut ViewContext<Workspace>,
140) {
141 cx.subscribe(&cx.handle(), {
142 let project = workspace.project().clone();
143 move |_, _, event, cx| {
144 if let workspace::Event::PaneAdded(pane) = event {
145 pane.update(cx, |pane, cx| {
146 pane.toolbar().update(cx, |toolbar, cx| {
147 let breadcrumbs = cx.add_view(|_| Breadcrumbs::new(project.clone()));
148 toolbar.add_item(breadcrumbs, cx);
149 let buffer_search_bar = cx.add_view(|cx| BufferSearchBar::new(cx));
150 toolbar.add_item(buffer_search_bar, cx);
151 let project_search_bar = cx.add_view(|_| ProjectSearchBar::new());
152 toolbar.add_item(project_search_bar, cx);
153 })
154 });
155 }
156 }
157 })
158 .detach();
159
160 cx.emit(workspace::Event::PaneAdded(workspace.active_pane().clone()));
161
162 let theme_names = app_state.themes.list().collect();
163 let language_names = app_state.languages.language_names();
164
165 workspace.project().update(cx, |project, cx| {
166 let action_names = cx.all_action_names().collect::<Vec<_>>();
167 project.set_language_server_settings(serde_json::json!({
168 "json": {
169 "schemas": [
170 {
171 "fileMatch": [".zed/settings.json"],
172 "schema": settings_file_json_schema(theme_names, language_names),
173 },
174 {
175 "fileMatch": [".zed/keymap.json"],
176 "schema": keymap_file_json_schema(&action_names),
177 }
178 ]
179 }
180 }));
181 });
182
183 let project_panel = ProjectPanel::new(workspace.project().clone(), cx);
184 let contact_panel = cx.add_view(|cx| {
185 ContactsPanel::new(
186 app_state.user_store.clone(),
187 app_state.project_store.clone(),
188 workspace.weak_handle(),
189 cx,
190 )
191 });
192
193 workspace.left_sidebar().update(cx, |sidebar, cx| {
194 sidebar.add_item(
195 "icons/folder-tree-solid-14.svg",
196 "Project Panel".to_string(),
197 project_panel.into(),
198 cx,
199 )
200 });
201 workspace.right_sidebar().update(cx, |sidebar, cx| {
202 sidebar.add_item(
203 "icons/contacts-solid-14.svg",
204 "Contacts Panel".to_string(),
205 contact_panel.into(),
206 cx,
207 )
208 });
209
210 let diagnostic_summary =
211 cx.add_view(|cx| diagnostics::items::DiagnosticIndicator::new(workspace.project(), cx));
212 let lsp_status = lsp_status::LspStatusItem::new(workspace, app_state.languages.clone(), cx);
213 let cursor_position = cx.add_view(|_| editor::items::CursorPosition::new());
214 let auto_update = cx.add_view(|cx| auto_update::AutoUpdateIndicator::new(cx));
215 let feedback_link = cx.add_view(|_| feedback::FeedbackLink);
216 workspace.status_bar().update(cx, |status_bar, cx| {
217 status_bar.add_left_item(diagnostic_summary, cx);
218 status_bar.add_left_item(lsp_status, cx);
219 status_bar.add_right_item(cursor_position, cx);
220 status_bar.add_right_item(auto_update, cx);
221 status_bar.add_right_item(feedback_link, cx);
222 });
223
224 auto_update::notify_of_any_new_update(cx.weak_handle(), cx);
225}
226
227pub fn build_window_options() -> WindowOptions<'static> {
228 WindowOptions {
229 bounds: WindowBounds::Maximized,
230 title: None,
231 titlebar_appears_transparent: true,
232 traffic_light_position: Some(vec2f(8., 8.)),
233 }
234}
235
236fn quit(_: &Quit, cx: &mut gpui::MutableAppContext) {
237 let mut workspaces = cx
238 .window_ids()
239 .filter_map(|window_id| cx.root_view::<Workspace>(window_id))
240 .collect::<Vec<_>>();
241
242 // If multiple windows have unsaved changes, and need a save prompt,
243 // prompt in the active window before switching to a different window.
244 workspaces.sort_by_key(|workspace| !cx.window_is_active(workspace.window_id()));
245
246 cx.spawn(|mut cx| async move {
247 // If the user cancels any save prompt, then keep the app open.
248 for workspace in workspaces {
249 if !workspace
250 .update(&mut cx, |workspace, cx| workspace.prepare_to_close(cx))
251 .await?
252 {
253 return Ok(());
254 }
255 }
256 cx.platform().quit();
257 anyhow::Ok(())
258 })
259 .detach_and_log_err(cx);
260}
261
262fn about(_: &mut Workspace, _: &About, cx: &mut gpui::ViewContext<Workspace>) {
263 cx.prompt(
264 gpui::PromptLevel::Info,
265 &format!("Zed {}", env!("CARGO_PKG_VERSION")),
266 &["OK"],
267 );
268}
269
270async fn install_cli(cx: &AsyncAppContext) -> Result<()> {
271 let cli_path = cx.platform().path_for_auxiliary_executable("cli")?;
272 let link_path = Path::new("/usr/local/bin/zed");
273 let bin_dir_path = link_path.parent().unwrap();
274
275 // Don't re-create symlink if it points to the same CLI binary.
276 if smol::fs::read_link(link_path).await.ok().as_ref() == Some(&cli_path) {
277 return Ok(());
278 }
279
280 // If the symlink is not there or is outdated, first try replacing it
281 // without escalating.
282 smol::fs::remove_file(link_path).await.log_err();
283 if smol::fs::unix::symlink(&cli_path, link_path)
284 .await
285 .log_err()
286 .is_some()
287 {
288 return Ok(());
289 }
290
291 // The symlink could not be created, so use osascript with admin privileges
292 // to create it.
293 let status = smol::process::Command::new("osascript")
294 .args([
295 "-e",
296 &format!(
297 "do shell script \" \
298 mkdir -p \'{}\' && \
299 ln -sf \'{}\' \'{}\' \
300 \" with administrator privileges",
301 bin_dir_path.to_string_lossy(),
302 cli_path.to_string_lossy(),
303 link_path.to_string_lossy(),
304 ),
305 ])
306 .stdout(smol::process::Stdio::inherit())
307 .stderr(smol::process::Stdio::inherit())
308 .output()
309 .await?
310 .status;
311 if status.success() {
312 Ok(())
313 } else {
314 Err(anyhow!("error running osascript"))
315 }
316}
317
318fn open_config_file(
319 path: &'static Path,
320 app_state: Arc<AppState>,
321 cx: &mut ViewContext<Workspace>,
322) {
323 cx.spawn(|workspace, mut cx| async move {
324 let fs = &app_state.fs;
325 if !fs.is_file(path).await {
326 fs.create_dir(&ROOT_PATH).await?;
327 fs.create_file(path, Default::default()).await?;
328 }
329
330 workspace
331 .update(&mut cx, |workspace, cx| {
332 if workspace.project().read(cx).is_local() {
333 workspace.open_paths(vec![path.to_path_buf()], false, cx)
334 } else {
335 let (_, workspace) = cx.add_window((app_state.build_window_options)(), |cx| {
336 let mut workspace = Workspace::new(
337 Project::local(
338 false,
339 app_state.client.clone(),
340 app_state.user_store.clone(),
341 app_state.project_store.clone(),
342 app_state.languages.clone(),
343 app_state.fs.clone(),
344 cx,
345 ),
346 cx,
347 );
348 (app_state.initialize_workspace)(&mut workspace, &app_state, cx);
349 workspace
350 });
351 workspace.update(cx, |workspace, cx| {
352 workspace.open_paths(vec![path.to_path_buf()], false, cx)
353 })
354 }
355 })
356 .await;
357 Ok::<_, anyhow::Error>(())
358 })
359 .detach_and_log_err(cx)
360}
361
362#[cfg(test)]
363mod tests {
364 use super::*;
365 use assets::Assets;
366 use editor::{Autoscroll, DisplayPoint, Editor};
367 use gpui::{AssetSource, MutableAppContext, TestAppContext, ViewHandle};
368 use project::ProjectPath;
369 use serde_json::json;
370 use std::{
371 collections::HashSet,
372 path::{Path, PathBuf},
373 };
374 use theme::{Theme, ThemeRegistry, DEFAULT_THEME_NAME};
375 use workspace::{
376 open_paths, pane, Item, ItemHandle, NewFile, Pane, SplitDirection, WorkspaceHandle,
377 };
378
379 #[gpui::test]
380 async fn test_open_paths_action(cx: &mut TestAppContext) {
381 let app_state = init(cx);
382 app_state
383 .fs
384 .as_fake()
385 .insert_tree(
386 "/root",
387 json!({
388 "a": {
389 "aa": null,
390 "ab": null,
391 },
392 "b": {
393 "ba": null,
394 "bb": null,
395 },
396 "c": {
397 "ca": null,
398 "cb": null,
399 },
400 }),
401 )
402 .await;
403
404 cx.update(|cx| {
405 open_paths(
406 &[PathBuf::from("/root/a"), PathBuf::from("/root/b")],
407 &app_state,
408 cx,
409 )
410 })
411 .await;
412 assert_eq!(cx.window_ids().len(), 1);
413
414 cx.update(|cx| open_paths(&[PathBuf::from("/root/a")], &app_state, cx))
415 .await;
416 assert_eq!(cx.window_ids().len(), 1);
417 let workspace_1 = cx.root_view::<Workspace>(cx.window_ids()[0]).unwrap();
418 workspace_1.update(cx, |workspace, cx| {
419 assert_eq!(workspace.worktrees(cx).count(), 2);
420 assert!(workspace.left_sidebar().read(cx).active_item().is_some());
421 assert!(workspace.active_pane().is_focused(cx));
422 });
423
424 cx.update(|cx| {
425 open_paths(
426 &[PathBuf::from("/root/b"), PathBuf::from("/root/c")],
427 &app_state,
428 cx,
429 )
430 })
431 .await;
432 assert_eq!(cx.window_ids().len(), 2);
433 }
434
435 #[gpui::test]
436 async fn test_new_empty_workspace(cx: &mut TestAppContext) {
437 let app_state = init(cx);
438 cx.dispatch_global_action(workspace::NewFile);
439 let window_id = *cx.window_ids().first().unwrap();
440 let workspace = cx.root_view::<Workspace>(window_id).unwrap();
441 let editor = workspace.update(cx, |workspace, cx| {
442 workspace
443 .active_item(cx)
444 .unwrap()
445 .downcast::<editor::Editor>()
446 .unwrap()
447 });
448
449 editor.update(cx, |editor, cx| {
450 assert!(editor.text(cx).is_empty());
451 });
452
453 let save_task = workspace.update(cx, |workspace, cx| workspace.save_active_item(false, cx));
454 app_state.fs.as_fake().insert_dir("/root").await;
455 cx.simulate_new_path_selection(|_| Some(PathBuf::from("/root/the-new-name")));
456 save_task.await.unwrap();
457 editor.read_with(cx, |editor, cx| {
458 assert!(!editor.is_dirty(cx));
459 assert_eq!(editor.title(cx), "the-new-name");
460 });
461 }
462
463 #[gpui::test]
464 async fn test_open_entry(cx: &mut TestAppContext) {
465 let app_state = init(cx);
466 app_state
467 .fs
468 .as_fake()
469 .insert_tree(
470 "/root",
471 json!({
472 "a": {
473 "file1": "contents 1",
474 "file2": "contents 2",
475 "file3": "contents 3",
476 },
477 }),
478 )
479 .await;
480
481 let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
482 let (_, workspace) = cx.add_window(|cx| Workspace::new(project, cx));
483
484 let entries = cx.read(|cx| workspace.file_project_paths(cx));
485 let file1 = entries[0].clone();
486 let file2 = entries[1].clone();
487 let file3 = entries[2].clone();
488
489 // Open the first entry
490 let entry_1 = workspace
491 .update(cx, |w, cx| w.open_path(file1.clone(), true, cx))
492 .await
493 .unwrap();
494 cx.read(|cx| {
495 let pane = workspace.read(cx).active_pane().read(cx);
496 assert_eq!(
497 pane.active_item().unwrap().project_path(cx),
498 Some(file1.clone())
499 );
500 assert_eq!(pane.items().count(), 1);
501 });
502
503 // Open the second entry
504 workspace
505 .update(cx, |w, cx| w.open_path(file2.clone(), true, cx))
506 .await
507 .unwrap();
508 cx.read(|cx| {
509 let pane = workspace.read(cx).active_pane().read(cx);
510 assert_eq!(
511 pane.active_item().unwrap().project_path(cx),
512 Some(file2.clone())
513 );
514 assert_eq!(pane.items().count(), 2);
515 });
516
517 // Open the first entry again. The existing pane item is activated.
518 let entry_1b = workspace
519 .update(cx, |w, cx| w.open_path(file1.clone(), true, cx))
520 .await
521 .unwrap();
522 assert_eq!(entry_1.id(), entry_1b.id());
523
524 cx.read(|cx| {
525 let pane = workspace.read(cx).active_pane().read(cx);
526 assert_eq!(
527 pane.active_item().unwrap().project_path(cx),
528 Some(file1.clone())
529 );
530 assert_eq!(pane.items().count(), 2);
531 });
532
533 // Split the pane with the first entry, then open the second entry again.
534 workspace
535 .update(cx, |w, cx| {
536 w.split_pane(w.active_pane().clone(), SplitDirection::Right, cx);
537 w.open_path(file2.clone(), true, cx)
538 })
539 .await
540 .unwrap();
541
542 workspace.read_with(cx, |w, cx| {
543 assert_eq!(
544 w.active_pane()
545 .read(cx)
546 .active_item()
547 .unwrap()
548 .project_path(cx.as_ref()),
549 Some(file2.clone())
550 );
551 });
552
553 // Open the third entry twice concurrently. Only one pane item is added.
554 let (t1, t2) = workspace.update(cx, |w, cx| {
555 (
556 w.open_path(file3.clone(), true, cx),
557 w.open_path(file3.clone(), true, cx),
558 )
559 });
560 t1.await.unwrap();
561 t2.await.unwrap();
562 cx.read(|cx| {
563 let pane = workspace.read(cx).active_pane().read(cx);
564 assert_eq!(
565 pane.active_item().unwrap().project_path(cx),
566 Some(file3.clone())
567 );
568 let pane_entries = pane
569 .items()
570 .map(|i| i.project_path(cx).unwrap())
571 .collect::<Vec<_>>();
572 assert_eq!(pane_entries, &[file1, file2, file3]);
573 });
574 }
575
576 #[gpui::test]
577 async fn test_open_paths(cx: &mut TestAppContext) {
578 let app_state = init(cx);
579
580 let fs = app_state.fs.as_fake();
581 fs.insert_dir("/dir1").await;
582 fs.insert_dir("/dir2").await;
583 fs.insert_dir("/dir3").await;
584 fs.insert_file("/dir1/a.txt", "".into()).await;
585 fs.insert_file("/dir2/b.txt", "".into()).await;
586 fs.insert_file("/dir3/c.txt", "".into()).await;
587 fs.insert_file("/d.txt", "".into()).await;
588
589 let project = Project::test(app_state.fs.clone(), ["/dir1".as_ref()], cx).await;
590 let (_, workspace) = cx.add_window(|cx| Workspace::new(project, cx));
591
592 // Open a file within an existing worktree.
593 cx.update(|cx| {
594 workspace.update(cx, |view, cx| {
595 view.open_paths(vec!["/dir1/a.txt".into()], true, cx)
596 })
597 })
598 .await;
599 cx.read(|cx| {
600 assert_eq!(
601 workspace
602 .read(cx)
603 .active_pane()
604 .read(cx)
605 .active_item()
606 .unwrap()
607 .to_any()
608 .downcast::<Editor>()
609 .unwrap()
610 .read(cx)
611 .title(cx),
612 "a.txt"
613 );
614 });
615
616 // Open a file outside of any existing worktree.
617 cx.update(|cx| {
618 workspace.update(cx, |view, cx| {
619 view.open_paths(vec!["/dir2/b.txt".into()], true, cx)
620 })
621 })
622 .await;
623 cx.read(|cx| {
624 let worktree_roots = workspace
625 .read(cx)
626 .worktrees(cx)
627 .map(|w| w.read(cx).as_local().unwrap().abs_path().as_ref())
628 .collect::<HashSet<_>>();
629 assert_eq!(
630 worktree_roots,
631 vec!["/dir1", "/dir2/b.txt"]
632 .into_iter()
633 .map(Path::new)
634 .collect(),
635 );
636 assert_eq!(
637 workspace
638 .read(cx)
639 .active_pane()
640 .read(cx)
641 .active_item()
642 .unwrap()
643 .to_any()
644 .downcast::<Editor>()
645 .unwrap()
646 .read(cx)
647 .title(cx),
648 "b.txt"
649 );
650 });
651
652 // Ensure opening a directory and one of its children only adds one worktree.
653 cx.update(|cx| {
654 workspace.update(cx, |view, cx| {
655 view.open_paths(vec!["/dir3".into(), "/dir3/c.txt".into()], true, cx)
656 })
657 })
658 .await;
659 cx.read(|cx| {
660 let worktree_roots = workspace
661 .read(cx)
662 .worktrees(cx)
663 .map(|w| w.read(cx).as_local().unwrap().abs_path().as_ref())
664 .collect::<HashSet<_>>();
665 assert_eq!(
666 worktree_roots,
667 vec!["/dir1", "/dir2/b.txt", "/dir3"]
668 .into_iter()
669 .map(Path::new)
670 .collect(),
671 );
672 assert_eq!(
673 workspace
674 .read(cx)
675 .active_pane()
676 .read(cx)
677 .active_item()
678 .unwrap()
679 .to_any()
680 .downcast::<Editor>()
681 .unwrap()
682 .read(cx)
683 .title(cx),
684 "c.txt"
685 );
686 });
687
688 // Ensure opening invisibly a file outside an existing worktree adds a new, invisible worktree.
689 cx.update(|cx| {
690 workspace.update(cx, |view, cx| {
691 view.open_paths(vec!["/d.txt".into()], false, cx)
692 })
693 })
694 .await;
695 cx.read(|cx| {
696 let worktree_roots = workspace
697 .read(cx)
698 .worktrees(cx)
699 .map(|w| w.read(cx).as_local().unwrap().abs_path().as_ref())
700 .collect::<HashSet<_>>();
701 assert_eq!(
702 worktree_roots,
703 vec!["/dir1", "/dir2/b.txt", "/dir3", "/d.txt"]
704 .into_iter()
705 .map(Path::new)
706 .collect(),
707 );
708
709 let visible_worktree_roots = workspace
710 .read(cx)
711 .visible_worktrees(cx)
712 .map(|w| w.read(cx).as_local().unwrap().abs_path().as_ref())
713 .collect::<HashSet<_>>();
714 assert_eq!(
715 visible_worktree_roots,
716 vec!["/dir1", "/dir2/b.txt", "/dir3"]
717 .into_iter()
718 .map(Path::new)
719 .collect(),
720 );
721
722 assert_eq!(
723 workspace
724 .read(cx)
725 .active_pane()
726 .read(cx)
727 .active_item()
728 .unwrap()
729 .to_any()
730 .downcast::<Editor>()
731 .unwrap()
732 .read(cx)
733 .title(cx),
734 "d.txt"
735 );
736 });
737 }
738
739 #[gpui::test]
740 async fn test_save_conflicting_item(cx: &mut TestAppContext) {
741 let app_state = init(cx);
742 app_state
743 .fs
744 .as_fake()
745 .insert_tree("/root", json!({ "a.txt": "" }))
746 .await;
747
748 let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
749 let (window_id, workspace) = cx.add_window(|cx| Workspace::new(project, cx));
750
751 // Open a file within an existing worktree.
752 cx.update(|cx| {
753 workspace.update(cx, |view, cx| {
754 view.open_paths(vec![PathBuf::from("/root/a.txt")], true, cx)
755 })
756 })
757 .await;
758 let editor = cx.read(|cx| {
759 let pane = workspace.read(cx).active_pane().read(cx);
760 let item = pane.active_item().unwrap();
761 item.downcast::<Editor>().unwrap()
762 });
763
764 cx.update(|cx| {
765 editor.update(cx, |editor, cx| {
766 editor.handle_input(&editor::Input("x".into()), cx)
767 })
768 });
769 app_state
770 .fs
771 .as_fake()
772 .insert_file("/root/a.txt", "changed".to_string())
773 .await;
774 editor
775 .condition(&cx, |editor, cx| editor.has_conflict(cx))
776 .await;
777 cx.read(|cx| assert!(editor.is_dirty(cx)));
778
779 let save_task = workspace.update(cx, |workspace, cx| workspace.save_active_item(false, cx));
780 cx.simulate_prompt_answer(window_id, 0);
781 save_task.await.unwrap();
782 editor.read_with(cx, |editor, cx| {
783 assert!(!editor.is_dirty(cx));
784 assert!(!editor.has_conflict(cx));
785 });
786 }
787
788 #[gpui::test]
789 async fn test_open_and_save_new_file(cx: &mut TestAppContext) {
790 let app_state = init(cx);
791 app_state.fs.as_fake().insert_dir("/root").await;
792
793 let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
794 project.update(cx, |project, _| project.languages().add(rust_lang()));
795 let (window_id, workspace) = cx.add_window(|cx| Workspace::new(project, cx));
796 let worktree = cx.read(|cx| workspace.read(cx).worktrees(cx).next().unwrap());
797
798 // Create a new untitled buffer
799 cx.dispatch_action(window_id, NewFile);
800 let editor = workspace.read_with(cx, |workspace, cx| {
801 workspace
802 .active_item(cx)
803 .unwrap()
804 .downcast::<Editor>()
805 .unwrap()
806 });
807
808 editor.update(cx, |editor, cx| {
809 assert!(!editor.is_dirty(cx));
810 assert_eq!(editor.title(cx), "untitled");
811 assert!(Arc::ptr_eq(
812 editor.language_at(0, cx).unwrap(),
813 &languages::PLAIN_TEXT
814 ));
815 editor.handle_input(&editor::Input("hi".into()), cx);
816 assert!(editor.is_dirty(cx));
817 });
818
819 // Save the buffer. This prompts for a filename.
820 let save_task = workspace.update(cx, |workspace, cx| workspace.save_active_item(false, cx));
821 cx.simulate_new_path_selection(|parent_dir| {
822 assert_eq!(parent_dir, Path::new("/root"));
823 Some(parent_dir.join("the-new-name.rs"))
824 });
825 cx.read(|cx| {
826 assert!(editor.is_dirty(cx));
827 assert_eq!(editor.read(cx).title(cx), "untitled");
828 });
829
830 // When the save completes, the buffer's title is updated and the language is assigned based
831 // on the path.
832 save_task.await.unwrap();
833 editor.read_with(cx, |editor, cx| {
834 assert!(!editor.is_dirty(cx));
835 assert_eq!(editor.title(cx), "the-new-name.rs");
836 assert_eq!(editor.language_at(0, cx).unwrap().name().as_ref(), "Rust");
837 });
838
839 // Edit the file and save it again. This time, there is no filename prompt.
840 editor.update(cx, |editor, cx| {
841 editor.handle_input(&editor::Input(" there".into()), cx);
842 assert_eq!(editor.is_dirty(cx.as_ref()), true);
843 });
844 let save_task = workspace.update(cx, |workspace, cx| workspace.save_active_item(false, cx));
845 save_task.await.unwrap();
846 assert!(!cx.did_prompt_for_new_path());
847 editor.read_with(cx, |editor, cx| {
848 assert!(!editor.is_dirty(cx));
849 assert_eq!(editor.title(cx), "the-new-name.rs")
850 });
851
852 // Open the same newly-created file in another pane item. The new editor should reuse
853 // the same buffer.
854 cx.dispatch_action(window_id, NewFile);
855 workspace
856 .update(cx, |workspace, cx| {
857 workspace.split_pane(workspace.active_pane().clone(), SplitDirection::Right, cx);
858 workspace.open_path((worktree.read(cx).id(), "the-new-name.rs"), true, cx)
859 })
860 .await
861 .unwrap();
862 let editor2 = workspace.update(cx, |workspace, cx| {
863 workspace
864 .active_item(cx)
865 .unwrap()
866 .downcast::<Editor>()
867 .unwrap()
868 });
869 cx.read(|cx| {
870 assert_eq!(
871 editor2.read(cx).buffer().read(cx).as_singleton().unwrap(),
872 editor.read(cx).buffer().read(cx).as_singleton().unwrap()
873 );
874 })
875 }
876
877 #[gpui::test]
878 async fn test_setting_language_when_saving_as_single_file_worktree(cx: &mut TestAppContext) {
879 let app_state = init(cx);
880 app_state.fs.as_fake().insert_dir("/root").await;
881
882 let project = Project::test(app_state.fs.clone(), [], cx).await;
883 project.update(cx, |project, _| project.languages().add(rust_lang()));
884 let (window_id, workspace) = cx.add_window(|cx| Workspace::new(project, cx));
885
886 // Create a new untitled buffer
887 cx.dispatch_action(window_id, NewFile);
888 let editor = workspace.read_with(cx, |workspace, cx| {
889 workspace
890 .active_item(cx)
891 .unwrap()
892 .downcast::<Editor>()
893 .unwrap()
894 });
895
896 editor.update(cx, |editor, cx| {
897 assert!(Arc::ptr_eq(
898 editor.language_at(0, cx).unwrap(),
899 &languages::PLAIN_TEXT
900 ));
901 editor.handle_input(&editor::Input("hi".into()), cx);
902 assert!(editor.is_dirty(cx.as_ref()));
903 });
904
905 // Save the buffer. This prompts for a filename.
906 let save_task = workspace.update(cx, |workspace, cx| workspace.save_active_item(false, cx));
907 cx.simulate_new_path_selection(|_| Some(PathBuf::from("/root/the-new-name.rs")));
908 save_task.await.unwrap();
909 // The buffer is not dirty anymore and the language is assigned based on the path.
910 editor.read_with(cx, |editor, cx| {
911 assert!(!editor.is_dirty(cx));
912 assert_eq!(editor.language_at(0, cx).unwrap().name().as_ref(), "Rust")
913 });
914 }
915
916 #[gpui::test]
917 async fn test_pane_actions(cx: &mut TestAppContext) {
918 init(cx);
919
920 let app_state = cx.update(AppState::test);
921 app_state
922 .fs
923 .as_fake()
924 .insert_tree(
925 "/root",
926 json!({
927 "a": {
928 "file1": "contents 1",
929 "file2": "contents 2",
930 "file3": "contents 3",
931 },
932 }),
933 )
934 .await;
935
936 let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
937 let (window_id, workspace) = cx.add_window(|cx| Workspace::new(project, cx));
938
939 let entries = cx.read(|cx| workspace.file_project_paths(cx));
940 let file1 = entries[0].clone();
941
942 let pane_1 = cx.read(|cx| workspace.read(cx).active_pane().clone());
943
944 workspace
945 .update(cx, |w, cx| w.open_path(file1.clone(), true, cx))
946 .await
947 .unwrap();
948
949 let (editor_1, buffer) = pane_1.update(cx, |pane_1, cx| {
950 let editor = pane_1.active_item().unwrap().downcast::<Editor>().unwrap();
951 assert_eq!(editor.project_path(cx), Some(file1.clone()));
952 let buffer = editor.update(cx, |editor, cx| {
953 editor.insert("dirt", cx);
954 editor.buffer().downgrade()
955 });
956 (editor.downgrade(), buffer)
957 });
958
959 cx.dispatch_action(window_id, pane::SplitRight);
960 let editor_2 = cx.update(|cx| {
961 let pane_2 = workspace.read(cx).active_pane().clone();
962 assert_ne!(pane_1, pane_2);
963
964 let pane2_item = pane_2.read(cx).active_item().unwrap();
965 assert_eq!(pane2_item.project_path(cx.as_ref()), Some(file1.clone()));
966
967 pane2_item.downcast::<Editor>().unwrap().downgrade()
968 });
969 cx.dispatch_action(window_id, workspace::CloseActiveItem);
970
971 cx.foreground().run_until_parked();
972 workspace.read_with(cx, |workspace, _| {
973 assert_eq!(workspace.panes().len(), 1);
974 assert_eq!(workspace.active_pane(), &pane_1);
975 });
976
977 cx.dispatch_action(window_id, workspace::CloseActiveItem);
978 cx.foreground().run_until_parked();
979 cx.simulate_prompt_answer(window_id, 1);
980 cx.foreground().run_until_parked();
981
982 workspace.read_with(cx, |workspace, cx| {
983 assert!(workspace.active_item(cx).is_none());
984 });
985
986 cx.assert_dropped(editor_1);
987 cx.assert_dropped(editor_2);
988 cx.assert_dropped(buffer);
989 }
990
991 #[gpui::test]
992 async fn test_navigation(cx: &mut TestAppContext) {
993 let app_state = init(cx);
994 app_state
995 .fs
996 .as_fake()
997 .insert_tree(
998 "/root",
999 json!({
1000 "a": {
1001 "file1": "contents 1\n".repeat(20),
1002 "file2": "contents 2\n".repeat(20),
1003 "file3": "contents 3\n".repeat(20),
1004 },
1005 }),
1006 )
1007 .await;
1008
1009 let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
1010 let (_, workspace) = cx.add_window(|cx| Workspace::new(project.clone(), cx));
1011
1012 let entries = cx.read(|cx| workspace.file_project_paths(cx));
1013 let file1 = entries[0].clone();
1014 let file2 = entries[1].clone();
1015 let file3 = entries[2].clone();
1016
1017 let editor1 = workspace
1018 .update(cx, |w, cx| w.open_path(file1.clone(), true, cx))
1019 .await
1020 .unwrap()
1021 .downcast::<Editor>()
1022 .unwrap();
1023 editor1.update(cx, |editor, cx| {
1024 editor.change_selections(Some(Autoscroll::Fit), cx, |s| {
1025 s.select_display_ranges([DisplayPoint::new(10, 0)..DisplayPoint::new(10, 0)])
1026 });
1027 });
1028 let editor2 = workspace
1029 .update(cx, |w, cx| w.open_path(file2.clone(), true, cx))
1030 .await
1031 .unwrap()
1032 .downcast::<Editor>()
1033 .unwrap();
1034 let editor3 = workspace
1035 .update(cx, |w, cx| w.open_path(file3.clone(), true, cx))
1036 .await
1037 .unwrap()
1038 .downcast::<Editor>()
1039 .unwrap();
1040
1041 editor3
1042 .update(cx, |editor, cx| {
1043 editor.change_selections(Some(Autoscroll::Fit), cx, |s| {
1044 s.select_display_ranges([DisplayPoint::new(12, 0)..DisplayPoint::new(12, 0)])
1045 });
1046 editor.newline(&Default::default(), cx);
1047 editor.newline(&Default::default(), cx);
1048 editor.move_down(&Default::default(), cx);
1049 editor.move_down(&Default::default(), cx);
1050 editor.save(project.clone(), cx)
1051 })
1052 .await
1053 .unwrap();
1054 editor3.update(cx, |editor, cx| {
1055 editor.set_scroll_position(vec2f(0., 12.5), cx)
1056 });
1057 assert_eq!(
1058 active_location(&workspace, cx),
1059 (file3.clone(), DisplayPoint::new(16, 0), 12.5)
1060 );
1061
1062 workspace
1063 .update(cx, |w, cx| Pane::go_back(w, None, cx))
1064 .await;
1065 assert_eq!(
1066 active_location(&workspace, cx),
1067 (file3.clone(), DisplayPoint::new(0, 0), 0.)
1068 );
1069
1070 workspace
1071 .update(cx, |w, cx| Pane::go_back(w, None, cx))
1072 .await;
1073 assert_eq!(
1074 active_location(&workspace, cx),
1075 (file2.clone(), DisplayPoint::new(0, 0), 0.)
1076 );
1077
1078 workspace
1079 .update(cx, |w, cx| Pane::go_back(w, None, cx))
1080 .await;
1081 assert_eq!(
1082 active_location(&workspace, cx),
1083 (file1.clone(), DisplayPoint::new(10, 0), 0.)
1084 );
1085
1086 workspace
1087 .update(cx, |w, cx| Pane::go_back(w, None, cx))
1088 .await;
1089 assert_eq!(
1090 active_location(&workspace, cx),
1091 (file1.clone(), DisplayPoint::new(0, 0), 0.)
1092 );
1093
1094 // Go back one more time and ensure we don't navigate past the first item in the history.
1095 workspace
1096 .update(cx, |w, cx| Pane::go_back(w, None, cx))
1097 .await;
1098 assert_eq!(
1099 active_location(&workspace, cx),
1100 (file1.clone(), DisplayPoint::new(0, 0), 0.)
1101 );
1102
1103 workspace
1104 .update(cx, |w, cx| Pane::go_forward(w, None, cx))
1105 .await;
1106 assert_eq!(
1107 active_location(&workspace, cx),
1108 (file1.clone(), DisplayPoint::new(10, 0), 0.)
1109 );
1110
1111 workspace
1112 .update(cx, |w, cx| Pane::go_forward(w, None, cx))
1113 .await;
1114 assert_eq!(
1115 active_location(&workspace, cx),
1116 (file2.clone(), DisplayPoint::new(0, 0), 0.)
1117 );
1118
1119 // Go forward to an item that has been closed, ensuring it gets re-opened at the same
1120 // location.
1121 workspace
1122 .update(cx, |workspace, cx| {
1123 let editor3_id = editor3.id();
1124 drop(editor3);
1125 Pane::close_item(workspace, workspace.active_pane().clone(), editor3_id, cx)
1126 })
1127 .await
1128 .unwrap();
1129 workspace
1130 .update(cx, |w, cx| Pane::go_forward(w, None, cx))
1131 .await;
1132 assert_eq!(
1133 active_location(&workspace, cx),
1134 (file3.clone(), DisplayPoint::new(0, 0), 0.)
1135 );
1136
1137 workspace
1138 .update(cx, |w, cx| Pane::go_forward(w, None, cx))
1139 .await;
1140 assert_eq!(
1141 active_location(&workspace, cx),
1142 (file3.clone(), DisplayPoint::new(16, 0), 12.5)
1143 );
1144
1145 workspace
1146 .update(cx, |w, cx| Pane::go_back(w, None, cx))
1147 .await;
1148 assert_eq!(
1149 active_location(&workspace, cx),
1150 (file3.clone(), DisplayPoint::new(0, 0), 0.)
1151 );
1152
1153 // Go back to an item that has been closed and removed from disk, ensuring it gets skipped.
1154 workspace
1155 .update(cx, |workspace, cx| {
1156 let editor2_id = editor2.id();
1157 drop(editor2);
1158 Pane::close_item(workspace, workspace.active_pane().clone(), editor2_id, cx)
1159 })
1160 .await
1161 .unwrap();
1162 app_state
1163 .fs
1164 .remove_file(Path::new("/root/a/file2"), Default::default())
1165 .await
1166 .unwrap();
1167 workspace
1168 .update(cx, |w, cx| Pane::go_back(w, None, cx))
1169 .await;
1170 assert_eq!(
1171 active_location(&workspace, cx),
1172 (file1.clone(), DisplayPoint::new(10, 0), 0.)
1173 );
1174 workspace
1175 .update(cx, |w, cx| Pane::go_forward(w, None, cx))
1176 .await;
1177 assert_eq!(
1178 active_location(&workspace, cx),
1179 (file3.clone(), DisplayPoint::new(0, 0), 0.)
1180 );
1181
1182 // Modify file to collapse multiple nav history entries into the same location.
1183 // Ensure we don't visit the same location twice when navigating.
1184 editor1.update(cx, |editor, cx| {
1185 editor.change_selections(None, cx, |s| {
1186 s.select_display_ranges([DisplayPoint::new(15, 0)..DisplayPoint::new(15, 0)])
1187 })
1188 });
1189
1190 for _ in 0..5 {
1191 editor1.update(cx, |editor, cx| {
1192 editor.change_selections(None, cx, |s| {
1193 s.select_display_ranges([DisplayPoint::new(3, 0)..DisplayPoint::new(3, 0)])
1194 });
1195 });
1196 editor1.update(cx, |editor, cx| {
1197 editor.change_selections(None, cx, |s| {
1198 s.select_display_ranges([DisplayPoint::new(13, 0)..DisplayPoint::new(13, 0)])
1199 })
1200 });
1201 }
1202
1203 editor1.update(cx, |editor, cx| {
1204 editor.transact(cx, |editor, cx| {
1205 editor.change_selections(None, cx, |s| {
1206 s.select_display_ranges([DisplayPoint::new(2, 0)..DisplayPoint::new(14, 0)])
1207 });
1208 editor.insert("", cx);
1209 })
1210 });
1211
1212 editor1.update(cx, |editor, cx| {
1213 editor.change_selections(None, cx, |s| {
1214 s.select_display_ranges([DisplayPoint::new(1, 0)..DisplayPoint::new(1, 0)])
1215 })
1216 });
1217 workspace
1218 .update(cx, |w, cx| Pane::go_back(w, None, cx))
1219 .await;
1220 assert_eq!(
1221 active_location(&workspace, cx),
1222 (file1.clone(), DisplayPoint::new(2, 0), 0.)
1223 );
1224 workspace
1225 .update(cx, |w, cx| Pane::go_back(w, None, cx))
1226 .await;
1227 assert_eq!(
1228 active_location(&workspace, cx),
1229 (file1.clone(), DisplayPoint::new(3, 0), 0.)
1230 );
1231
1232 fn active_location(
1233 workspace: &ViewHandle<Workspace>,
1234 cx: &mut TestAppContext,
1235 ) -> (ProjectPath, DisplayPoint, f32) {
1236 workspace.update(cx, |workspace, cx| {
1237 let item = workspace.active_item(cx).unwrap();
1238 let editor = item.downcast::<Editor>().unwrap();
1239 let (selections, scroll_position) = editor.update(cx, |editor, cx| {
1240 (
1241 editor.selections.display_ranges(cx),
1242 editor.scroll_position(cx),
1243 )
1244 });
1245 (
1246 item.project_path(cx).unwrap(),
1247 selections[0].start,
1248 scroll_position.y(),
1249 )
1250 })
1251 }
1252 }
1253
1254 #[gpui::test]
1255 fn test_bundled_themes(cx: &mut MutableAppContext) {
1256 let themes = ThemeRegistry::new(Assets, cx.font_cache().clone());
1257
1258 lazy_static::lazy_static! {
1259 static ref DEFAULT_THEME: parking_lot::Mutex<Option<Arc<Theme>>> = Default::default();
1260 static ref FONTS: Vec<Arc<Vec<u8>>> = vec![
1261 Assets.load("fonts/zed-sans/zed-sans-extended.ttf").unwrap().to_vec().into(),
1262 Assets.load("fonts/zed-mono/zed-mono-extended.ttf").unwrap().to_vec().into(),
1263 ];
1264 }
1265
1266 cx.platform().fonts().add_fonts(&FONTS).unwrap();
1267
1268 let mut has_default_theme = false;
1269 for theme_name in themes.list() {
1270 let theme = themes.get(&theme_name).unwrap();
1271 if theme.name == DEFAULT_THEME_NAME {
1272 has_default_theme = true;
1273 }
1274 assert_eq!(theme.name, theme_name);
1275 }
1276 assert!(has_default_theme);
1277 }
1278
1279 fn init(cx: &mut TestAppContext) -> Arc<AppState> {
1280 cx.foreground().forbid_parking();
1281 cx.update(|cx| {
1282 let mut app_state = AppState::test(cx);
1283 let state = Arc::get_mut(&mut app_state).unwrap();
1284 state.initialize_workspace = initialize_workspace;
1285 state.build_window_options = build_window_options;
1286 workspace::init(app_state.clone(), cx);
1287 editor::init(cx);
1288 pane::init(cx);
1289 app_state
1290 })
1291 }
1292
1293 fn rust_lang() -> Arc<language::Language> {
1294 Arc::new(language::Language::new(
1295 language::LanguageConfig {
1296 name: "Rust".into(),
1297 path_suffixes: vec!["rs".to_string()],
1298 ..Default::default()
1299 },
1300 Some(tree_sitter_rust::language()),
1301 ))
1302 }
1303}