1use db::kvp::KeyValueStore;
2use dev_container::find_configs_in_snapshot;
3use gpui::{SharedString, Window};
4use project::{Project, WorktreeId};
5use std::sync::LazyLock;
6use ui::Tooltip;
7use ui::prelude::*;
8use util::ResultExt;
9use util::rel_path::RelPath;
10use workspace::Workspace;
11use workspace::notifications::NotificationId;
12use workspace::notifications::simple_message_notification::MessageNotification;
13use worktree::UpdatedEntriesSet;
14
15const DEV_CONTAINER_SUGGEST_KEY: &str = "dev_container_suggest_dismissed";
16
17fn devcontainer_dir_path() -> &'static RelPath {
18 static PATH: LazyLock<&'static RelPath> =
19 LazyLock::new(|| RelPath::unix(".devcontainer").expect("valid path"));
20 *PATH
21}
22
23fn devcontainer_json_path() -> &'static RelPath {
24 static PATH: LazyLock<&'static RelPath> =
25 LazyLock::new(|| RelPath::unix(".devcontainer.json").expect("valid path"));
26 *PATH
27}
28
29fn project_devcontainer_key(project_path: &str) -> String {
30 format!("{}_{}", DEV_CONTAINER_SUGGEST_KEY, project_path)
31}
32
33pub fn suggest_on_worktree_updated(
34 workspace: &mut Workspace,
35 worktree_id: WorktreeId,
36 updated_entries: &UpdatedEntriesSet,
37 project: &gpui::Entity<Project>,
38 window: &mut Window,
39 cx: &mut Context<Workspace>,
40) {
41 let cli_auto_open = workspace.open_in_dev_container();
42
43 let devcontainer_updated = updated_entries.iter().any(|(path, _, _)| {
44 path.as_ref() == devcontainer_dir_path() || path.as_ref() == devcontainer_json_path()
45 });
46
47 if !devcontainer_updated && !cli_auto_open {
48 return;
49 }
50
51 let Some(worktree) = project.read(cx).worktree_for_id(worktree_id, cx) else {
52 return;
53 };
54
55 let worktree = worktree.read(cx);
56
57 if !worktree.is_local() {
58 return;
59 }
60
61 let has_configs = !find_configs_in_snapshot(worktree).is_empty();
62
63 if cli_auto_open {
64 workspace.set_open_in_dev_container(false);
65 let task = cx.spawn_in(window, async move |workspace, cx| {
66 let scans_complete =
67 workspace.update(cx, |workspace, cx| workspace.worktree_scans_complete(cx))?;
68 scans_complete.await;
69
70 workspace.update_in(cx, |workspace, window, cx| {
71 let has_configs = workspace
72 .project()
73 .read(cx)
74 .worktrees(cx)
75 .any(|wt| !find_configs_in_snapshot(wt.read(cx)).is_empty());
76 if has_configs {
77 cx.on_next_frame(window, move |_workspace, window, cx| {
78 window.dispatch_action(Box::new(zed_actions::OpenDevContainer), cx);
79 });
80 } else {
81 log::warn!("--dev-container: no devcontainer configuration found in project");
82 }
83 })
84 });
85 workspace.set_dev_container_task(task);
86 return;
87 }
88
89 if !has_configs {
90 return;
91 }
92
93 let abs_path = worktree.abs_path();
94 let project_path = abs_path.to_string_lossy().to_string();
95 let worktree_name = worktree.root_name_str().to_string();
96 let key_for_dismiss = project_devcontainer_key(&project_path);
97
98 let already_dismissed = KeyValueStore::global(cx)
99 .read_kvp(&key_for_dismiss)
100 .ok()
101 .flatten()
102 .is_some();
103
104 if already_dismissed {
105 return;
106 }
107
108 cx.on_next_frame(window, move |workspace, _window, cx| {
109 struct DevContainerSuggestionNotification;
110
111 let notification_id = NotificationId::composite::<DevContainerSuggestionNotification>(
112 SharedString::from(project_path.clone()),
113 );
114
115 workspace.show_notification(notification_id, cx, |cx| {
116 cx.new(move |cx| {
117 let message: SharedString = format!(
118 "{worktree_name} contains a Dev Container configuration file. Would you like to re-open it in a container?"
119 )
120 .into();
121 let tooltip_text: SharedString = project_path.clone().into();
122 MessageNotification::new_from_builder(cx, move |_window, _cx| {
123 div()
124 .id("dev-container-suggest-message")
125 .child(Label::new(message.clone()))
126 .tooltip(Tooltip::text(tooltip_text.clone()))
127 .into_any_element()
128 })
129 .primary_message("Yes, Open in Container")
130 .primary_icon(IconName::Check)
131 .primary_icon_color(Color::Success)
132 .primary_on_click({
133 move |window, cx| {
134 window.dispatch_action(Box::new(zed_actions::OpenDevContainer), cx);
135 }
136 })
137 .secondary_message("Don't Show Again")
138 .secondary_icon(IconName::Close)
139 .secondary_icon_color(Color::Error)
140 .secondary_on_click({
141 move |_window, cx| {
142 let key = key_for_dismiss.clone();
143 let kvp = KeyValueStore::global(cx);
144 cx.background_spawn(async move {
145 kvp.write_kvp(key, "dismissed".to_string())
146 .await
147 .log_err();
148 })
149 .detach();
150 }
151 })
152 })
153 });
154 });
155}