skill_web/components/
install_skill_modal.rs

1//! Install Skill Modal component
2//!
3//! Modal dialog for installing skills from various sources.
4
5use 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/// Source type for skill installation
15#[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/// Props for the InstallSkillModal component
54#[derive(Properties, PartialEq)]
55pub struct InstallSkillModalProps {
56    /// Callback when installation is complete
57    #[prop_or_default]
58    pub on_installed: Callback<String>,
59    /// Callback when modal is closed
60    #[prop_or_default]
61    pub on_close: Callback<()>,
62}
63
64/// Installation state
65#[derive(Clone, PartialEq)]
66enum InstallState {
67    Idle,
68    Installing,
69    Success(String),
70    Error(String),
71}
72
73/// Install Skill Modal component
74#[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    // Form state
80    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    // API client
88    let api = use_memo((), |_| Rc::new(Api::new()));
89
90    // Check if modal should be shown
91    let is_open = ui_store.modal.open
92        && ui_store.modal.modal_type == Some(crate::store::ui::ModalType::InstallSkill);
93
94    // Close handler
95    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    // Backdrop click handler
108    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            // Only close if clicking directly on the backdrop
114            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    // Source type change handler
124    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    // Input handlers
140    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    // Install handler
172    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            // Build the source string based on type
192            let full_source = match *source_type {
193                SourceType::Git => {
194                    // Handle github: shorthand
195                    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            // Build request
212            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    // Validation
268    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                // Header
282                <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                // Body
298                <div class="p-6 space-y-5">
299                    // Source Type
300                    <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                    // Source Input
325                    <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                    // Git Ref (only for Git type)
344                    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                    // Instance Name
362                    <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                    // Force Reinstall Toggle
381                    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                    // Error display
419                    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                // Footer
439                <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 to open the install skill modal
472#[hook]
473pub fn use_install_skill_modal() -> UseInstallSkillModalHandle {
474    let (_, dispatch) = use_store::<UiStore>();
475    UseInstallSkillModalHandle { dispatch }
476}
477
478/// Handle for the install skill modal hook
479pub struct UseInstallSkillModalHandle {
480    dispatch: Dispatch<UiStore>,
481}
482
483impl UseInstallSkillModalHandle {
484    /// Open the install skill modal
485    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}