1use std::collections::HashMap;
2use std::path::Path;
3use std::sync::{Arc, OnceLock};
4
5use db::kvp::KEY_VALUE_STORE;
6use editor::Editor;
7use extension::ExtensionStore;
8use gpui::{Entity, Model, VisualContext};
9use language::Buffer;
10use ui::ViewContext;
11use workspace::{notifications::simple_message_notification, Workspace};
12
13fn suggested_extensions() -> &'static HashMap<&'static str, Arc<str>> {
14 static SUGGESTED: OnceLock<HashMap<&str, Arc<str>>> = OnceLock::new();
15 SUGGESTED.get_or_init(|| {
16 [
17 ("astro", "astro"),
18 ("beancount", "beancount"),
19 ("clojure", "bb"),
20 ("clojure", "clj"),
21 ("clojure", "cljc"),
22 ("clojure", "cljs"),
23 ("clojure", "edn"),
24 ("csharp", "cs"),
25 ("dockerfile", "Dockerfile"),
26 ("elisp", "el"),
27 ("erlang", "erl"),
28 ("erlang", "hrl"),
29 ("fish", "fish"),
30 ("git-firefly", ".gitconfig"),
31 ("git-firefly", ".gitignore"),
32 ("git-firefly", "COMMIT_EDITMSG"),
33 ("git-firefly", "EDIT_DESCRIPTION"),
34 ("git-firefly", "MERGE_MSG"),
35 ("git-firefly", "NOTES_EDITMSG"),
36 ("git-firefly", "TAG_EDITMSG"),
37 ("git-firefly", "git-rebase-todo"),
38 ("gleam", "gleam"),
39 ("graphql", "gql"),
40 ("graphql", "graphql"),
41 ("haskell", "hs"),
42 ("java", "java"),
43 ("kotlin", "kt"),
44 ("latex", "tex"),
45 ("make", "Makefile"),
46 ("nix", "nix"),
47 ("php", "php"),
48 ("prisma", "prisma"),
49 ("purescript", "purs"),
50 ("r", "r"),
51 ("r", "R"),
52 ("sql", "sql"),
53 ("svelte", "svelte"),
54 ("swift", "swift"),
55 ("templ", "templ"),
56 ("toml", "Cargo.lock"),
57 ("toml", "toml"),
58 ("wgsl", "wgsl"),
59 ("zig", "zig"),
60 ]
61 .into_iter()
62 .map(|(name, file)| (file, name.into()))
63 .collect()
64 })
65}
66
67#[derive(Debug, PartialEq, Eq, Clone)]
68struct SuggestedExtension {
69 pub extension_id: Arc<str>,
70 pub file_name_or_extension: Arc<str>,
71}
72
73/// Returns the suggested extension for the given [`Path`].
74fn suggested_extension(path: impl AsRef<Path>) -> Option<SuggestedExtension> {
75 let path = path.as_ref();
76
77 let file_extension: Option<Arc<str>> = path
78 .extension()
79 .and_then(|extension| Some(extension.to_str()?.into()));
80 let file_name: Option<Arc<str>> = path
81 .file_name()
82 .and_then(|file_name| Some(file_name.to_str()?.into()));
83
84 let (file_name_or_extension, extension_id) = None
85 // We suggest against file names first, as these suggestions will be more
86 // specific than ones based on the file extension.
87 .or_else(|| {
88 file_name.clone().zip(
89 file_name
90 .as_deref()
91 .and_then(|file_name| suggested_extensions().get(file_name)),
92 )
93 })
94 .or_else(|| {
95 file_extension.clone().zip(
96 file_extension
97 .as_deref()
98 .and_then(|file_extension| suggested_extensions().get(file_extension)),
99 )
100 })?;
101
102 Some(SuggestedExtension {
103 extension_id: extension_id.clone(),
104 file_name_or_extension,
105 })
106}
107
108fn language_extension_key(extension_id: &str) -> String {
109 format!("{}_extension_suggest", extension_id)
110}
111
112pub(crate) fn suggest(buffer: Model<Buffer>, cx: &mut ViewContext<Workspace>) {
113 let Some(file) = buffer.read(cx).file().cloned() else {
114 return;
115 };
116
117 let Some(SuggestedExtension {
118 extension_id,
119 file_name_or_extension,
120 }) = suggested_extension(file.path())
121 else {
122 return;
123 };
124
125 let key = language_extension_key(&extension_id);
126 let Ok(None) = KEY_VALUE_STORE.read_kvp(&key) else {
127 return;
128 };
129
130 cx.on_next_frame(move |workspace, cx| {
131 let Some(editor) = workspace.active_item_as::<Editor>(cx) else {
132 return;
133 };
134
135 if editor.read(cx).buffer().read(cx).as_singleton().as_ref() != Some(&buffer) {
136 return;
137 }
138
139 workspace.show_notification(buffer.entity_id().as_u64() as usize, cx, |cx| {
140 cx.new_view(move |_cx| {
141 simple_message_notification::MessageNotification::new(format!(
142 "Do you want to install the recommended '{}' extension for '{}' files?",
143 extension_id, file_name_or_extension
144 ))
145 .with_click_message("Yes")
146 .on_click({
147 let extension_id = extension_id.clone();
148 move |cx| {
149 let extension_id = extension_id.clone();
150 let extension_store = ExtensionStore::global(cx);
151 extension_store.update(cx, move |store, cx| {
152 store.install_latest_extension(extension_id, cx);
153 });
154 }
155 })
156 .with_secondary_click_message("No")
157 .on_secondary_click(move |cx| {
158 let key = language_extension_key(&extension_id);
159 db::write_and_log(cx, move || {
160 KEY_VALUE_STORE.write_kvp(key, "dismissed".to_string())
161 });
162 })
163 })
164 });
165 })
166}
167
168#[cfg(test)]
169mod tests {
170 use super::*;
171
172 #[test]
173 pub fn test_suggested_extension() {
174 assert_eq!(
175 suggested_extension("Cargo.toml"),
176 Some(SuggestedExtension {
177 extension_id: "toml".into(),
178 file_name_or_extension: "toml".into()
179 })
180 );
181 assert_eq!(
182 suggested_extension("Cargo.lock"),
183 Some(SuggestedExtension {
184 extension_id: "toml".into(),
185 file_name_or_extension: "Cargo.lock".into()
186 })
187 );
188 assert_eq!(
189 suggested_extension("Dockerfile"),
190 Some(SuggestedExtension {
191 extension_id: "dockerfile".into(),
192 file_name_or_extension: "Dockerfile".into()
193 })
194 );
195 assert_eq!(
196 suggested_extension("a/b/c/d/.gitignore"),
197 Some(SuggestedExtension {
198 extension_id: "git-firefly".into(),
199 file_name_or_extension: ".gitignore".into()
200 })
201 );
202 assert_eq!(
203 suggested_extension("a/b/c/d/test.gleam"),
204 Some(SuggestedExtension {
205 extension_id: "gleam".into(),
206 file_name_or_extension: "gleam".into()
207 })
208 );
209 }
210}