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