1pub mod languages;
2pub mod menus;
3pub mod settings_file;
4#[cfg(any(test, feature = "test-support"))]
5pub mod test;
6
7use anyhow::Context;
8use breadcrumbs::Breadcrumbs;
9use chat_panel::ChatPanel;
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 platform::{WindowBounds, WindowOptions},
19 ModelHandle, ViewContext,
20};
21use lazy_static::lazy_static;
22pub use lsp;
23use project::Project;
24pub use project::{self, fs};
25use project_panel::ProjectPanel;
26use search::{BufferSearchBar, ProjectSearchBar};
27use serde_json::to_string_pretty;
28use settings::Settings;
29use std::{path::PathBuf, sync::Arc};
30use util::ResultExt;
31pub use workspace;
32use workspace::{AppState, Workspace, WorkspaceParams};
33
34actions!(
35 zed,
36 [
37 About,
38 Quit,
39 DebugElements,
40 OpenSettings,
41 IncreaseBufferFontSize,
42 DecreaseBufferFontSize,
43 InstallCommandLineTool,
44 ]
45);
46
47const MIN_FONT_SIZE: f32 = 6.0;
48
49lazy_static! {
50 pub static ref ROOT_PATH: PathBuf = dirs::home_dir()
51 .expect("failed to determine home directory")
52 .join(".zed");
53 pub static ref SETTINGS_PATH: PathBuf = ROOT_PATH.join("settings.json");
54 pub static ref KEYMAP_PATH: PathBuf = ROOT_PATH.join("keymap.json");
55}
56
57pub fn init(app_state: &Arc<AppState>, cx: &mut gpui::MutableAppContext) {
58 cx.add_global_action(quit);
59 cx.add_global_action(move |_: &IncreaseBufferFontSize, cx| {
60 cx.update_global::<Settings, _, _>(|settings, cx| {
61 settings.buffer_font_size = (settings.buffer_font_size + 1.0).max(MIN_FONT_SIZE);
62 cx.refresh_windows();
63 });
64 });
65 cx.add_global_action(move |_: &DecreaseBufferFontSize, cx| {
66 cx.update_global::<Settings, _, _>(|settings, cx| {
67 settings.buffer_font_size = (settings.buffer_font_size - 1.0).max(MIN_FONT_SIZE);
68 cx.refresh_windows();
69 });
70 });
71 cx.add_global_action(move |_: &InstallCommandLineTool, cx| {
72 cx.spawn(|cx| async move {
73 log::info!("installing command line launcher");
74 let cli_path = cx
75 .platform()
76 .path_for_auxiliary_executable("cli")
77 .log_err()?;
78 let link_path = "/opt/homebrew/bin/zed";
79 smol::fs::unix::symlink(cli_path.as_path(), link_path)
80 .await
81 .context("failed to install cli symlink")
82 .log_err()?;
83 log::info!(
84 "created symlink {} -> {}",
85 link_path,
86 cli_path.to_string_lossy()
87 );
88 Some(())
89 })
90 .detach();
91 });
92 cx.add_action({
93 let app_state = app_state.clone();
94 move |_: &mut Workspace, _: &OpenSettings, cx: &mut ViewContext<Workspace>| {
95 let app_state = app_state.clone();
96 cx.spawn(move |workspace, mut cx| async move {
97 let fs = &app_state.fs;
98 if !fs.is_file(&SETTINGS_PATH).await {
99 fs.create_dir(&ROOT_PATH).await?;
100 fs.create_file(&SETTINGS_PATH, Default::default()).await?;
101 }
102
103 workspace
104 .update(&mut cx, |workspace, cx| {
105 if workspace.project().read(cx).is_local() {
106 workspace.open_paths(&[SETTINGS_PATH.clone()], cx)
107 } else {
108 let (_, workspace) =
109 cx.add_window((app_state.build_window_options)(), |cx| {
110 let project = Project::local(
111 app_state.client.clone(),
112 app_state.user_store.clone(),
113 app_state.languages.clone(),
114 app_state.fs.clone(),
115 cx,
116 );
117 (app_state.build_workspace)(project, &app_state, cx)
118 });
119 workspace.update(cx, |workspace, cx| {
120 workspace.open_paths(&[SETTINGS_PATH.clone()], cx)
121 })
122 }
123 })
124 .await;
125 Ok::<_, anyhow::Error>(())
126 })
127 .detach_and_log_err(cx);
128 }
129 });
130 cx.add_action(
131 |workspace: &mut Workspace, _: &DebugElements, cx: &mut ViewContext<Workspace>| {
132 let content = to_string_pretty(&cx.debug_elements()).unwrap();
133 let project = workspace.project().clone();
134 let json_language = project.read(cx).languages().get_language("JSON").unwrap();
135 if project.read(cx).is_remote() {
136 cx.propagate_action();
137 } else if let Some(buffer) = project
138 .update(cx, |project, cx| {
139 project.create_buffer(&content, Some(json_language), cx)
140 })
141 .log_err()
142 {
143 workspace.add_item(
144 Box::new(
145 cx.add_view(|cx| Editor::for_buffer(buffer, Some(project.clone()), cx)),
146 ),
147 cx,
148 );
149 }
150 },
151 );
152
153 workspace::lsp_status::init(cx);
154
155 settings::KeymapFile::load_defaults(cx);
156}
157
158pub fn build_workspace(
159 project: ModelHandle<Project>,
160 app_state: &Arc<AppState>,
161 cx: &mut ViewContext<Workspace>,
162) -> Workspace {
163 cx.subscribe(&cx.handle(), {
164 let project = project.clone();
165 move |_, _, event, cx| {
166 let workspace::Event::PaneAdded(pane) = event;
167 pane.update(cx, |pane, cx| {
168 pane.toolbar().update(cx, |toolbar, cx| {
169 let breadcrumbs = cx.add_view(|_| Breadcrumbs::new(project.clone()));
170 toolbar.add_item(breadcrumbs, cx);
171 let buffer_search_bar = cx.add_view(|cx| BufferSearchBar::new(cx));
172 toolbar.add_item(buffer_search_bar, cx);
173 let project_search_bar = cx.add_view(|_| ProjectSearchBar::new());
174 toolbar.add_item(project_search_bar, cx);
175 })
176 });
177 }
178 })
179 .detach();
180
181 let workspace_params = WorkspaceParams {
182 project,
183 client: app_state.client.clone(),
184 fs: app_state.fs.clone(),
185 languages: app_state.languages.clone(),
186 themes: app_state.themes.clone(),
187 user_store: app_state.user_store.clone(),
188 channel_list: app_state.channel_list.clone(),
189 };
190 let mut workspace = Workspace::new(&workspace_params, cx);
191 let project = workspace.project().clone();
192
193 let theme_names = app_state.themes.list().collect();
194 let language_names = app_state.languages.language_names();
195
196 project.update(cx, |project, _| {
197 project.set_language_server_settings(serde_json::json!({
198 "json": {
199 "schemas": [
200 {
201 "fileMatch": "**/.zed/settings.json",
202 "schema": Settings::file_json_schema(theme_names, language_names),
203 }
204 ]
205 }
206 }));
207 });
208
209 workspace.left_sidebar_mut().add_item(
210 "icons/folder-tree-16.svg",
211 ProjectPanel::new(project, cx).into(),
212 );
213 workspace.right_sidebar_mut().add_item(
214 "icons/user-16.svg",
215 cx.add_view(|cx| ContactsPanel::new(app_state.clone(), cx))
216 .into(),
217 );
218 workspace.right_sidebar_mut().add_item(
219 "icons/comment-16.svg",
220 cx.add_view(|cx| {
221 ChatPanel::new(app_state.client.clone(), app_state.channel_list.clone(), cx)
222 })
223 .into(),
224 );
225
226 let diagnostic_message = cx.add_view(|_| editor::items::DiagnosticMessage::new());
227 let diagnostic_summary =
228 cx.add_view(|cx| diagnostics::items::DiagnosticSummary::new(workspace.project(), cx));
229 let lsp_status = cx.add_view(|cx| {
230 workspace::lsp_status::LspStatus::new(workspace.project(), app_state.languages.clone(), cx)
231 });
232 let cursor_position = cx.add_view(|_| editor::items::CursorPosition::new());
233 workspace.status_bar().update(cx, |status_bar, cx| {
234 status_bar.add_left_item(diagnostic_summary, cx);
235 status_bar.add_left_item(diagnostic_message, cx);
236 status_bar.add_left_item(lsp_status, cx);
237 status_bar.add_right_item(cursor_position, cx);
238 });
239
240 workspace
241}
242
243pub fn build_window_options() -> WindowOptions<'static> {
244 WindowOptions {
245 bounds: WindowBounds::Maximized,
246 title: None,
247 titlebar_appears_transparent: true,
248 traffic_light_position: Some(vec2f(8., 8.)),
249 }
250}
251
252fn quit(_: &Quit, cx: &mut gpui::MutableAppContext) {
253 cx.platform().quit();
254}
255
256#[cfg(test)]
257mod tests {
258 use super::*;
259 use assets::Assets;
260 use editor::{DisplayPoint, Editor};
261 use gpui::{AssetSource, MutableAppContext, TestAppContext, ViewHandle};
262 use project::{Fs, ProjectPath};
263 use serde_json::json;
264 use std::{
265 collections::HashSet,
266 path::{Path, PathBuf},
267 };
268 use test::test_app_state;
269 use theme::{Theme, ThemeRegistry, DEFAULT_THEME_NAME};
270 use util::test::temp_tree;
271 use workspace::{
272 open_paths, pane, Item, ItemHandle, OpenNew, Pane, SplitDirection, WorkspaceHandle,
273 };
274
275 #[gpui::test]
276 async fn test_open_paths_action(cx: &mut TestAppContext) {
277 let app_state = cx.update(test_app_state);
278 let dir = temp_tree(json!({
279 "a": {
280 "aa": null,
281 "ab": null,
282 },
283 "b": {
284 "ba": null,
285 "bb": null,
286 },
287 "c": {
288 "ca": null,
289 "cb": null,
290 },
291 }));
292
293 cx.update(|cx| {
294 open_paths(
295 &[
296 dir.path().join("a").to_path_buf(),
297 dir.path().join("b").to_path_buf(),
298 ],
299 &app_state,
300 cx,
301 )
302 })
303 .await;
304 assert_eq!(cx.window_ids().len(), 1);
305
306 cx.update(|cx| open_paths(&[dir.path().join("a").to_path_buf()], &app_state, cx))
307 .await;
308 assert_eq!(cx.window_ids().len(), 1);
309 let workspace_1 = cx.root_view::<Workspace>(cx.window_ids()[0]).unwrap();
310 workspace_1.read_with(cx, |workspace, cx| {
311 assert_eq!(workspace.worktrees(cx).count(), 2)
312 });
313
314 cx.update(|cx| {
315 open_paths(
316 &[
317 dir.path().join("b").to_path_buf(),
318 dir.path().join("c").to_path_buf(),
319 ],
320 &app_state,
321 cx,
322 )
323 })
324 .await;
325 assert_eq!(cx.window_ids().len(), 2);
326 }
327
328 #[gpui::test]
329 async fn test_new_empty_workspace(cx: &mut TestAppContext) {
330 let app_state = cx.update(test_app_state);
331 cx.update(|cx| {
332 workspace::init(&app_state.client, cx);
333 });
334 cx.dispatch_global_action(workspace::OpenNew(app_state.clone()));
335 let window_id = *cx.window_ids().first().unwrap();
336 let workspace = cx.root_view::<Workspace>(window_id).unwrap();
337 let editor = workspace.update(cx, |workspace, cx| {
338 workspace
339 .active_item(cx)
340 .unwrap()
341 .downcast::<editor::Editor>()
342 .unwrap()
343 });
344
345 editor.update(cx, |editor, cx| {
346 assert!(editor.text(cx).is_empty());
347 });
348
349 let save_task = workspace.update(cx, |workspace, cx| workspace.save_active_item(cx));
350 app_state.fs.as_fake().insert_dir("/root").await;
351 cx.simulate_new_path_selection(|_| Some(PathBuf::from("/root/the-new-name")));
352 save_task.await.unwrap();
353 editor.read_with(cx, |editor, cx| {
354 assert!(!editor.is_dirty(cx));
355 assert_eq!(editor.title(cx), "the-new-name");
356 });
357 }
358
359 #[gpui::test]
360 async fn test_open_entry(cx: &mut TestAppContext) {
361 let app_state = cx.update(test_app_state);
362 app_state
363 .fs
364 .as_fake()
365 .insert_tree(
366 "/root",
367 json!({
368 "a": {
369 "file1": "contents 1",
370 "file2": "contents 2",
371 "file3": "contents 3",
372 },
373 }),
374 )
375 .await;
376 let params = cx.update(|cx| WorkspaceParams::local(&app_state, cx));
377 let (_, workspace) = cx.add_window(|cx| Workspace::new(¶ms, cx));
378 params
379 .project
380 .update(cx, |project, cx| {
381 project.find_or_create_local_worktree("/root", true, cx)
382 })
383 .await
384 .unwrap();
385
386 cx.read(|cx| workspace.read(cx).worktree_scans_complete(cx))
387 .await;
388 let entries = cx.read(|cx| workspace.file_project_paths(cx));
389 let file1 = entries[0].clone();
390 let file2 = entries[1].clone();
391 let file3 = entries[2].clone();
392
393 // Open the first entry
394 let entry_1 = workspace
395 .update(cx, |w, cx| w.open_path(file1.clone(), cx))
396 .await
397 .unwrap();
398 cx.read(|cx| {
399 let pane = workspace.read(cx).active_pane().read(cx);
400 assert_eq!(
401 pane.active_item().unwrap().project_path(cx),
402 Some(file1.clone())
403 );
404 assert_eq!(pane.items().count(), 1);
405 });
406
407 // Open the second entry
408 workspace
409 .update(cx, |w, cx| w.open_path(file2.clone(), cx))
410 .await
411 .unwrap();
412 cx.read(|cx| {
413 let pane = workspace.read(cx).active_pane().read(cx);
414 assert_eq!(
415 pane.active_item().unwrap().project_path(cx),
416 Some(file2.clone())
417 );
418 assert_eq!(pane.items().count(), 2);
419 });
420
421 // Open the first entry again. The existing pane item is activated.
422 let entry_1b = workspace
423 .update(cx, |w, cx| w.open_path(file1.clone(), cx))
424 .await
425 .unwrap();
426 assert_eq!(entry_1.id(), entry_1b.id());
427
428 cx.read(|cx| {
429 let pane = workspace.read(cx).active_pane().read(cx);
430 assert_eq!(
431 pane.active_item().unwrap().project_path(cx),
432 Some(file1.clone())
433 );
434 assert_eq!(pane.items().count(), 2);
435 });
436
437 // Split the pane with the first entry, then open the second entry again.
438 workspace
439 .update(cx, |w, cx| {
440 w.split_pane(w.active_pane().clone(), SplitDirection::Right, cx);
441 w.open_path(file2.clone(), cx)
442 })
443 .await
444 .unwrap();
445
446 workspace.read_with(cx, |w, cx| {
447 assert_eq!(
448 w.active_pane()
449 .read(cx)
450 .active_item()
451 .unwrap()
452 .project_path(cx.as_ref()),
453 Some(file2.clone())
454 );
455 });
456
457 // Open the third entry twice concurrently. Only one pane item is added.
458 let (t1, t2) = workspace.update(cx, |w, cx| {
459 (
460 w.open_path(file3.clone(), cx),
461 w.open_path(file3.clone(), cx),
462 )
463 });
464 t1.await.unwrap();
465 t2.await.unwrap();
466 cx.read(|cx| {
467 let pane = workspace.read(cx).active_pane().read(cx);
468 assert_eq!(
469 pane.active_item().unwrap().project_path(cx),
470 Some(file3.clone())
471 );
472 let pane_entries = pane
473 .items()
474 .map(|i| i.project_path(cx).unwrap())
475 .collect::<Vec<_>>();
476 assert_eq!(pane_entries, &[file1, file2, file3]);
477 });
478 }
479
480 #[gpui::test]
481 async fn test_open_paths(cx: &mut TestAppContext) {
482 let app_state = cx.update(test_app_state);
483 let fs = app_state.fs.as_fake();
484 fs.insert_dir("/dir1").await;
485 fs.insert_dir("/dir2").await;
486 fs.insert_file("/dir1/a.txt", "".into()).await;
487 fs.insert_file("/dir2/b.txt", "".into()).await;
488
489 let params = cx.update(|cx| WorkspaceParams::local(&app_state, cx));
490 let (_, workspace) = cx.add_window(|cx| Workspace::new(¶ms, cx));
491 params
492 .project
493 .update(cx, |project, cx| {
494 project.find_or_create_local_worktree("/dir1", true, cx)
495 })
496 .await
497 .unwrap();
498 cx.read(|cx| workspace.read(cx).worktree_scans_complete(cx))
499 .await;
500
501 // Open a file within an existing worktree.
502 cx.update(|cx| {
503 workspace.update(cx, |view, cx| view.open_paths(&["/dir1/a.txt".into()], cx))
504 })
505 .await;
506 cx.read(|cx| {
507 assert_eq!(
508 workspace
509 .read(cx)
510 .active_pane()
511 .read(cx)
512 .active_item()
513 .unwrap()
514 .to_any()
515 .downcast::<Editor>()
516 .unwrap()
517 .read(cx)
518 .title(cx),
519 "a.txt"
520 );
521 });
522
523 // Open a file outside of any existing worktree.
524 cx.update(|cx| {
525 workspace.update(cx, |view, cx| view.open_paths(&["/dir2/b.txt".into()], cx))
526 })
527 .await;
528 cx.read(|cx| {
529 let worktree_roots = workspace
530 .read(cx)
531 .worktrees(cx)
532 .map(|w| w.read(cx).as_local().unwrap().abs_path().as_ref())
533 .collect::<HashSet<_>>();
534 assert_eq!(
535 worktree_roots,
536 vec!["/dir1", "/dir2/b.txt"]
537 .into_iter()
538 .map(Path::new)
539 .collect(),
540 );
541 assert_eq!(
542 workspace
543 .read(cx)
544 .active_pane()
545 .read(cx)
546 .active_item()
547 .unwrap()
548 .to_any()
549 .downcast::<Editor>()
550 .unwrap()
551 .read(cx)
552 .title(cx),
553 "b.txt"
554 );
555 });
556 }
557
558 #[gpui::test]
559 async fn test_save_conflicting_item(cx: &mut TestAppContext) {
560 let app_state = cx.update(test_app_state);
561 let fs = app_state.fs.as_fake();
562 fs.insert_tree("/root", json!({ "a.txt": "" })).await;
563
564 let params = cx.update(|cx| WorkspaceParams::local(&app_state, cx));
565 let (window_id, workspace) = cx.add_window(|cx| Workspace::new(¶ms, cx));
566 params
567 .project
568 .update(cx, |project, cx| {
569 project.find_or_create_local_worktree("/root", true, cx)
570 })
571 .await
572 .unwrap();
573
574 // Open a file within an existing worktree.
575 cx.update(|cx| {
576 workspace.update(cx, |view, cx| {
577 view.open_paths(&[PathBuf::from("/root/a.txt")], cx)
578 })
579 })
580 .await;
581 let editor = cx.read(|cx| {
582 let pane = workspace.read(cx).active_pane().read(cx);
583 let item = pane.active_item().unwrap();
584 item.downcast::<Editor>().unwrap()
585 });
586
587 cx.update(|cx| {
588 editor.update(cx, |editor, cx| {
589 editor.handle_input(&editor::Input("x".into()), cx)
590 })
591 });
592 fs.insert_file("/root/a.txt", "changed".to_string()).await;
593 editor
594 .condition(&cx, |editor, cx| editor.has_conflict(cx))
595 .await;
596 cx.read(|cx| assert!(editor.is_dirty(cx)));
597
598 let save_task = workspace.update(cx, |workspace, cx| workspace.save_active_item(cx));
599 cx.simulate_prompt_answer(window_id, 0);
600 save_task.await.unwrap();
601 editor.read_with(cx, |editor, cx| {
602 assert!(!editor.is_dirty(cx));
603 assert!(!editor.has_conflict(cx));
604 });
605 }
606
607 #[gpui::test]
608 async fn test_open_and_save_new_file(cx: &mut TestAppContext) {
609 let app_state = cx.update(test_app_state);
610 app_state.fs.as_fake().insert_dir("/root").await;
611 let params = cx.update(|cx| WorkspaceParams::local(&app_state, cx));
612 let (window_id, workspace) = cx.add_window(|cx| Workspace::new(¶ms, cx));
613 params
614 .project
615 .update(cx, |project, cx| {
616 project.find_or_create_local_worktree("/root", true, cx)
617 })
618 .await
619 .unwrap();
620 let worktree = cx.read(|cx| workspace.read(cx).worktrees(cx).next().unwrap());
621
622 // Create a new untitled buffer
623 cx.dispatch_action(window_id, OpenNew(app_state.clone()));
624 let editor = workspace.read_with(cx, |workspace, cx| {
625 workspace
626 .active_item(cx)
627 .unwrap()
628 .downcast::<Editor>()
629 .unwrap()
630 });
631
632 editor.update(cx, |editor, cx| {
633 assert!(!editor.is_dirty(cx));
634 assert_eq!(editor.title(cx), "untitled");
635 assert!(Arc::ptr_eq(
636 editor.language_at(0, cx).unwrap(),
637 &languages::PLAIN_TEXT
638 ));
639 editor.handle_input(&editor::Input("hi".into()), cx);
640 assert!(editor.is_dirty(cx));
641 });
642
643 // Save the buffer. This prompts for a filename.
644 let save_task = workspace.update(cx, |workspace, cx| workspace.save_active_item(cx));
645 cx.simulate_new_path_selection(|parent_dir| {
646 assert_eq!(parent_dir, Path::new("/root"));
647 Some(parent_dir.join("the-new-name.rs"))
648 });
649 cx.read(|cx| {
650 assert!(editor.is_dirty(cx));
651 assert_eq!(editor.read(cx).title(cx), "untitled");
652 });
653
654 // When the save completes, the buffer's title is updated and the language is assigned based
655 // on the path.
656 save_task.await.unwrap();
657 editor.read_with(cx, |editor, cx| {
658 assert!(!editor.is_dirty(cx));
659 assert_eq!(editor.title(cx), "the-new-name.rs");
660 assert_eq!(editor.language_at(0, cx).unwrap().name().as_ref(), "Rust");
661 });
662
663 // Edit the file and save it again. This time, there is no filename prompt.
664 editor.update(cx, |editor, cx| {
665 editor.handle_input(&editor::Input(" there".into()), cx);
666 assert_eq!(editor.is_dirty(cx.as_ref()), true);
667 });
668 let save_task = workspace.update(cx, |workspace, cx| workspace.save_active_item(cx));
669 save_task.await.unwrap();
670 assert!(!cx.did_prompt_for_new_path());
671 editor.read_with(cx, |editor, cx| {
672 assert!(!editor.is_dirty(cx));
673 assert_eq!(editor.title(cx), "the-new-name.rs")
674 });
675
676 // Open the same newly-created file in another pane item. The new editor should reuse
677 // the same buffer.
678 cx.dispatch_action(window_id, OpenNew(app_state.clone()));
679 workspace
680 .update(cx, |workspace, cx| {
681 workspace.split_pane(workspace.active_pane().clone(), SplitDirection::Right, cx);
682 workspace.open_path(
683 ProjectPath {
684 worktree_id: worktree.read(cx).id(),
685 path: Path::new("the-new-name.rs").into(),
686 },
687 cx,
688 )
689 })
690 .await
691 .unwrap();
692 let editor2 = workspace.update(cx, |workspace, cx| {
693 workspace
694 .active_item(cx)
695 .unwrap()
696 .downcast::<Editor>()
697 .unwrap()
698 });
699 cx.read(|cx| {
700 assert_eq!(
701 editor2.read(cx).buffer().read(cx).as_singleton().unwrap(),
702 editor.read(cx).buffer().read(cx).as_singleton().unwrap()
703 );
704 })
705 }
706
707 #[gpui::test]
708 async fn test_setting_language_when_saving_as_single_file_worktree(cx: &mut TestAppContext) {
709 let app_state = cx.update(test_app_state);
710 app_state.fs.as_fake().insert_dir("/root").await;
711 let params = cx.update(|cx| WorkspaceParams::local(&app_state, cx));
712 let (window_id, workspace) = cx.add_window(|cx| Workspace::new(¶ms, cx));
713
714 // Create a new untitled buffer
715 cx.dispatch_action(window_id, OpenNew(app_state.clone()));
716 let editor = workspace.read_with(cx, |workspace, cx| {
717 workspace
718 .active_item(cx)
719 .unwrap()
720 .downcast::<Editor>()
721 .unwrap()
722 });
723
724 editor.update(cx, |editor, cx| {
725 assert!(Arc::ptr_eq(
726 editor.language_at(0, cx).unwrap(),
727 &languages::PLAIN_TEXT
728 ));
729 editor.handle_input(&editor::Input("hi".into()), cx);
730 assert!(editor.is_dirty(cx.as_ref()));
731 });
732
733 // Save the buffer. This prompts for a filename.
734 let save_task = workspace.update(cx, |workspace, cx| workspace.save_active_item(cx));
735 cx.simulate_new_path_selection(|_| Some(PathBuf::from("/root/the-new-name.rs")));
736 save_task.await.unwrap();
737 // The buffer is not dirty anymore and the language is assigned based on the path.
738 editor.read_with(cx, |editor, cx| {
739 assert!(!editor.is_dirty(cx));
740 assert_eq!(editor.language_at(0, cx).unwrap().name().as_ref(), "Rust")
741 });
742 }
743
744 #[gpui::test]
745 async fn test_pane_actions(cx: &mut TestAppContext) {
746 cx.foreground().forbid_parking();
747
748 cx.update(|cx| pane::init(cx));
749 let app_state = cx.update(test_app_state);
750 app_state
751 .fs
752 .as_fake()
753 .insert_tree(
754 "/root",
755 json!({
756 "a": {
757 "file1": "contents 1",
758 "file2": "contents 2",
759 "file3": "contents 3",
760 },
761 }),
762 )
763 .await;
764
765 let params = cx.update(|cx| WorkspaceParams::local(&app_state, cx));
766 let (window_id, workspace) = cx.add_window(|cx| Workspace::new(¶ms, cx));
767 params
768 .project
769 .update(cx, |project, cx| {
770 project.find_or_create_local_worktree("/root", true, cx)
771 })
772 .await
773 .unwrap();
774 cx.read(|cx| workspace.read(cx).worktree_scans_complete(cx))
775 .await;
776 let entries = cx.read(|cx| workspace.file_project_paths(cx));
777 let file1 = entries[0].clone();
778
779 let pane_1 = cx.read(|cx| workspace.read(cx).active_pane().clone());
780
781 workspace
782 .update(cx, |w, cx| w.open_path(file1.clone(), cx))
783 .await
784 .unwrap();
785
786 let (editor_1, buffer) = pane_1.update(cx, |pane_1, cx| {
787 let editor = pane_1.active_item().unwrap().downcast::<Editor>().unwrap();
788 assert_eq!(editor.project_path(cx), Some(file1.clone()));
789 let buffer = editor.update(cx, |editor, cx| {
790 editor.insert("dirt", cx);
791 editor.buffer().downgrade()
792 });
793 (editor.downgrade(), buffer)
794 });
795
796 cx.dispatch_action(window_id, pane::Split(SplitDirection::Right));
797 let editor_2 = cx.update(|cx| {
798 let pane_2 = workspace.read(cx).active_pane().clone();
799 assert_ne!(pane_1, pane_2);
800
801 let pane2_item = pane_2.read(cx).active_item().unwrap();
802 assert_eq!(pane2_item.project_path(cx.as_ref()), Some(file1.clone()));
803
804 pane2_item.downcast::<Editor>().unwrap().downgrade()
805 });
806 cx.dispatch_action(window_id, workspace::CloseActiveItem);
807
808 cx.foreground().run_until_parked();
809 workspace.read_with(cx, |workspace, _| {
810 assert_eq!(workspace.panes().len(), 1);
811 assert_eq!(workspace.active_pane(), &pane_1);
812 });
813
814 cx.dispatch_action(window_id, workspace::CloseActiveItem);
815 cx.foreground().run_until_parked();
816 cx.simulate_prompt_answer(window_id, 1);
817 cx.foreground().run_until_parked();
818
819 workspace.read_with(cx, |workspace, cx| {
820 assert!(workspace.active_item(cx).is_none());
821 });
822
823 cx.assert_dropped(editor_1);
824 cx.assert_dropped(editor_2);
825 cx.assert_dropped(buffer);
826 }
827
828 #[gpui::test]
829 async fn test_navigation(cx: &mut TestAppContext) {
830 let app_state = cx.update(test_app_state);
831 app_state
832 .fs
833 .as_fake()
834 .insert_tree(
835 "/root",
836 json!({
837 "a": {
838 "file1": "contents 1\n".repeat(20),
839 "file2": "contents 2\n".repeat(20),
840 "file3": "contents 3\n".repeat(20),
841 },
842 }),
843 )
844 .await;
845 let params = cx.update(|cx| WorkspaceParams::local(&app_state, cx));
846 let (_, workspace) = cx.add_window(|cx| Workspace::new(¶ms, cx));
847 params
848 .project
849 .update(cx, |project, cx| {
850 project.find_or_create_local_worktree("/root", true, cx)
851 })
852 .await
853 .unwrap();
854 cx.read(|cx| workspace.read(cx).worktree_scans_complete(cx))
855 .await;
856 let entries = cx.read(|cx| workspace.file_project_paths(cx));
857 let file1 = entries[0].clone();
858 let file2 = entries[1].clone();
859 let file3 = entries[2].clone();
860
861 let editor1 = workspace
862 .update(cx, |w, cx| w.open_path(file1.clone(), cx))
863 .await
864 .unwrap()
865 .downcast::<Editor>()
866 .unwrap();
867 editor1.update(cx, |editor, cx| {
868 editor.select_display_ranges(&[DisplayPoint::new(10, 0)..DisplayPoint::new(10, 0)], cx);
869 });
870 let editor2 = workspace
871 .update(cx, |w, cx| w.open_path(file2.clone(), cx))
872 .await
873 .unwrap()
874 .downcast::<Editor>()
875 .unwrap();
876 let editor3 = workspace
877 .update(cx, |w, cx| w.open_path(file3.clone(), cx))
878 .await
879 .unwrap()
880 .downcast::<Editor>()
881 .unwrap();
882 editor3.update(cx, |editor, cx| {
883 editor.select_display_ranges(&[DisplayPoint::new(15, 0)..DisplayPoint::new(15, 0)], cx);
884 });
885 assert_eq!(
886 active_location(&workspace, cx),
887 (file3.clone(), DisplayPoint::new(15, 0))
888 );
889
890 workspace
891 .update(cx, |w, cx| Pane::go_back(w, None, cx))
892 .await;
893 assert_eq!(
894 active_location(&workspace, cx),
895 (file3.clone(), DisplayPoint::new(0, 0))
896 );
897
898 workspace
899 .update(cx, |w, cx| Pane::go_back(w, None, cx))
900 .await;
901 assert_eq!(
902 active_location(&workspace, cx),
903 (file2.clone(), DisplayPoint::new(0, 0))
904 );
905
906 workspace
907 .update(cx, |w, cx| Pane::go_back(w, None, cx))
908 .await;
909 assert_eq!(
910 active_location(&workspace, cx),
911 (file1.clone(), DisplayPoint::new(10, 0))
912 );
913
914 workspace
915 .update(cx, |w, cx| Pane::go_back(w, None, cx))
916 .await;
917 assert_eq!(
918 active_location(&workspace, cx),
919 (file1.clone(), DisplayPoint::new(0, 0))
920 );
921
922 // Go back one more time and ensure we don't navigate past the first item in the history.
923 workspace
924 .update(cx, |w, cx| Pane::go_back(w, None, cx))
925 .await;
926 assert_eq!(
927 active_location(&workspace, cx),
928 (file1.clone(), DisplayPoint::new(0, 0))
929 );
930
931 workspace
932 .update(cx, |w, cx| Pane::go_forward(w, None, cx))
933 .await;
934 assert_eq!(
935 active_location(&workspace, cx),
936 (file1.clone(), DisplayPoint::new(10, 0))
937 );
938
939 workspace
940 .update(cx, |w, cx| Pane::go_forward(w, None, cx))
941 .await;
942 assert_eq!(
943 active_location(&workspace, cx),
944 (file2.clone(), DisplayPoint::new(0, 0))
945 );
946
947 // Go forward to an item that has been closed, ensuring it gets re-opened at the same
948 // location.
949 workspace
950 .update(cx, |workspace, cx| {
951 let editor3_id = editor3.id();
952 drop(editor3);
953 Pane::close_item(workspace, workspace.active_pane().clone(), editor3_id, cx)
954 })
955 .await
956 .unwrap();
957 workspace
958 .update(cx, |w, cx| Pane::go_forward(w, None, cx))
959 .await;
960 assert_eq!(
961 active_location(&workspace, cx),
962 (file3.clone(), DisplayPoint::new(0, 0))
963 );
964
965 // Go back to an item that has been closed and removed from disk, ensuring it gets skipped.
966 workspace
967 .update(cx, |workspace, cx| {
968 let editor2_id = editor2.id();
969 drop(editor2);
970 Pane::close_item(workspace, workspace.active_pane().clone(), editor2_id, cx)
971 })
972 .await
973 .unwrap();
974 app_state
975 .fs
976 .as_fake()
977 .remove_file(Path::new("/root/a/file2"), Default::default())
978 .await
979 .unwrap();
980 workspace
981 .update(cx, |w, cx| Pane::go_back(w, None, cx))
982 .await;
983 assert_eq!(
984 active_location(&workspace, cx),
985 (file1.clone(), DisplayPoint::new(10, 0))
986 );
987 workspace
988 .update(cx, |w, cx| Pane::go_forward(w, None, cx))
989 .await;
990 assert_eq!(
991 active_location(&workspace, cx),
992 (file3.clone(), DisplayPoint::new(0, 0))
993 );
994
995 // Modify file to remove nav history location, and ensure duplicates are skipped
996 editor1.update(cx, |editor, cx| {
997 editor.select_display_ranges(&[DisplayPoint::new(15, 0)..DisplayPoint::new(15, 0)], cx)
998 });
999
1000 for _ in 0..5 {
1001 editor1.update(cx, |editor, cx| {
1002 editor
1003 .select_display_ranges(&[DisplayPoint::new(3, 0)..DisplayPoint::new(3, 0)], cx);
1004 });
1005 editor1.update(cx, |editor, cx| {
1006 editor.select_display_ranges(
1007 &[DisplayPoint::new(13, 0)..DisplayPoint::new(13, 0)],
1008 cx,
1009 )
1010 });
1011 }
1012
1013 editor1.update(cx, |editor, cx| {
1014 editor.transact(cx, |editor, cx| {
1015 editor.select_display_ranges(
1016 &[DisplayPoint::new(2, 0)..DisplayPoint::new(14, 0)],
1017 cx,
1018 );
1019 editor.insert("", cx);
1020 })
1021 });
1022
1023 editor1.update(cx, |editor, cx| {
1024 editor.select_display_ranges(&[DisplayPoint::new(1, 0)..DisplayPoint::new(1, 0)], cx)
1025 });
1026 workspace
1027 .update(cx, |w, cx| Pane::go_back(w, None, cx))
1028 .await;
1029 assert_eq!(
1030 active_location(&workspace, cx),
1031 (file1.clone(), DisplayPoint::new(2, 0))
1032 );
1033 workspace
1034 .update(cx, |w, cx| Pane::go_back(w, None, cx))
1035 .await;
1036 assert_eq!(
1037 active_location(&workspace, cx),
1038 (file1.clone(), DisplayPoint::new(3, 0))
1039 );
1040
1041 fn active_location(
1042 workspace: &ViewHandle<Workspace>,
1043 cx: &mut TestAppContext,
1044 ) -> (ProjectPath, DisplayPoint) {
1045 workspace.update(cx, |workspace, cx| {
1046 let item = workspace.active_item(cx).unwrap();
1047 let editor = item.downcast::<Editor>().unwrap();
1048 let selections = editor.update(cx, |editor, cx| editor.selected_display_ranges(cx));
1049 (item.project_path(cx).unwrap(), selections[0].start)
1050 })
1051 }
1052 }
1053
1054 #[gpui::test]
1055 fn test_bundled_themes(cx: &mut MutableAppContext) {
1056 let themes = ThemeRegistry::new(Assets, cx.font_cache().clone());
1057
1058 lazy_static::lazy_static! {
1059 static ref DEFAULT_THEME: parking_lot::Mutex<Option<Arc<Theme>>> = Default::default();
1060 static ref FONTS: Vec<Arc<Vec<u8>>> = vec![
1061 Assets.load("fonts/zed-sans/zed-sans-extended.ttf").unwrap().to_vec().into(),
1062 Assets.load("fonts/zed-mono/zed-mono-extended.ttf").unwrap().to_vec().into(),
1063 ];
1064 }
1065
1066 cx.platform().fonts().add_fonts(&FONTS).unwrap();
1067
1068 let mut has_default_theme = false;
1069 for theme_name in themes.list() {
1070 let theme = themes.get(&theme_name).unwrap();
1071 if theme.name == DEFAULT_THEME_NAME {
1072 has_default_theme = true;
1073 }
1074 assert_eq!(theme.name, theme_name);
1075 }
1076 assert!(has_default_theme);
1077 }
1078}