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