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