skill_web/components/
install_skill_modal.rs1use std::rc::Rc;
6use wasm_bindgen_futures::spawn_local;
7use yew::prelude::*;
8use yewdux::prelude::*;
9
10use crate::api::{Api, InstallSkillRequest};
11use crate::components::use_notifications;
12use crate::store::ui::{UiAction, UiStore};
13
14#[derive(Clone, PartialEq, Default)]
16pub enum SourceType {
17 #[default]
18 Git,
19 Url,
20 Local,
21 Registry,
22}
23
24impl SourceType {
25 fn label(&self) -> &'static str {
26 match self {
27 SourceType::Git => "Git Repository",
28 SourceType::Url => "URL",
29 SourceType::Local => "Local Path",
30 SourceType::Registry => "Registry",
31 }
32 }
33
34 fn placeholder(&self) -> &'static str {
35 match self {
36 SourceType::Git => "github:user/repo or https://github.com/user/repo.git",
37 SourceType::Url => "https://example.com/skill.tar.gz",
38 SourceType::Local => "/path/to/skill or ./relative/path",
39 SourceType::Registry => "skill-name@1.0.0",
40 }
41 }
42
43 fn help_text(&self) -> &'static str {
44 match self {
45 SourceType::Git => "Enter a GitHub shorthand (github:user/repo) or full git URL. Optionally specify a ref with @tag or @branch.",
46 SourceType::Url => "Enter a direct URL to a skill archive (.tar.gz or .zip).",
47 SourceType::Local => "Enter a local filesystem path to the skill directory.",
48 SourceType::Registry => "Enter the skill name from the registry. Optionally specify version with @version.",
49 }
50 }
51}
52
53#[derive(Properties, PartialEq)]
55pub struct InstallSkillModalProps {
56 #[prop_or_default]
58 pub on_installed: Callback<String>,
59 #[prop_or_default]
61 pub on_close: Callback<()>,
62}
63
64#[derive(Clone, PartialEq)]
66enum InstallState {
67 Idle,
68 Installing,
69 Success(String),
70 Error(String),
71}
72
73#[function_component(InstallSkillModal)]
75pub fn install_skill_modal(props: &InstallSkillModalProps) -> Html {
76 let (ui_store, ui_dispatch) = use_store::<UiStore>();
77 let notifications = use_notifications();
78
79 let source_type = use_state(SourceType::default);
81 let source_input = use_state(String::new);
82 let git_ref = use_state(String::new);
83 let instance_name = use_state(String::new);
84 let force_reinstall = use_state(|| false);
85 let install_state = use_state(|| InstallState::Idle);
86
87 let api = use_memo((), |_| Rc::new(Api::new()));
89
90 let is_open = ui_store.modal.open
92 && ui_store.modal.modal_type == Some(crate::store::ui::ModalType::InstallSkill);
93
94 let on_close = {
96 let ui_dispatch = ui_dispatch.clone();
97 let on_close_prop = props.on_close.clone();
98 let install_state = install_state.clone();
99 Callback::from(move |_: MouseEvent| {
100 if *install_state != InstallState::Installing {
101 ui_dispatch.apply(UiAction::CloseModal);
102 on_close_prop.emit(());
103 }
104 })
105 };
106
107 let on_backdrop_click = {
109 let ui_dispatch = ui_dispatch.clone();
110 let on_close_prop = props.on_close.clone();
111 let install_state = install_state.clone();
112 Callback::from(move |e: MouseEvent| {
113 let target = e.target().unwrap();
115 let current_target = e.current_target().unwrap();
116 if target == current_target && *install_state != InstallState::Installing {
117 ui_dispatch.apply(UiAction::CloseModal);
118 on_close_prop.emit(());
119 }
120 })
121 };
122
123 let on_source_type_change = {
125 let source_type = source_type.clone();
126 Callback::from(move |e: Event| {
127 let select: web_sys::HtmlSelectElement = e.target_unchecked_into();
128 let new_type = match select.value().as_str() {
129 "git" => SourceType::Git,
130 "url" => SourceType::Url,
131 "local" => SourceType::Local,
132 "registry" => SourceType::Registry,
133 _ => SourceType::Git,
134 };
135 source_type.set(new_type);
136 })
137 };
138
139 let on_source_input = {
141 let source_input = source_input.clone();
142 Callback::from(move |e: InputEvent| {
143 let input: web_sys::HtmlInputElement = e.target_unchecked_into();
144 source_input.set(input.value());
145 })
146 };
147
148 let on_git_ref_input = {
149 let git_ref = git_ref.clone();
150 Callback::from(move |e: InputEvent| {
151 let input: web_sys::HtmlInputElement = e.target_unchecked_into();
152 git_ref.set(input.value());
153 })
154 };
155
156 let on_instance_input = {
157 let instance_name = instance_name.clone();
158 Callback::from(move |e: InputEvent| {
159 let input: web_sys::HtmlInputElement = e.target_unchecked_into();
160 instance_name.set(input.value());
161 })
162 };
163
164 let on_force_toggle = {
165 let force_reinstall = force_reinstall.clone();
166 Callback::from(move |_| {
167 force_reinstall.set(!*force_reinstall);
168 })
169 };
170
171 let on_install = {
173 let api = api.clone();
174 let source_type = source_type.clone();
175 let source_input = source_input.clone();
176 let git_ref = git_ref.clone();
177 let instance_name = instance_name.clone();
178 let force_reinstall = force_reinstall.clone();
179 let install_state = install_state.clone();
180 let notifications = notifications.clone();
181 let on_installed = props.on_installed.clone();
182 let ui_dispatch = ui_dispatch.clone();
183
184 Callback::from(move |_| {
185 let source = (*source_input).trim().to_string();
186 if source.is_empty() {
187 notifications.error("Validation Error", "Please enter a source");
188 return;
189 }
190
191 let full_source = match *source_type {
193 SourceType::Git => {
194 if source.starts_with("github:") || source.contains("github.com") {
196 source.clone()
197 } else {
198 format!("github:{}", source)
199 }
200 }
201 SourceType::Local => {
202 if source.starts_with("local:") {
203 source.clone()
204 } else {
205 format!("local:{}", source)
206 }
207 }
208 _ => source.clone(),
209 };
210
211 let request = InstallSkillRequest {
213 source: full_source,
214 name: if (*instance_name).is_empty() {
215 None
216 } else {
217 Some((*instance_name).clone())
218 },
219 git_ref: if (*git_ref).is_empty() {
220 None
221 } else {
222 Some((*git_ref).clone())
223 },
224 force: *force_reinstall,
225 };
226
227 install_state.set(InstallState::Installing);
228
229 let api = api.clone();
230 let install_state = install_state.clone();
231 let notifications = notifications.clone();
232 let on_installed = on_installed.clone();
233 let ui_dispatch = ui_dispatch.clone();
234
235 spawn_local(async move {
236 match api.skills.install(&request).await {
237 Ok(response) => {
238 if response.success {
239 let name = response.name.unwrap_or_else(|| "skill".to_string());
240 let version = response.version.unwrap_or_else(|| "unknown".to_string());
241 install_state.set(InstallState::Success(name.clone()));
242 notifications.success(
243 "Skill Installed",
244 format!(
245 "Successfully installed {} v{} with {} tools",
246 name, version, response.tools_count
247 ),
248 );
249 on_installed.emit(name);
250 ui_dispatch.apply(UiAction::CloseModal);
251 } else {
252 let error = response.error.unwrap_or_else(|| "Unknown error".to_string());
253 install_state.set(InstallState::Error(error.clone()));
254 notifications.error("Installation Failed", &error);
255 }
256 }
257 Err(e) => {
258 let error = e.to_string();
259 install_state.set(InstallState::Error(error.clone()));
260 notifications.error("Installation Failed", &error);
261 }
262 }
263 });
264 })
265 };
266
267 let is_valid = !(*source_input).trim().is_empty();
269 let is_installing = *install_state == InstallState::Installing;
270
271 if !is_open {
272 return html! {};
273 }
274
275 html! {
276 <div
277 class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm animate-fade-in"
278 onclick={on_backdrop_click}
279 >
280 <div class="bg-white dark:bg-gray-800 rounded-xl shadow-2xl w-full max-w-lg mx-4 animate-scale-in">
281 <div class="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
283 <h2 class="text-xl font-semibold text-gray-900 dark:text-white">
284 { "Install Skill" }
285 </h2>
286 <button
287 onclick={on_close.clone()}
288 class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors"
289 disabled={is_installing}
290 >
291 <svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
292 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
293 </svg>
294 </button>
295 </div>
296
297 <div class="p-6 space-y-5">
299 <div>
301 <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
302 { "Source Type" }
303 </label>
304 <select
305 class="input"
306 onchange={on_source_type_change}
307 disabled={is_installing}
308 >
309 <option value="git" selected={*source_type == SourceType::Git}>
310 { SourceType::Git.label() }
311 </option>
312 <option value="url" selected={*source_type == SourceType::Url}>
313 { SourceType::Url.label() }
314 </option>
315 <option value="local" selected={*source_type == SourceType::Local}>
316 { SourceType::Local.label() }
317 </option>
318 <option value="registry" selected={*source_type == SourceType::Registry}>
319 { SourceType::Registry.label() }
320 </option>
321 </select>
322 </div>
323
324 <div>
326 <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
327 { "Source" }
328 <span class="text-red-500">{ " *" }</span>
329 </label>
330 <input
331 type="text"
332 class="input"
333 placeholder={source_type.placeholder()}
334 value={(*source_input).clone()}
335 oninput={on_source_input}
336 disabled={is_installing}
337 />
338 <p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
339 { source_type.help_text() }
340 </p>
341 </div>
342
343 if *source_type == SourceType::Git {
345 <div>
346 <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
347 { "Branch / Tag / Commit" }
348 <span class="text-gray-400 text-xs ml-2">{ "(optional)" }</span>
349 </label>
350 <input
351 type="text"
352 class="input"
353 placeholder="main, v1.0.0, or commit hash"
354 value={(*git_ref).clone()}
355 oninput={on_git_ref_input}
356 disabled={is_installing}
357 />
358 </div>
359 }
360
361 <div>
363 <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
364 { "Instance Name" }
365 <span class="text-gray-400 text-xs ml-2">{ "(optional)" }</span>
366 </label>
367 <input
368 type="text"
369 class="input"
370 placeholder="default"
371 value={(*instance_name).clone()}
372 oninput={on_instance_input}
373 disabled={is_installing}
374 />
375 <p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
376 { "Custom name for this skill installation. Leave empty for default." }
377 </p>
378 </div>
379
380 if *source_type == SourceType::Git {
382 <div class="flex items-center gap-3">
383 <button
384 type="button"
385 role="switch"
386 aria-checked={(*force_reinstall).to_string()}
387 onclick={on_force_toggle}
388 disabled={is_installing}
389 class={classes!(
390 "relative", "inline-flex", "h-6", "w-11", "flex-shrink-0",
391 "cursor-pointer", "rounded-full", "border-2", "border-transparent",
392 "transition-colors", "duration-200", "ease-in-out",
393 "focus:outline-none", "focus:ring-2", "focus:ring-primary-500", "focus:ring-offset-2",
394 if *force_reinstall { "bg-primary-600" } else { "bg-gray-200 dark:bg-gray-700" },
395 if is_installing { "opacity-50 cursor-not-allowed" } else { "" }
396 )}
397 >
398 <span
399 class={classes!(
400 "pointer-events-none", "inline-block", "h-5", "w-5",
401 "transform", "rounded-full", "bg-white", "shadow",
402 "ring-0", "transition", "duration-200", "ease-in-out",
403 if *force_reinstall { "translate-x-5" } else { "translate-x-0" }
404 )}
405 />
406 </button>
407 <div>
408 <span class="text-sm font-medium text-gray-700 dark:text-gray-300">
409 { "Force re-clone" }
410 </span>
411 <p class="text-xs text-gray-500 dark:text-gray-400">
412 { "Delete existing installation and re-clone from source" }
413 </p>
414 </div>
415 </div>
416 }
417
418 if let InstallState::Error(ref error) = *install_state {
420 <div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
421 <div class="flex items-start gap-3">
422 <svg class="w-5 h-5 text-red-500 flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
423 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
424 </svg>
425 <div>
426 <p class="text-sm font-medium text-red-700 dark:text-red-300">
427 { "Installation failed" }
428 </p>
429 <p class="text-sm text-red-600 dark:text-red-400 mt-1">
430 { error }
431 </p>
432 </div>
433 </div>
434 </div>
435 }
436 </div>
437
438 <div class="flex items-center justify-end gap-3 p-6 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50 rounded-b-xl">
440 <button
441 onclick={on_close}
442 class="btn btn-secondary"
443 disabled={is_installing}
444 >
445 { "Cancel" }
446 </button>
447 <button
448 onclick={on_install}
449 class="btn btn-primary"
450 disabled={!is_valid || is_installing}
451 >
452 if is_installing {
453 <svg class="animate-spin -ml-1 mr-2 h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
454 <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
455 <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
456 </svg>
457 { "Installing..." }
458 } else {
459 <svg class="w-4 h-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
460 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
461 </svg>
462 { "Install Skill" }
463 }
464 </button>
465 </div>
466 </div>
467 </div>
468 }
469}
470
471#[hook]
473pub fn use_install_skill_modal() -> UseInstallSkillModalHandle {
474 let (_, dispatch) = use_store::<UiStore>();
475 UseInstallSkillModalHandle { dispatch }
476}
477
478pub struct UseInstallSkillModalHandle {
480 dispatch: Dispatch<UiStore>,
481}
482
483impl UseInstallSkillModalHandle {
484 pub fn open(&self) {
486 self.dispatch
487 .apply(UiAction::OpenModal(crate::store::ui::ModalType::InstallSkill, None));
488 }
489}
490
491impl Clone for UseInstallSkillModalHandle {
492 fn clone(&self) -> Self {
493 Self {
494 dispatch: self.dispatch.clone(),
495 }
496 }
497}