From f2805b57e8c7ea1d6473f597f65be63ed13149d7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 11 Oct 2026 02:02:32 +0000 Subject: [PATCH] feat: show setup steps and container templates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First-launch failures should say whether WSL or the SDK failed, and keep a log the user can copy. Create forms are saved locally so the same ports and mounts are not retyped. Co-authored-by: 绿岸公式 --- src/app/containers.rs | 721 ++++++++++++++++++++++++++++++---------- src/app/mod.rs | 138 ++++---- src/app/refresh.rs | 46 ++- src/app/sdk_setup.rs | 441 ++++++++++++++++++------ src/i18n.rs | 136 +++++++- src/wslc/catalog.rs | 18 +- src/wslc/mod.rs | 133 ++++++-- src/wslc/paths.rs | 22 ++ src/wslc/prefs.rs | 16 +- src/wslc/sdk_fetch.rs | 15 + src/wslc/sdk_version.rs | 119 +++++++ src/wslc/setup_log.rs | 182 ++++++++++ src/wslc/templates.rs | 212 ++++++++++++ src/wslc/types.rs | 40 +++ 14 files changed, 1845 insertions(+), 394 deletions(-) create mode 100644 src/wslc/paths.rs create mode 100644 src/wslc/setup_log.rs create mode 100644 src/wslc/templates.rs diff --git a/src/app/containers.rs b/src/app/containers.rs index e03ee05..a8e8bbe 100644 --- a/src/app/containers.rs +++ b/src/app/containers.rs @@ -1,4 +1,3 @@ - use gpui_kit::component::{ ActiveTheme, Disableable as _, IconName, Sizable as _, StyledExt as _, button::{Button, ButtonVariants as _}, @@ -12,14 +11,15 @@ use gpui_kit::*; use crate::i18n::{self}; use crate::wslc::{ - ContainerInfo, CreateContainerRequest, + ContainerInfo, ContainerTemplate, CreateContainerRequest, TemplateEditError, TemplateStore, + UpsertKind, }; -use super::state::{ConfirmAction, PullPhase}; use super::AppView; use super::parsing::{parse_bind_mounts, parse_named_volumes, parse_port_mappings}; +use super::state::{ConfirmAction, PullPhase}; use super::widgets::{ - list_header, modal_panel, modal_scrim, resource_list, resource_row, row_actions, + list_header, modal_panel, modal_scrim, resource_list, resource_row, row_actions, settings_card, }; impl AppView { @@ -51,6 +51,186 @@ impl AppView { self.create_auto_remove = false; } + fn reset_template_editor(&mut self, window: &mut Window, cx: &mut Context) { + self.template_save_open = false; + self.template_renaming = None; + self.template_banner = None; + self.template_banner_error = false; + self.template_name_input.update(cx, |state, cx| { + state.set_value("", window, cx); + }); + self.template_rename_input.update(cx, |state, cx| { + state.set_value("", window, cx); + }); + } + + fn set_template_banner( + &mut self, + error: bool, + text: impl Into, + cx: &mut Context, + ) { + self.template_banner_error = error; + self.template_banner = Some(text.into()); + cx.notify(); + } + + fn template_edit_error(&self, err: TemplateEditError, name: &str) -> String { + let t = self.t(); + match err { + TemplateEditError::EmptyName => t.template_name_empty.to_string(), + TemplateEditError::NameTooLong => t.template_name_too_long.to_string(), + TemplateEditError::NameTaken => i18n::fmt1(t.template_name_taken, "name", name), + TemplateEditError::Missing => t.template_missing.to_string(), + } + } + + fn persist_templates(&mut self, next: TemplateStore) -> Result<(), String> { + next.save()?; + self.templates = next; + Ok(()) + } + + fn current_template(&self, name: String, cx: &Context) -> ContainerTemplate { + ContainerTemplate { + name, + image: self.image_input.read(cx).value().trim().to_string(), + container_name: self.name_input.read(cx).value().trim().to_string(), + ports: self.ports_input.read(cx).value().trim().to_string(), + bind_mounts: self.bind_mounts_input.read(cx).value().trim().to_string(), + named_volumes: self.named_volumes_input.read(cx).value().trim().to_string(), + auto_remove: self.create_auto_remove, + command: self.command_input.read(cx).value().trim().to_string(), + } + } + + fn save_current_as_template(&mut self, _window: &mut Window, cx: &mut Context) { + let raw_name = self.template_name_input.read(cx).value().to_string(); + let draft = self.current_template(raw_name, cx); + let mut next = self.templates.clone(); + match next.upsert(draft) { + Ok((kind, name)) => match self.persist_templates(next) { + Ok(()) => { + self.template_save_open = false; + let template = match kind { + UpsertKind::Created => self.t().template_saved, + UpsertKind::Updated => self.t().template_updated, + }; + self.set_template_banner(false, i18n::fmt1(template, "name", &name), cx); + } + Err(err) => { + self.set_template_banner( + true, + i18n::fmt1(self.t().template_save_failed, "err", err), + cx, + ); + } + }, + Err(err) => { + let text = self.template_edit_error(err, ""); + self.set_template_banner(true, text, cx); + } + } + } + + fn fill_template(&mut self, name: &str, window: &mut Window, cx: &mut Context) { + let Some(template) = self + .templates + .templates + .iter() + .find(|t| t.name == name) + .cloned() + else { + self.set_template_banner(true, self.t().template_missing, cx); + return; + }; + let image = template.image.clone(); + let container_name = template.container_name.clone(); + let ports = template.ports.clone(); + let bind_mounts = template.bind_mounts.clone(); + let named_volumes = template.named_volumes.clone(); + let command = template.command.clone(); + self.image_input.update(cx, |state, cx| { + state.set_value(image, window, cx); + }); + self.name_input.update(cx, |state, cx| { + state.set_value(container_name, window, cx); + }); + self.ports_input.update(cx, |state, cx| { + state.set_value(ports, window, cx); + }); + self.bind_mounts_input.update(cx, |state, cx| { + state.set_value(bind_mounts, window, cx); + }); + self.named_volumes_input.update(cx, |state, cx| { + state.set_value(named_volumes, window, cx); + }); + self.command_input.update(cx, |state, cx| { + state.set_value(command, window, cx); + }); + self.create_auto_remove = template.auto_remove; + self.set_template_banner( + false, + i18n::fmt1(self.t().template_filled, "name", &template.name), + cx, + ); + } + + fn commit_rename_template(&mut self, from: String, cx: &mut Context) { + let to = self.template_rename_input.read(cx).value().to_string(); + let mut next = self.templates.clone(); + match next.rename(&from, &to) { + Ok(renamed_to) => match self.persist_templates(next) { + Ok(()) => { + self.template_renaming = None; + self.set_template_banner( + false, + i18n::fmt1(self.t().template_renamed, "name", &renamed_to), + cx, + ); + } + Err(err) => { + self.set_template_banner( + true, + i18n::fmt1(self.t().template_save_failed, "err", err), + cx, + ); + } + }, + Err(err) => { + let text = self.template_edit_error(err, to.trim()); + self.set_template_banner(true, text, cx); + } + } + } + + fn delete_template(&mut self, name: String, cx: &mut Context) { + let mut next = self.templates.clone(); + if !next.remove(&name) { + self.set_template_banner(true, self.t().template_missing, cx); + return; + } + match self.persist_templates(next) { + Ok(()) => { + if self.template_renaming.as_deref() == Some(name.as_str()) { + self.template_renaming = None; + } + self.set_template_banner( + false, + i18n::fmt1(self.t().template_deleted, "name", &name), + cx, + ); + } + Err(err) => { + self.set_template_banner( + true, + i18n::fmt1(self.t().template_save_failed, "err", err), + cx, + ); + } + } + } + pub(crate) fn open_create_modal( &mut self, prefill_image: Option, @@ -61,6 +241,7 @@ impl AppView { return; } self.reset_create_form(window, cx); + self.reset_template_editor(window, cx); if let Some(image) = prefill_image { self.image_input.update(cx, |state, cx| { state.set_value(&image, window, cx); @@ -70,16 +251,13 @@ impl AppView { cx.notify(); } - pub(crate) fn dismiss_create_modal( - &mut self, - window: &mut Window, - cx: &mut Context, - ) { + pub(crate) fn dismiss_create_modal(&mut self, window: &mut Window, cx: &mut Context) { if self.busy { return; } self.create_modal_open = false; self.reset_create_form(window, cx); + self.reset_template_editor(window, cx); cx.notify(); } @@ -90,11 +268,7 @@ impl AppView { cx.notify(); } - pub(crate) fn submit_create_container( - &mut self, - _window: &mut Window, - cx: &mut Context, - ) { + pub(crate) fn submit_create_container(&mut self, _window: &mut Window, cx: &mut Context) { if self.sdk_locked() || self.busy || self.pull_phase == PullPhase::Pulling { return; } @@ -196,7 +370,12 @@ impl AppView { }); } - pub(crate) fn open_exec_modal(&mut self, index: usize, window: &mut Window, cx: &mut Context) { + pub(crate) fn open_exec_modal( + &mut self, + index: usize, + window: &mut Window, + cx: &mut Context, + ) { if self.ops_disabled() { return; } @@ -283,17 +462,11 @@ impl AppView { .w_full() .items_start() .gap_3() - .child( - div() - .mt_1() - .size_2() - .rounded_full() - .bg(if running { - cx.theme().green - } else { - cx.theme().muted_foreground.opacity(0.5) - }), - ) + .child(div().mt_1().size_2().rounded_full().bg(if running { + cx.theme().green + } else { + cx.theme().muted_foreground.opacity(0.5) + })) .child( v_flex() .flex_1() @@ -514,127 +687,325 @@ impl AppView { })), ), )) - .child( - resource_list("container-list").child({ - if self.containers.is_empty() { - v_flex().gap_1().w_full().child(self.empty_state( - t.no_containers_title, - t.no_containers_detail, - cx, - )) + .child(resource_list("container-list").child({ + if self.containers.is_empty() { + v_flex().gap_1().w_full().child(self.empty_state( + t.no_containers_title, + t.no_containers_detail, + cx, + )) + } else { + let rows: Vec<_> = self + .containers + .iter() + .enumerate() + .map(|(index, item)| { + self.render_container_row(index, item, cx) + .into_any_element() + }) + .collect(); + v_flex().gap_1().w_full().children(rows) + } + })) + } + fn render_template_section(&self, creating: bool, cx: &mut Context) -> impl IntoElement { + let t = self.t(); + let templates = self.templates.templates.clone(); + let renaming = self.template_renaming.clone(); + let banner = self.template_banner.clone(); + let banner_error = self.template_banner_error; + let banner_color = if banner_error { + cx.theme().red + } else { + cx.theme().muted_foreground + }; + + let rows: Vec = if templates.is_empty() { + vec![ + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(t.template_empty) + .into_any_element(), + ] + } else { + templates + .iter() + .enumerate() + .map(|(index, template)| { + let name = template.name.clone(); + let image = template.image.clone(); + let is_renaming = renaming.as_deref() == Some(name.as_str()); + if is_renaming { + let original = name.clone(); + h_flex() + .gap_2() + .items_center() + .child( + div() + .flex_1() + .child(Input::new(&self.template_rename_input)), + ) + .child( + Button::new(("tpl-rename-save", index)) + .small() + .primary() + .label(t.template_save_confirm) + .disabled(creating) + .on_click(cx.listener(move |this, _, _, cx| { + this.commit_rename_template(original.clone(), cx); + })), + ) + .child( + Button::new(("tpl-rename-cancel", index)) + .small() + .label(t.cancel) + .disabled(creating) + .on_click(cx.listener(|this, _, _, cx| { + this.template_renaming = None; + cx.notify(); + })), + ) + .into_any_element() } else { - let rows: Vec<_> = self - .containers - .iter() - .enumerate() - .map(|(index, item)| { - self.render_container_row(index, item, cx).into_any_element() - }) - .collect(); - v_flex().gap_1().w_full().children(rows) + let fill_name = name.clone(); + let rename_name = name.clone(); + let delete_name = name.clone(); + h_flex() + .gap_2() + .items_center() + .child( + v_flex() + .flex_1() + .min_w_0() + .child(div().text_sm().truncate().child(name)) + .when(!image.is_empty(), |col| { + col.child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .truncate() + .child(image), + ) + }), + ) + .child( + Button::new(("tpl-fill", index)) + .small() + .label(t.template_apply) + .disabled(creating) + .on_click(cx.listener(move |this, _, window, cx| { + this.fill_template(&fill_name, window, cx); + })), + ) + .child( + Button::new(("tpl-rename", index)) + .small() + .ghost() + .label(t.template_rename) + .disabled(creating) + .on_click(cx.listener(move |this, _, window, cx| { + let current = rename_name.clone(); + this.template_renaming = Some(current.clone()); + this.template_rename_input.update(cx, |state, cx| { + state.set_value(current, window, cx); + }); + cx.notify(); + })), + ) + .child( + Button::new(("tpl-delete", index)) + .small() + .danger() + .label(t.template_delete) + .disabled(creating) + .on_click(cx.listener(move |this, _, _, cx| { + this.delete_template(delete_name.clone(), cx); + })), + ) + .into_any_element() } - }), + }) + .collect() + }; + + settings_card(cx) + .child(div().text_sm().font_medium().child(t.template_section)) + .child( + div() + .id("create-template-list") + .w_full() + .max_h(px(160.)) + .overflow_y_scroll() + .child(v_flex().gap_2().w_full().children(rows)), ) + .when_some(banner, |card, text| { + card.child(div().text_xs().text_color(banner_color).child(text)) + }) } + pub(crate) fn render_create_modal(&self, cx: &mut Context) -> impl IntoElement { let t = self.t(); let creating = self.busy && self.create_modal_open; let auto_remove = self.create_auto_remove; + let save_open = self.template_save_open; + let template_section = self + .render_template_section(creating, cx) + .into_any_element(); modal_scrim( "create-container-modal", cx, modal_panel(cx) + .id("create-container-panel") .w(px(560.)) - .max_h(px(640.)) - .child(div().font_semibold().text_base().child(t.create_container_title)) - .child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(t.create_container_intro), - ) - .child( - v_flex() - .gap_2() - .child(Label::new(t.image_label)) - .child(Input::new(&self.image_input)), - ) - .child( - v_flex() - .gap_2() - .child(Label::new(t.name_optional)) - .child(Input::new(&self.name_input)), - ) - .child( - v_flex() - .gap_2() - .child(Label::new(t.ports_label)) - .child(Input::new(&self.ports_input)), - ) - .child( - v_flex() - .gap_2() - .child(Label::new(t.bind_mounts_label)) - .child(Input::new(&self.bind_mounts_input)), - ) - .child( + .max_h(px(720.)) + .overflow_y_scroll() + .child( + div() + .font_semibold() + .text_base() + .child(t.create_container_title), + ) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(t.create_container_intro), + ) + .child(template_section) + .child( + v_flex() + .gap_2() + .child(Label::new(t.image_label)) + .child(Input::new(&self.image_input)), + ) + .child( + v_flex() + .gap_2() + .child(Label::new(t.name_optional)) + .child(Input::new(&self.name_input)), + ) + .child( + v_flex() + .gap_2() + .child(Label::new(t.ports_label)) + .child(Input::new(&self.ports_input)), + ) + .child( + v_flex() + .gap_2() + .child(Label::new(t.bind_mounts_label)) + .child(Input::new(&self.bind_mounts_input)), + ) + .child( + v_flex() + .gap_2() + .child(Label::new(t.named_volumes_label)) + .child(Input::new(&self.named_volumes_input)), + ) + .child( + h_flex() + .items_center() + .justify_between() + .child(Label::new(t.auto_remove_label)) + .child( + Button::new("create-auto-remove") + .small() + .label(if auto_remove { + t.auto_remove_on + } else { + t.auto_remove_off + }) + .when(auto_remove, |b| b.primary()) + .disabled(creating) + .on_click(cx.listener(|this, _, _, cx| { + this.create_auto_remove = !this.create_auto_remove; + cx.notify(); + })), + ), + ) + .child( + v_flex() + .gap_2() + .child(Label::new(t.command_label)) + .child(Input::new(&self.command_input)), + ) + .when(save_open, |panel| { + panel.child( v_flex() .gap_2() - .child(Label::new(t.named_volumes_label)) - .child(Input::new(&self.named_volumes_input)), - ) - .child( - h_flex() - .items_center() - .justify_between() - .child(Label::new(t.auto_remove_label)) + .child(Label::new(t.template_name_label)) + .child(Input::new(&self.template_name_input)) .child( - Button::new("create-auto-remove") - .small() - .label(if auto_remove { - t.auto_remove_on - } else { - t.auto_remove_off - }) - .when(auto_remove, |b| b.primary()) - .disabled(creating) - .on_click(cx.listener(|this, _, _, cx| { - this.create_auto_remove = !this.create_auto_remove; - cx.notify(); - })), + h_flex() + .gap_2() + .justify_end() + .child( + Button::new("template-save-cancel") + .small() + .label(t.cancel) + .disabled(creating) + .on_click(cx.listener(|this, _, _, cx| { + this.template_save_open = false; + cx.notify(); + })), + ) + .child( + Button::new("template-save-confirm") + .small() + .primary() + .label(t.template_save_confirm) + .disabled(creating) + .on_click(cx.listener(|this, _, window, cx| { + this.save_current_as_template(window, cx); + })), + ), ), ) - .child( - v_flex() - .gap_2() - .child(Label::new(t.command_label)) - .child(Input::new(&self.command_input)), - ) - .child( - h_flex() - .gap_2() - .justify_end() - .child( - Button::new("create-cancel") - .label(t.close) + }) + .child( + h_flex() + .gap_2() + .justify_end() + .when(!save_open, |row| { + row.child( + Button::new("template-save-open") + .label(t.template_save) .disabled(creating) .on_click(cx.listener(|this, _, window, cx| { - this.dismiss_create_modal(window, cx); + this.template_save_open = true; + this.template_name_input.update(cx, |state, cx| { + state.set_value("", window, cx); + }); + cx.notify(); })), ) - .child( - Button::new("create-submit") - .primary() - .label(if creating { t.working } else { t.create }) - .icon(IconName::Plus) - .disabled(self.sdk_locked() || creating || self.pull_phase == PullPhase::Pulling) - .on_click(cx.listener(|this, _, window, cx| { - this.submit_create_container(window, cx); - })), - ), - ), - ) + }) + .child( + Button::new("create-cancel") + .label(t.close) + .disabled(creating) + .on_click(cx.listener(|this, _, window, cx| { + this.dismiss_create_modal(window, cx); + })), + ) + .child( + Button::new("create-submit") + .primary() + .label(if creating { t.working } else { t.create }) + .icon(IconName::Plus) + .disabled( + self.sdk_locked() + || creating + || self.pull_phase == PullPhase::Pulling, + ) + .on_click(cx.listener(|this, _, window, cx| { + this.submit_create_container(window, cx); + })), + ), + ), + ) } pub(crate) fn render_exec_modal(&self, cx: &mut Context) -> impl IntoElement { @@ -645,12 +1016,20 @@ impl AppView { cx, modal_panel(cx) .w(px(520.)) - .child(div().font_semibold().text_base().child(t.container_exec_title)) + .child( + div() + .font_semibold() + .text_base() + .child(t.container_exec_title), + ) .child( div() .text_xs() .text_color(cx.theme().muted_foreground) - .child(format!("{} · {}", t.container_exec_intro, self.exec_target_key)), + .child(format!( + "{} · {}", + t.container_exec_intro, self.exec_target_key + )), ) .child(Input::new(&self.exec_input)) .child( @@ -670,7 +1049,11 @@ impl AppView { Button::new("exec-run") .small() .primary() - .label(if running { t.working } else { t.container_exec_run }) + .label(if running { + t.working + } else { + t.container_exec_run + }) .disabled(self.sdk_locked() || running) .on_click(cx.listener(|this, _, _, cx| { this.submit_exec(cx); @@ -701,49 +1084,47 @@ impl AppView { modal_panel(cx) .w(px(640.)) .max_h(px(520.)) - .child( - h_flex() - .items_center() - .justify_between() - .child(div().font_semibold().text_base().child(heading)) - .child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .truncate() - .child(self.logs_title.clone()), - ), - ) - .child( - div() - .id("container-logs-body") - .h(px(320.)) - .w_full() - .overflow_y_scroll() - .rounded(cx.theme().radius) - .border_1() - .border_color(cx.theme().border) - .bg(cx.theme().background.opacity(0.65)) - .p_2() - .child( - div() - .text_xs() - .font_family("monospace") - .text_color(cx.theme().foreground) - .child(body), - ), - ) - .child( - h_flex() - .justify_end() - .child( - Button::new("logs-modal-close") - .label(t.close) - .on_click(cx.listener(|this, _, _, cx| { - this.dismiss_logs_modal(cx); - })), - ), + .child( + h_flex() + .items_center() + .justify_between() + .child(div().font_semibold().text_base().child(heading)) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .truncate() + .child(self.logs_title.clone()), + ), + ) + .child( + div() + .id("container-logs-body") + .h(px(320.)) + .w_full() + .overflow_y_scroll() + .rounded(cx.theme().radius) + .border_1() + .border_color(cx.theme().border) + .bg(cx.theme().background.opacity(0.65)) + .p_2() + .child( + div() + .text_xs() + .font_family("monospace") + .text_color(cx.theme().foreground) + .child(body), + ), + ) + .child( + h_flex().justify_end().child( + Button::new("logs-modal-close") + .label(t.close) + .on_click(cx.listener(|this, _, _, cx| { + this.dismiss_logs_modal(cx); + })), ), - ) + ), + ) } } diff --git a/src/app/mod.rs b/src/app/mod.rs index e11d452..bf62606 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1,18 +1,13 @@ use std::sync::mpsc::Receiver; -use gpui_kit::component::{ - ActiveTheme, ThemeMode, - h_flex, - input::InputState, - v_flex, -}; +use gpui_kit::component::{ActiveTheme, ThemeMode, h_flex, input::InputState, v_flex}; use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::*; use crate::i18n::Locale; use crate::wslc::{ - ContainerInfo, EngineStatus, ImageInfo, PullEvent, SdkSetupEvent, SessionPrefs, VolumeInfo, - WslcClient, + ContainerInfo, EngineStatus, ImageInfo, PullEvent, SdkSetupEvent, SessionPrefs, SetupStepState, + TemplateStore, VolumeInfo, WslcClient, }; mod confirm; @@ -57,10 +52,17 @@ pub struct AppView { bind_mounts_input: Entity, named_volumes_input: Entity, command_input: Entity, + template_name_input: Entity, + template_rename_input: Entity, pull_input: Entity, volume_input: Entity, exec_input: Entity, create_auto_remove: bool, + templates: TemplateStore, + template_save_open: bool, + template_renaming: Option, + template_banner_error: bool, + template_banner: Option, volume_size_gib: u32, session_prefs: SessionPrefs, pull_modal_open: bool, @@ -85,6 +87,11 @@ pub struct AppView { sdk_setup_logs: Vec, sdk_setup_rx: Option>, sdk_setup_poll_generation: u64, + wsl_step: SetupStepState, + sdk_step: SetupStepState, + sdk_logs_open: bool, + sdk_logs_copied: bool, + sdk_log_error: Option, } impl AppView { @@ -99,20 +106,18 @@ impl AppView { .placeholder("nginx:alpine") .default_value("nginx:alpine") }); - let name_input = - cx.new(|cx| InputState::new(window, cx).placeholder(t.name_placeholder)); - let ports_input = cx.new(|cx| { - InputState::new(window, cx).placeholder(t.ports_placeholder) - }); - let bind_mounts_input = cx.new(|cx| { - InputState::new(window, cx).placeholder(t.bind_mounts_placeholder) - }); - let named_volumes_input = cx.new(|cx| { - InputState::new(window, cx).placeholder(t.named_volumes_placeholder) - }); - let command_input = cx.new(|cx| { - InputState::new(window, cx).placeholder(t.command_placeholder) - }); + let name_input = cx.new(|cx| InputState::new(window, cx).placeholder(t.name_placeholder)); + let ports_input = cx.new(|cx| InputState::new(window, cx).placeholder(t.ports_placeholder)); + let bind_mounts_input = + cx.new(|cx| InputState::new(window, cx).placeholder(t.bind_mounts_placeholder)); + let named_volumes_input = + cx.new(|cx| InputState::new(window, cx).placeholder(t.named_volumes_placeholder)); + let command_input = + cx.new(|cx| InputState::new(window, cx).placeholder(t.command_placeholder)); + let template_name_input = + cx.new(|cx| InputState::new(window, cx).placeholder(t.template_name_placeholder)); + let template_rename_input = + cx.new(|cx| InputState::new(window, cx).placeholder(t.template_name_placeholder)); let pull_input = cx.new(|cx| { InputState::new(window, cx) .placeholder("library/alpine:latest") @@ -150,10 +155,17 @@ impl AppView { bind_mounts_input, named_volumes_input, command_input, + template_name_input, + template_rename_input, pull_input, volume_input, exec_input, create_auto_remove: false, + templates: TemplateStore::load(), + template_save_open: false, + template_renaming: None, + template_banner_error: false, + template_banner: None, volume_size_gib: 1, session_prefs, pull_modal_open: false, @@ -177,6 +189,11 @@ impl AppView { sdk_setup_logs: Vec::new(), sdk_setup_rx: None, sdk_setup_poll_generation: 0, + wsl_step: SetupStepState::Pending, + sdk_step: SetupStepState::Pending, + sdk_logs_open: false, + sdk_logs_copied: false, + sdk_log_error: None, }; view.begin_sdk_setup(false, cx); view @@ -191,58 +208,51 @@ impl Render for AppView { .size_full() .bg(cx.theme().background) .child( - h_flex() - .size_full() - .child(self.render_sidebar(cx)) - .child( - v_flex() - .size_full() - .min_w_0() - .child(self.render_title_bar(cx)) - .child( - v_flex() - .flex_1() - .min_h_0() - .p_4() - .gap_4() - .child(self.render_toolbar(cx)) - .child( - div().id("section").flex_1().min_h_0().map(|panel| { - match self.section { - Section::Overview => { - panel.child(self.render_overview(cx)) - } - Section::Containers => { - panel.child(self.render_containers(cx)) - } - Section::Images => { - panel.child(self.render_images(cx)) - } - Section::Volumes => { - panel.child(self.render_volumes(cx)) - } - Section::Settings => { - panel.child(self.render_settings(cx)) - } - } - }), - ), - ) - .child(self.render_status_bar(cx)), - ), + h_flex().size_full().child(self.render_sidebar(cx)).child( + v_flex() + .size_full() + .min_w_0() + .child(self.render_title_bar(cx)) + .child( + v_flex() + .flex_1() + .min_h_0() + .p_4() + .gap_4() + .child(self.render_toolbar(cx)) + .child(div().id("section").flex_1().min_h_0().map(|panel| { + match self.section { + Section::Overview => panel.child(self.render_overview(cx)), + Section::Containers => { + panel.child(self.render_containers(cx)) + } + Section::Images => panel.child(self.render_images(cx)), + Section::Volumes => panel.child(self.render_volumes(cx)), + Section::Settings => panel.child(self.render_settings(cx)), + } + })), + ) + .child(self.render_status_bar(cx)), + ), ) .when(show_setup, |root| { root.child(self.render_sdk_setup_modal(cx)) }) - .when(self.pull_modal_open, |root| root.child(self.render_pull_modal(cx))) + .when(self.pull_modal_open, |root| { + root.child(self.render_pull_modal(cx)) + }) .when(self.create_modal_open, |root| { root.child(self.render_create_modal(cx)) }) .when(self.volume_modal_open, |root| { root.child(self.render_volume_modal(cx)) }) - .when(self.logs_modal_open, |root| root.child(self.render_logs_modal(cx))) - .when(self.exec_modal_open, |root| root.child(self.render_exec_modal(cx))) + .when(self.logs_modal_open, |root| { + root.child(self.render_logs_modal(cx)) + }) + .when(self.exec_modal_open, |root| { + root.child(self.render_exec_modal(cx)) + }) .when(self.confirm.is_some(), |root| { root.child(self.render_confirm_modal(cx)) }) diff --git a/src/app/refresh.rs b/src/app/refresh.rs index f712f00..f30b209 100644 --- a/src/app/refresh.rs +++ b/src/app/refresh.rs @@ -1,27 +1,25 @@ use std::sync::mpsc; use std::time::Duration; -use gpui_kit::component::{ - ActiveTheme, Theme, ThemeMode, - h_flex, - label::Label, - v_flex, -}; +use gpui_kit::component::{ActiveTheme, Theme, ThemeMode, h_flex, label::Label, v_flex}; use gpui_kit::*; use smol::Timer; use crate::i18n::{self, Locale}; -use crate::wslc::{ - BackendKind, EngineStatus, WslcClient, -}; +use crate::wslc::{BackendKind, EngineStatus, WslcClient}; +use super::AppView; use super::poll::recv_all; use super::state::PullPhase; use super::widgets::modal_scrim; -use super::AppView; impl AppView { - pub(crate) fn apply_locale(&mut self, locale: Locale, window: &mut Window, cx: &mut Context) { + pub(crate) fn apply_locale( + &mut self, + locale: Locale, + window: &mut Window, + cx: &mut Context, + ) { if self.locale == locale { return; } @@ -42,6 +40,13 @@ impl AppView { self.command_input.update(cx, |state, cx| { state.set_placeholder(t.command_placeholder, window, cx); }); + self.template_name_input.update(cx, |state, cx| { + state.set_placeholder(t.template_name_placeholder, window, cx); + }); + self.template_rename_input.update(cx, |state, cx| { + state.set_placeholder(t.template_name_placeholder, window, cx); + }); + self.template_banner = None; self.volume_input.update(cx, |state, cx| { state.set_placeholder(t.volume_placeholder, window, cx); }); @@ -76,7 +81,12 @@ impl AppView { cx.notify(); } - pub(crate) fn apply_theme(&mut self, mode: ThemeMode, window: &mut Window, cx: &mut Context) { + pub(crate) fn apply_theme( + &mut self, + mode: ThemeMode, + window: &mut Window, + cx: &mut Context, + ) { if self.theme_mode == mode { return; } @@ -104,7 +114,9 @@ impl AppView { || lower.contains("wslcsdk.dll") || lower.contains("sdk install failed") { - if detail.is_empty() || lower.contains("wslcsdk.dll") || lower.contains("sdk not found") + if detail.is_empty() + || lower.contains("wslcsdk.dll") + || lower.contains("sdk not found") { t.engine_missing.to_string() } else { @@ -161,7 +173,8 @@ impl AppView { } Err(err) => { ok = false; - self.status_line = i18n::fmt1(t.list_containers_failed, "err", err.to_string()).into(); + self.status_line = + i18n::fmt1(t.list_containers_failed, "err", err.to_string()).into(); } } match client.list_images() { @@ -179,7 +192,10 @@ impl AppView { match client.list_volumes() { Ok(rows) => { self.volumes = rows; - if self.selected_volume.is_some_and(|i| i >= self.volumes.len()) { + if self + .selected_volume + .is_some_and(|i| i >= self.volumes.len()) + { self.selected_volume = None; } } diff --git a/src/app/sdk_setup.rs b/src/app/sdk_setup.rs index 942b5d4..ad5e576 100644 --- a/src/app/sdk_setup.rs +++ b/src/app/sdk_setup.rs @@ -1,7 +1,7 @@ use std::time::Duration; use gpui_kit::component::{ - ActiveTheme, IconName, StyledExt as _, + ActiveTheme, Disableable as _, IconName, Sizable as _, StyledExt as _, button::{Button, ButtonVariants as _}, h_flex, progress::Progress, @@ -13,17 +13,25 @@ use smol::Timer; use crate::i18n::{self}; use crate::wslc::{ - EngineStatus, SdkSetupEvent, WslcClient, + EngineStatus, SdkSetupEvent, SetupFault, SetupStepId, SetupStepState, WslcClient, setup_log, }; -use super::state::{SdkSetupPhase, MAX_SDK_LOG_LINES}; use super::AppView; use super::poll::recv_all; +use super::state::{MAX_SDK_LOG_LINES, SdkSetupPhase}; use super::widgets::{modal_panel, modal_scrim}; impl AppView { pub(crate) fn append_sdk_log(&mut self, line: impl Into) { - self.sdk_setup_logs.push(line.into()); + let line = line.into(); + if let Err(err) = setup_log::append_line(&line) + && self.sdk_log_error.is_none() + { + self.sdk_log_error = + Some(i18n::fmt1(self.t().sdk_setup_log_write_failed, "err", err).into()); + } + self.sdk_logs_copied = false; + self.sdk_setup_logs.push(line); if self.sdk_setup_logs.len() > MAX_SDK_LOG_LINES { let drop = self.sdk_setup_logs.len() - MAX_SDK_LOG_LINES; self.sdk_setup_logs.drain(0..drop); @@ -35,6 +43,13 @@ impl AppView { return; } + if let Err(err) = setup_log::begin_session() { + self.sdk_log_error = + Some(i18n::fmt1(self.t().sdk_setup_log_write_failed, "err", err).into()); + } else { + self.sdk_log_error = None; + } + let (tx, rx) = std::sync::mpsc::channel(); WslcClient::start_setup(force_redownload, tx); self.client = None; @@ -42,11 +57,14 @@ impl AppView { self.sdk_setup_phase = Some(SdkSetupPhase::Running); self.sdk_setup_progress = 0.0; self.sdk_setup_status = self.t().sdk_setup_installing.into(); + self.wsl_step = SetupStepState::Running; + self.sdk_step = SetupStepState::Pending; + self.sdk_logs_copied = false; self.sdk_setup_logs.clear(); self.append_sdk_log(if force_redownload { - "Retry: reinstalling WSL Containers SDK…" + self.t().sdk_setup_log_retry } else { - "Starting WSL Containers SDK setup…" + self.t().sdk_setup_log_start }); self.status = EngineStatus::unavailable(self.t().sdk_locked); self.status_line = self.t().sdk_locked.into(); @@ -73,6 +91,95 @@ impl AppView { .detach(); } + fn apply_setup_step(&mut self, id: SetupStepId, state: SetupStepState) { + let log_line = match &state { + SetupStepState::Passed { detail } if !detail.is_empty() => { + Some(self.step_passed_text(id, detail)) + } + SetupStepState::Failed(fault) if !matches!(fault, SetupFault::Message(_)) => { + Some(self.fault_text(fault)) + } + _ => None, + }; + match id { + SetupStepId::WslVersion => self.wsl_step = state, + SetupStepId::SdkInstall => self.sdk_step = state, + } + if let Some(line) = log_line { + self.append_sdk_log(line); + } + } + + fn fail_unfinished_step(&mut self, fault: SetupFault) { + if self.sdk_step.is_failed() || self.wsl_step.is_failed() { + return; + } + if matches!(self.sdk_step, SetupStepState::Running) + || matches!(self.wsl_step, SetupStepState::Passed { .. }) + { + self.sdk_step = SetupStepState::Failed(fault); + } else { + self.wsl_step = SetupStepState::Failed(fault); + } + } + + fn current_step_failure_text(&self) -> Option { + if let SetupStepState::Failed(fault) = &self.wsl_step { + return Some(self.fault_text(fault)); + } + if let SetupStepState::Failed(fault) = &self.sdk_step { + return Some(self.fault_text(fault)); + } + None + } + + fn fault_text(&self, fault: &SetupFault) -> String { + let t = self.t(); + let required = crate::wslc::min_wsl_version_text(); + match fault { + SetupFault::WslMissing => t.sdk_wsl_missing.to_string(), + SetupFault::WslTooOld { found } => { + i18n::fmt2(t.sdk_wsl_too_old, "found", found, "required", &required) + } + SetupFault::NotWindows => i18n::fmt1(t.sdk_not_windows, "required", &required), + SetupFault::Message(message) => message.clone(), + } + } + + fn step_passed_text(&self, id: SetupStepId, detail: &str) -> String { + let t = self.t(); + let required = crate::wslc::min_wsl_version_text(); + match id { + SetupStepId::WslVersion => { + i18n::fmt2(t.sdk_wsl_ok, "version", detail, "required", &required) + } + SetupStepId::SdkInstall => i18n::fmt1(t.sdk_install_ok, "version", detail), + } + } + + fn copy_sdk_logs(&mut self, cx: &mut Context) { + if self.sdk_setup_logs.is_empty() { + return; + } + let text = self + .sdk_setup_logs + .iter() + .map(|line| line.to_string()) + .collect::>() + .join("\n"); + cx.write_to_clipboard(ClipboardItem::new_string(text)); + self.sdk_logs_copied = true; + cx.notify(); + } + + fn open_sdk_log_folder(&mut self, cx: &mut Context) { + if let Err(err) = setup_log::open_log_dir() { + self.sdk_log_error = + Some(i18n::fmt1(self.t().sdk_setup_open_log_folder_failed, "err", err).into()); + } + cx.notify(); + } + /// Returns `true` while setup is still running. pub(crate) fn poll_sdk_setup(&mut self, cx: &mut Context) -> bool { let Some(rx) = self.sdk_setup_rx.take() else { @@ -93,6 +200,9 @@ impl AppView { SdkSetupEvent::Log(line) => { self.append_sdk_log(line); } + SdkSetupEvent::Step { id, state } => { + self.apply_setup_step(id, state); + } SdkSetupEvent::Finished(result) => { finished = true; match result { @@ -101,18 +211,25 @@ impl AppView { self.sdk_setup_phase = None; self.sdk_setup_progress = 100.0; self.sdk_setup_status = self.t().ready.into(); - self.append_sdk_log("✓ SDK connected"); + self.append_sdk_log(self.t().sdk_setup_log_connected); self.refresh(cx); } Err(err) => { self.client = None; self.sdk_setup_phase = Some(SdkSetupPhase::Failed); + if !self.wsl_step.is_failed() && !self.sdk_step.is_failed() { + self.fail_unfinished_step(SetupFault::Message(err.clone())); + self.append_sdk_log(format!("✗ {err}")); + } self.status = EngineStatus::unavailable(err.clone()); self.status.message = self.localize_engine_message(&self.status); - let msg = i18n::fmt1(self.t().engine_unusable, "err", &err); self.sdk_setup_status = self.t().sdk_setup_failed.into(); - self.append_sdk_log(format!("✗ {err}")); - self.status_line = msg.into(); + self.status_line = self + .current_step_failure_text() + .unwrap_or_else(|| { + i18n::fmt1(self.t().engine_unusable, "err", &err) + }) + .into(); } } } @@ -120,10 +237,13 @@ impl AppView { } if disconnected && self.sdk_setup_phase == Some(SdkSetupPhase::Running) { + let message = self.t().sdk_setup_channel_disconnected.to_string(); self.sdk_setup_phase = Some(SdkSetupPhase::Failed); - self.sdk_setup_status = self.t().sdk_setup_channel_disconnected.into(); - self.append_sdk_log(self.t().sdk_setup_channel_disconnected); - self.status = EngineStatus::unavailable(self.t().sdk_setup_channel_disconnected); + self.sdk_setup_status = message.clone().into(); + self.fail_unfinished_step(SetupFault::Message(message.clone())); + self.append_sdk_log(message.clone()); + self.status = EngineStatus::unavailable(message.clone()); + self.status_line = message.into(); finished = true; } @@ -135,6 +255,69 @@ impl AppView { cx.notify(); self.sdk_setup_phase == Some(SdkSetupPhase::Running) } + + fn render_setup_step( + &self, + id: SetupStepId, + index: u8, + title: &'static str, + state: &SetupStepState, + cx: &Context, + ) -> Div { + let t = self.t(); + let (badge, color) = match state { + SetupStepState::Pending => (t.sdk_setup_step_pending, cx.theme().muted_foreground), + SetupStepState::Running => (t.sdk_setup_step_checking, cx.theme().blue), + SetupStepState::Passed { .. } => (t.sdk_setup_step_passed, cx.theme().green), + SetupStepState::Failed(_) => (t.sdk_setup_step_failed, cx.theme().red), + }; + let detail = match state { + SetupStepState::Passed { detail } if !detail.is_empty() => Some(( + self.step_passed_text(id, detail), + cx.theme().muted_foreground, + )), + SetupStepState::Failed(fault) => Some((self.fault_text(fault), cx.theme().red)), + _ => None, + }; + + v_flex() + .gap_1() + .w_full() + .child( + h_flex() + .items_center() + .gap_2() + .child( + div() + .flex() + .items_center() + .justify_center() + .size_6() + .rounded_full() + .bg(color.opacity(0.18)) + .text_color(color) + .text_xs() + .font_semibold() + .child(index.to_string()), + ) + .child(div().flex_1().text_sm().font_medium().child(title)) + .child( + div() + .px_2() + .py_0p5() + .rounded(cx.theme().radius) + .bg(color.opacity(0.18)) + .text_color(color) + .text_xs() + .font_semibold() + .child(badge), + ), + ) + .when_some(detail, |row, (text, color)| { + row.child(div().pl_6().text_xs().text_color(color).child(text)) + }) + } + pub(crate) fn render_sdk_setup_modal(&self, cx: &mut Context) -> impl IntoElement { let t = self.t(); let phase = self.sdk_setup_phase.unwrap_or(SdkSetupPhase::Running); @@ -144,78 +327,125 @@ impl AppView { } else { cx.theme().blue }; - let logs = self - .sdk_setup_logs - .iter() - .cloned() - .collect::>(); + let logs = self.sdk_setup_logs.clone(); + let logs_open = self.sdk_logs_open; + let copied = self.sdk_logs_copied; + let log_path = setup_log::log_file_path(); + let log_error = self.sdk_log_error.clone(); + let wsl_step = self.wsl_step.clone(); + let sdk_step = self.sdk_step.clone(); modal_scrim( "sdk-setup-modal", cx, modal_panel(cx) - .w(px(560.)) - .max_h(px(520.)) - .child( - h_flex() - .items_center() - .justify_between() - .child(div().font_semibold().text_base().child(t.sdk_setup_title)) - .child( - div() - .px_2() - .py_0p5() - .rounded(cx.theme().radius) - .bg(badge_color.opacity(0.18)) - .text_color(badge_color) - .text_xs() - .font_semibold() - .child(phase.label(t)), - ), - ) - .child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(t.sdk_setup_intro), - ) - .child( - h_flex() - .items_center() - .justify_between() - .child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .truncate() - .child(self.sdk_setup_status.clone()), + .id("sdk-setup-panel") + .w(px(600.)) + .max_h(px(680.)) + .overflow_y_scroll() + .child( + h_flex() + .items_center() + .justify_between() + .child(div().font_semibold().text_base().child(t.sdk_setup_title)) + .child( + div() + .px_2() + .py_0p5() + .rounded(cx.theme().radius) + .bg(badge_color.opacity(0.18)) + .text_color(badge_color) + .text_xs() + .font_semibold() + .child(phase.label(t)), + ), + ) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(t.sdk_setup_intro), + ) + .child(self.render_setup_step( + SetupStepId::WslVersion, + 1, + t.sdk_setup_step_wsl, + &wsl_step, + cx, + )) + .child(self.render_setup_step( + SetupStepId::SdkInstall, + 2, + t.sdk_setup_step_sdk, + &sdk_step, + cx, + )) + .child( + h_flex() + .items_center() + .justify_between() + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .truncate() + .child(self.sdk_setup_status.clone()), + ) + .child( + div() + .text_xs() + .font_semibold() + .child(format!("{:.0}%", self.sdk_setup_progress)), + ), + ) + .child( + Progress::new("sdk-setup-progress") + .w_full() + .h_2() + .value(self.sdk_setup_progress), + ) + .child( + h_flex() + .gap_2() + .child( + Button::new("sdk-setup-view-logs") + .label(if logs_open { + t.sdk_setup_hide_logs + } else { + t.sdk_setup_view_logs + }) + .when(failed, |button| button.primary()) + .on_click(cx.listener(|this, _, _, cx| { + this.sdk_logs_open = !this.sdk_logs_open; + cx.notify(); + })), + ) + .when(failed, |row| { + row.child( + Button::new("sdk-setup-retry") + .primary() + .label(t.sdk_setup_retry) + .icon(IconName::RefreshCw) + .on_click(cx.listener(|this, _, _, cx| { + this.begin_sdk_setup(true, cx); + })), ) - .child( - div() - .text_xs() - .font_semibold() - .child(format!("{:.0}%", self.sdk_setup_progress)), - ), - ) - .child( - Progress::new("sdk-setup-progress") - .w_full() - .h_2() - .value(self.sdk_setup_progress), - ) - .child( - div() - .id("sdk-setup-logs") - .h(px(220.)) - .w_full() - .overflow_y_scroll() - .rounded(cx.theme().radius) - .border_1() - .border_color(cx.theme().border) - .bg(cx.theme().background.opacity(0.65)) - .p_2() - .child( - v_flex().gap_0p5().w_full().map(|list| { + }), + ) + .when(logs_open, |panel| { + panel + .child( + div() + .id("sdk-setup-logs") + .h(px(220.)) + .w_full() + .overflow_y_scroll() + .rounded(cx.theme().radius) + .border_1() + .border_color(cx.theme().border) + .bg(cx.theme().background.opacity(0.65)) + .p_2() + .child(v_flex().gap_0p5().w_full().map(|list| { if logs.is_empty() { list.child( div() @@ -227,24 +457,49 @@ impl AppView { list.children(logs.into_iter().map(|line| { div() .text_xs() - .text_color(cx.theme().muted_foreground) + .font_family("monospace") + .text_color(cx.theme().foreground) .child(line) })) } - }), - ), - ) - .when(failed, |el| { - el.child( - Button::new("sdk-setup-retry") - .primary() - .label(t.sdk_setup_retry) - .icon(IconName::RefreshCw) - .on_click(cx.listener(|this, _, _, cx| { - this.begin_sdk_setup(true, cx); })), ) - }), - ) + .child( + h_flex() + .gap_2() + .child( + Button::new("sdk-setup-copy-logs") + .small() + .label(if copied { + t.sdk_setup_logs_copied + } else { + t.sdk_setup_copy_logs + }) + .disabled(self.sdk_setup_logs.is_empty()) + .on_click(cx.listener(|this, _, _, cx| { + this.copy_sdk_logs(cx); + })), + ) + .child( + Button::new("sdk-setup-open-log-dir") + .small() + .label(t.sdk_setup_open_log_folder) + .icon(IconName::Folder) + .on_click(cx.listener(|this, _, _, cx| { + this.open_sdk_log_folder(cx); + })), + ), + ) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(format!("{}: {}", t.sdk_setup_log_file, log_path.display())), + ) + .when_some(log_error, |panel, err| { + panel.child(div().text_xs().text_color(cx.theme().red).child(err)) + }) + }), + ) } } diff --git a/src/i18n.rs b/src/i18n.rs index f467bdc..9076b29 100644 --- a/src/i18n.rs +++ b/src/i18n.rs @@ -129,6 +129,28 @@ pub struct Strings { pub sdk_setup_phase_failed: &'static str, pub sdk_setup_channel_disconnected: &'static str, pub sdk_locked: &'static str, + pub sdk_setup_view_logs: &'static str, + pub sdk_setup_hide_logs: &'static str, + pub sdk_setup_copy_logs: &'static str, + pub sdk_setup_logs_copied: &'static str, + pub sdk_setup_open_log_folder: &'static str, + pub sdk_setup_open_log_folder_failed: &'static str, + pub sdk_setup_log_file: &'static str, + pub sdk_setup_log_write_failed: &'static str, + pub sdk_setup_step_wsl: &'static str, + pub sdk_setup_step_sdk: &'static str, + pub sdk_setup_step_pending: &'static str, + pub sdk_setup_step_checking: &'static str, + pub sdk_setup_step_passed: &'static str, + pub sdk_setup_step_failed: &'static str, + pub sdk_wsl_missing: &'static str, + pub sdk_wsl_too_old: &'static str, + pub sdk_wsl_ok: &'static str, + pub sdk_install_ok: &'static str, + pub sdk_not_windows: &'static str, + pub sdk_setup_log_start: &'static str, + pub sdk_setup_log_retry: &'static str, + pub sdk_setup_log_connected: &'static str, pub create_container_title: &'static str, pub create_container_intro: &'static str, @@ -147,6 +169,25 @@ pub struct Strings { pub auto_remove_badge: &'static str, pub command_label: &'static str, pub command_placeholder: &'static str, + pub template_section: &'static str, + pub template_empty: &'static str, + pub template_save: &'static str, + pub template_save_confirm: &'static str, + pub template_name_label: &'static str, + pub template_name_placeholder: &'static str, + pub template_name_empty: &'static str, + pub template_name_too_long: &'static str, + pub template_name_taken: &'static str, + pub template_missing: &'static str, + pub template_saved: &'static str, + pub template_updated: &'static str, + pub template_renamed: &'static str, + pub template_deleted: &'static str, + pub template_rename: &'static str, + pub template_delete: &'static str, + pub template_apply: &'static str, + pub template_filled: &'static str, + pub template_save_failed: &'static str, pub volume_placeholder: &'static str, pub volume_size_label: &'static str, @@ -293,8 +334,8 @@ pub static EN: Strings = Strings { pulled_image: "Pulled image", sdk_setup_title: "WSL Containers SDK", - sdk_setup_intro: "Install or update the SDK to continue. The app stays locked until a live session starts. If this keeps failing, install or update WSL yourself (`wsl --install` / `wsl --update`), then retry.", - sdk_setup_retry: "Retry install SDK", + sdk_setup_intro: "Two steps run before the app unlocks: confirm WSL is 2.9.3 or newer, then download and install the SDK. If a step fails, the reason is shown on that step.", + sdk_setup_retry: "Retry", sdk_setup_installing: "Installing / connecting…", sdk_setup_failed: "SDK setup failed", sdk_setup_logs_placeholder: "Setup logs will appear here.", @@ -302,6 +343,28 @@ pub static EN: Strings = Strings { sdk_setup_phase_failed: "FAILED", sdk_setup_channel_disconnected: "SDK setup channel disconnected", sdk_locked: "Waiting for WSL Containers SDK…", + sdk_setup_view_logs: "View logs", + sdk_setup_hide_logs: "Hide logs", + sdk_setup_copy_logs: "Copy logs", + sdk_setup_logs_copied: "Copied", + sdk_setup_open_log_folder: "Open log folder", + sdk_setup_open_log_folder_failed: "Could not open the log folder: {err}", + sdk_setup_log_file: "Log file", + sdk_setup_log_write_failed: "Could not write the log file: {err}", + sdk_setup_step_wsl: "Check WSL version", + sdk_setup_step_sdk: "Download and install SDK", + sdk_setup_step_pending: "Waiting", + sdk_setup_step_checking: "Checking", + sdk_setup_step_passed: "Passed", + sdk_setup_step_failed: "Failed", + sdk_wsl_missing: "WSL is missing, or its version could not be read. Install or update WSL with `wsl --update`, then retry.", + sdk_wsl_too_old: "WSL {found} is older than {required}. Run `wsl --update`, then retry.", + sdk_wsl_ok: "WSL {version} (requires {required}+)", + sdk_install_ok: "SDK {version}", + sdk_not_windows: "This app only runs on Windows with WSL {required} or newer.", + sdk_setup_log_start: "Starting WSL Containers SDK setup…", + sdk_setup_log_retry: "Retry: checking WSL again and reinstalling the SDK…", + sdk_setup_log_connected: "SDK connected", create_container_title: "Create container", create_container_intro: "Map host ports (e.g. 8080:80) to reach the container. Leave command empty to use the image entrypoint.", @@ -320,6 +383,25 @@ pub static EN: Strings = Strings { auto_remove_badge: "auto-remove", command_label: "Command (optional)", command_placeholder: "optional; leave empty for image default", + template_section: "Fill from template", + template_empty: "No templates yet. Fill in the form, then save it as a template.", + template_save: "Save as template", + template_save_confirm: "Save", + template_name_label: "Template name", + template_name_placeholder: "e.g. nginx dev", + template_name_empty: "Enter a template name", + template_name_too_long: "Template name is too long", + template_name_taken: "A template named {name} already exists", + template_missing: "Template not found", + template_saved: "Saved template {name}", + template_updated: "Updated template {name}", + template_renamed: "Renamed template to {name}", + template_deleted: "Deleted template {name}", + template_rename: "Rename", + template_delete: "Delete", + template_apply: "Fill", + template_filled: "Filled from template {name}. Fields stay editable.", + template_save_failed: "Could not save templates: {err}", volume_placeholder: "my-volume", volume_size_label: "Size", @@ -463,8 +545,8 @@ pub static ZH: Strings = Strings { pulled_image: "已拉取镜像", sdk_setup_title: "WSL Containers SDK", - sdk_setup_intro: "请安装或更新 SDK 后才能继续。在成功建立实时会话前,应用将保持锁定。若反复失败,请自行安装/更新 WSL(`wsl --install` / `wsl --update`),然后再重试。", - sdk_setup_retry: "重新安装 SDK", + sdk_setup_intro: "应用解锁前会走两步:确认 WSL 为 2.9.3 或更高,然后下载并安装 SDK。某一步失败时,原因会直接显示在该步骤上。", + sdk_setup_retry: "重试", sdk_setup_installing: "正在安装 / 连接…", sdk_setup_failed: "SDK 安装失败", sdk_setup_logs_placeholder: "安装日志将显示在这里。", @@ -472,6 +554,28 @@ pub static ZH: Strings = Strings { sdk_setup_phase_failed: "失败", sdk_setup_channel_disconnected: "SDK 安装通道已断开", sdk_locked: "等待 WSL Containers SDK…", + sdk_setup_view_logs: "查看日志", + sdk_setup_hide_logs: "隐藏日志", + sdk_setup_copy_logs: "复制日志", + sdk_setup_logs_copied: "已复制", + sdk_setup_open_log_folder: "打开日志文件夹", + sdk_setup_open_log_folder_failed: "无法打开日志文件夹: {err}", + sdk_setup_log_file: "日志文件", + sdk_setup_log_write_failed: "无法写入日志文件: {err}", + sdk_setup_step_wsl: "检查 WSL 版本", + sdk_setup_step_sdk: "下载并安装 SDK", + sdk_setup_step_pending: "等待", + sdk_setup_step_checking: "检查中", + sdk_setup_step_passed: "通过", + sdk_setup_step_failed: "失败", + sdk_wsl_missing: "未检测到 WSL,或无法读取版本。请运行 `wsl --update` 安装或更新后再重试。", + sdk_wsl_too_old: "WSL {found} 低于要求的 {required}。请运行 `wsl --update` 后再重试。", + sdk_wsl_ok: "WSL {version}(要求 {required}+)", + sdk_install_ok: "SDK {version}", + sdk_not_windows: "本应用仅能在安装了 WSL {required} 或更高版本的 Windows 上运行。", + sdk_setup_log_start: "开始检查 WSL 并安装 SDK…", + sdk_setup_log_retry: "重试:重新检查 WSL 并安装 SDK…", + sdk_setup_log_connected: "SDK 已连接", create_container_title: "创建容器", create_container_intro: "请映射主机端口(如 8080:80)才能从本机访问。命令留空则使用镜像默认入口。", @@ -490,6 +594,25 @@ pub static ZH: Strings = Strings { auto_remove_badge: "退出删除", command_label: "命令(可选)", command_placeholder: "可选;留空则用镜像默认命令", + template_section: "从模板填充", + template_empty: "还没有模板。填好下面的配置后,可以存为模板。", + template_save: "存为模板", + template_save_confirm: "保存", + template_name_label: "模板名称", + template_name_placeholder: "例如:nginx 开发", + template_name_empty: "请填写模板名称", + template_name_too_long: "模板名称过长", + template_name_taken: "已有同名模板「{name}」", + template_missing: "找不到该模板", + template_saved: "已保存模板 {name}", + template_updated: "已更新模板 {name}", + template_renamed: "已将模板重命名为 {name}", + template_deleted: "已删除模板 {name}", + template_rename: "重命名", + template_delete: "删除", + template_apply: "填充", + template_filled: "已从模板「{name}」填充,字段仍可修改。", + template_save_failed: "保存模板失败: {err}", volume_placeholder: "我的卷", volume_size_label: "容量", @@ -596,9 +719,6 @@ mod tests { #[test] fn fmt_helpers() { assert_eq!(fmt1("Hello {name}", "name", "WSLc"), "Hello WSLc"); - assert_eq!( - fmt2("{a}/{b}", "a", "1", "b", "2"), - "1/2" - ); + assert_eq!(fmt2("{a}/{b}", "a", "1", "b", "2"), "1/2"); } } diff --git a/src/wslc/catalog.rs b/src/wslc/catalog.rs index 1046873..2e7e10c 100644 --- a/src/wslc/catalog.rs +++ b/src/wslc/catalog.rs @@ -8,6 +8,7 @@ use std::path::PathBuf; use serde::{Deserialize, Serialize}; +use super::paths::{app_data_dir, write_atomic}; use super::types::VolumeInfo; pub const CATALOG_VERSION: u32 = 1; @@ -49,10 +50,7 @@ impl Default for Catalog { #[cfg_attr(not(windows), allow(dead_code))] impl Catalog { pub fn catalog_path() -> PathBuf { - let base = std::env::var_os("LOCALAPPDATA") - .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from(r"C:\WslcData")); - base.join("WSLcDesktop").join("catalog.json") + app_data_dir().join("catalog.json") } pub fn load() -> Self { @@ -70,15 +68,9 @@ impl Catalog { } pub fn save(&self) -> Result<(), String> { - let path = Self::catalog_path(); - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).map_err(|e| format!("create catalog dir: {e}"))?; - } - let json = serde_json::to_vec_pretty(self).map_err(|e| format!("serialize catalog: {e}"))?; - let tmp = path.with_extension("json.tmp"); - fs::write(&tmp, &json).map_err(|e| format!("write catalog tmp: {e}"))?; - fs::rename(&tmp, &path).map_err(|e| format!("replace catalog: {e}"))?; - Ok(()) + let json = + serde_json::to_vec_pretty(self).map_err(|e| format!("serialize catalog: {e}"))?; + write_atomic(&Self::catalog_path(), &json) } } diff --git a/src/wslc/mod.rs b/src/wslc/mod.rs index 1b5d144..92123f3 100644 --- a/src/wslc/mod.rs +++ b/src/wslc/mod.rs @@ -3,26 +3,35 @@ //! There is no CLI path and no demo mode. The app must obtain a live SDK session //! (download/cache `wslcsdk.dll`, then open a Session) before any operations. -use std::sync::mpsc::Sender; use std::sync::Arc; +use std::sync::mpsc::Sender; use std::thread; -use anyhow::{anyhow, Result}; +use anyhow::{Result, anyhow}; mod catalog; #[cfg(windows)] mod live; +mod paths; mod prefs; #[cfg(windows)] mod sdk_fetch; mod sdk_version; +pub mod setup_log; #[cfg(windows)] mod syspath; +pub mod templates; mod types; pub use prefs::SessionPrefs; +pub use templates::{ContainerTemplate, TemplateEditError, TemplateStore, UpsertKind}; pub use types::*; +/// Minimum WSL release the first-launch check accepts (`2.9.3`). +pub fn min_wsl_version_text() -> String { + sdk_version::MIN_WSL_VERSION.to_string() +} + pub(super) fn split_ref(reference: &str) -> (String, String) { if let Some((repo, tag)) = reference.rsplit_once(':') { if !tag.contains('/') { @@ -95,6 +104,10 @@ impl WslcClient { "This build is not Windows; the WSL Containers SDK cannot load.".into(), )); let _ = tx.send(SdkSetupEvent::Progress(0.0)); + let _ = tx.send(SdkSetupEvent::Step { + id: SetupStepId::WslVersion, + state: SetupStepState::Failed(SetupFault::NotWindows), + }); let _ = tx.send(SdkSetupEvent::Finished(Err( "WSLc Desktop only runs on Windows with WSL Containers".into(), ))); @@ -108,6 +121,14 @@ impl WslcClient { #[cfg(windows)] fn run_setup_windows(force_redownload: bool, tx: Sender) { + if !Self::check_wsl_version(&tx) { + return; + } + + let _ = tx.send(SdkSetupEvent::Step { + id: SetupStepId::SdkInstall, + state: SetupStepState::Running, + }); let _ = tx.send(SdkSetupEvent::Status( "Preparing WSL Containers SDK…".into(), )); @@ -139,11 +160,14 @@ impl WslcClient { preferred.push(dir); } Err(err) => { - let _ = tx.send(SdkSetupEvent::Log(format!("SDK install failed: {err:#}"))); + let message = format!("SDK install failed: {err:#}"); + let _ = tx.send(SdkSetupEvent::Log(message.clone())); let _ = tx.send(SdkSetupEvent::Progress(0.0)); - let _ = tx.send(SdkSetupEvent::Finished(Err(format!( - "SDK install failed: {err:#}" - )))); + let _ = tx.send(SdkSetupEvent::Step { + id: SetupStepId::SdkInstall, + state: SetupStepState::Failed(SetupFault::Message(message.clone())), + }); + let _ = tx.send(SdkSetupEvent::Finished(Err(message))); return; } } @@ -163,16 +187,90 @@ impl WslcClient { ))); let _ = tx.send(SdkSetupEvent::Progress(100.0)); let _ = tx.send(SdkSetupEvent::Status("Connected".into())); + let _ = tx.send(SdkSetupEvent::Step { + id: SetupStepId::SdkInstall, + state: SetupStepState::Passed { + detail: version.clone(), + }, + }); let _ = tx.send(SdkSetupEvent::Finished(Ok(Self::from_live( cmd_tx, version, )))); } Err(detail) => { + let _ = tx.send(SdkSetupEvent::Log(format!("Live session failed: {detail}"))); + let _ = tx.send(SdkSetupEvent::Progress(0.0)); + let _ = tx.send(SdkSetupEvent::Step { + id: SetupStepId::SdkInstall, + state: SetupStepState::Failed(SetupFault::Message(detail.clone())), + }); + let _ = tx.send(SdkSetupEvent::Finished(Err(detail))); + } + } + } + + /// Returns false when setup must stop (event stream already finished). + #[cfg(windows)] + fn check_wsl_version(tx: &Sender) -> bool { + let _ = tx.send(SdkSetupEvent::Step { + id: SetupStepId::WslVersion, + state: SetupStepState::Running, + }); + let _ = tx.send(SdkSetupEvent::Status("Checking WSL version…".into())); + let _ = tx.send(SdkSetupEvent::Progress(4.0)); + let required = sdk_version::MIN_WSL_VERSION; + let _ = tx.send(SdkSetupEvent::Log(format!( + "Checking WSL version (require {required} or newer)…" + ))); + + let output = sdk_fetch::probe_wsl_version_output(); + if let Some(out) = &output { + for line in out.lines() { + let line = line.trim(); + if !line.is_empty() { + let _ = tx.send(SdkSetupEvent::Log(format!("wsl: {line}"))); + } + } + } else { + let _ = tx.send(SdkSetupEvent::Log( + "wsl --version produced no output (is WSL installed and on PATH?)".into(), + )); + } + + match sdk_version::judge_wsl_version(output.as_deref()) { + sdk_version::WslVersionVerdict::Ok(version) => { let _ = tx.send(SdkSetupEvent::Log(format!( - "Live session failed: {detail}" + "WSL {version} meets the {required}+ requirement" ))); + let _ = tx.send(SdkSetupEvent::Step { + id: SetupStepId::WslVersion, + state: SetupStepState::Passed { detail: version }, + }); + true + } + sdk_version::WslVersionVerdict::Missing => { let _ = tx.send(SdkSetupEvent::Progress(0.0)); - let _ = tx.send(SdkSetupEvent::Finished(Err(detail))); + let _ = tx.send(SdkSetupEvent::Step { + id: SetupStepId::WslVersion, + state: SetupStepState::Failed(SetupFault::WslMissing), + }); + let _ = tx.send(SdkSetupEvent::Finished(Err( + "WSL is missing or its version could not be read".into(), + ))); + false + } + sdk_version::WslVersionVerdict::TooOld { found } => { + let _ = tx.send(SdkSetupEvent::Progress(0.0)); + let _ = tx.send(SdkSetupEvent::Step { + id: SetupStepId::WslVersion, + state: SetupStepState::Failed(SetupFault::WslTooOld { + found: found.clone(), + }), + }); + let _ = tx.send(SdkSetupEvent::Finished(Err(format!( + "WSL {found} is older than {required}" + )))); + false } } } @@ -187,24 +285,21 @@ impl WslcClient { return status; } } - EngineStatus::unavailable(format!( - "SDK session became unavailable (was {version})" - )) + EngineStatus::unavailable(format!("SDK session became unavailable (was {version})")) } #[cfg(not(windows))] - Backend::Unsupported => EngineStatus::unavailable( - "WSLc Desktop only runs on Windows with WSL Containers", - ), + Backend::Unsupported => { + EngineStatus::unavailable("WSLc Desktop only runs on Windows with WSL Containers") + } } } pub fn list_containers(&self, all: bool) -> Result> { match &self.inner.backend { #[cfg(windows)] - Backend::Live { tx, .. } => live::call(tx, |reply| live::Cmd::ListContainers { - all, - reply, - }), + Backend::Live { tx, .. } => { + live::call(tx, |reply| live::Cmd::ListContainers { all, reply }) + } #[cfg(not(windows))] Backend::Unsupported => Err(anyhow!("WSL Containers SDK unavailable")), } @@ -416,7 +511,7 @@ impl WslcClient { #[cfg(not(windows))] Backend::Unsupported => { let _ = tx.send(PullEvent::Finished(Err( - "WSL Containers SDK unavailable".into(), + "WSL Containers SDK unavailable".into() ))); } } diff --git a/src/wslc/paths.rs b/src/wslc/paths.rs new file mode 100644 index 0000000..9926577 --- /dev/null +++ b/src/wslc/paths.rs @@ -0,0 +1,22 @@ +//! Shared desktop data directory (`%LOCALAPPDATA%\WSLcDesktop`). + +use std::fs; +use std::path::{Path, PathBuf}; + +pub fn app_data_dir() -> PathBuf { + let base = std::env::var_os("LOCALAPPDATA") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(r"C:\WslcData")); + base.join("WSLcDesktop") +} + +/// Write `bytes` via a sibling temp file, then rename over `path`. +pub fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), String> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|e| format!("create dir: {e}"))?; + } + let tmp = path.with_extension("json.tmp"); + fs::write(&tmp, bytes).map_err(|e| format!("write tmp: {e}"))?; + fs::rename(&tmp, path).map_err(|e| format!("replace file: {e}"))?; + Ok(()) +} diff --git a/src/wslc/prefs.rs b/src/wslc/prefs.rs index da15d0d..0ae5f59 100644 --- a/src/wslc/prefs.rs +++ b/src/wslc/prefs.rs @@ -5,6 +5,8 @@ use std::path::PathBuf; use serde::{Deserialize, Serialize}; +use super::paths::{app_data_dir, write_atomic}; + pub const DEFAULT_CPU_COUNT: u32 = 2; pub const DEFAULT_MEMORY_MB: u32 = 2048; @@ -26,10 +28,7 @@ impl Default for SessionPrefs { #[cfg_attr(not(windows), allow(dead_code))] impl SessionPrefs { pub fn path() -> PathBuf { - let base = std::env::var_os("LOCALAPPDATA") - .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from(r"C:\WslcData")); - base.join("WSLcDesktop").join("prefs.json") + app_data_dir().join("prefs.json") } pub fn load() -> Self { @@ -51,15 +50,8 @@ impl SessionPrefs { } pub fn save(&self) -> Result<(), String> { - let path = Self::path(); - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).map_err(|e| format!("create prefs dir: {e}"))?; - } let json = serde_json::to_vec_pretty(self).map_err(|e| format!("serialize prefs: {e}"))?; - let tmp = path.with_extension("json.tmp"); - fs::write(&tmp, json).map_err(|e| format!("write prefs tmp: {e}"))?; - fs::rename(&tmp, path).map_err(|e| format!("replace prefs: {e}"))?; - Ok(()) + write_atomic(&Self::path(), &json) } pub fn clamp(mut self) -> Self { diff --git a/src/wslc/sdk_fetch.rs b/src/wslc/sdk_fetch.rs index d512d7d..6de4b78 100644 --- a/src/wslc/sdk_fetch.rs +++ b/src/wslc/sdk_fetch.rs @@ -204,6 +204,21 @@ fn native_arch_folder() -> &'static str { } } +/// Raw `wsl --version` text, if the command ran and printed anything. +pub(crate) fn probe_wsl_version_output() -> Option { + for args in [ + ["wsl", "--version"].as_slice(), + ["wsl.exe", "--version"].as_slice(), + ] { + if let Ok(out) = run_hidden(args) { + if !out.trim().is_empty() { + return Some(out); + } + } + } + None +} + fn probe_host_version() -> Result { for args in [ ["wslc", "version"].as_slice(), diff --git a/src/wslc/sdk_version.rs b/src/wslc/sdk_version.rs index 55b046a..1f5f4d7 100644 --- a/src/wslc/sdk_version.rs +++ b/src/wslc/sdk_version.rs @@ -21,6 +21,80 @@ impl SemVer { } } +impl std::fmt::Display for SemVer { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}.{}.{}", self.major, self.minor, self.patch) + } +} + +/// First-launch gate. Older hosts cannot run the WSL Containers SDK this app targets. +pub const MIN_WSL_VERSION: SemVer = SemVer { + major: 2, + minor: 9, + patch: 3, +}; + +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(not(windows), allow(dead_code))] +pub enum WslVersionVerdict { + Ok(String), + Missing, + TooOld { found: String }, +} + +/// Classify `wsl --version` output. +/// +/// Prefers a line labeled `WSL` (English or `WSL 版本`) and ignores `WSLg`. +/// A single bare version token is accepted. Multiple unlabeled versions are +/// treated as unreadable so a kernel version cannot pass the check by accident. +#[cfg_attr(not(windows), allow(dead_code))] +pub fn judge_wsl_version(output: Option<&str>) -> WslVersionVerdict { + let Some(text) = output.map(str::trim).filter(|s| !s.is_empty()) else { + return WslVersionVerdict::Missing; + }; + let version = labeled_wsl_version(text).or_else(|| sole_version(text)); + match version { + Some(v) => match SemVer::parse(&v) { + Some(sv) if sv >= MIN_WSL_VERSION => WslVersionVerdict::Ok(v), + Some(_) => WslVersionVerdict::TooOld { found: v }, + None => WslVersionVerdict::Missing, + }, + None => WslVersionVerdict::Missing, + } +} + +#[cfg_attr(not(windows), allow(dead_code))] +fn labeled_wsl_version(text: &str) -> Option { + let line = text.lines().find(|line| { + let head = line + .split(':') + .next() + .unwrap_or("") + .trim() + .to_ascii_lowercase(); + head.starts_with("wsl") && !head.starts_with("wslg") + })?; + extract_version(line) +} + +#[cfg_attr(not(windows), allow(dead_code))] +fn sole_version(text: &str) -> Option { + let mut found = None; + for token in text.split(|c: char| !(c.is_ascii_digit() || c == '.')) { + if token.is_empty() { + continue; + } + let Some(version) = extract_version(token) else { + continue; + }; + if found.is_some() { + return None; + } + found = Some(version); + } + found +} + /// Pull the first `x.y.z` / `x.y.z.w` from command output. pub fn extract_version(text: &str) -> Option { for token in text.split(|c: char| !(c.is_ascii_digit() || c == '.')) { @@ -99,4 +173,49 @@ mod tests { Some("3.0.1") ); } + + #[test] + fn judges_wsl_version_output() { + assert_eq!(judge_wsl_version(None), WslVersionVerdict::Missing); + assert_eq!(judge_wsl_version(Some(" ")), WslVersionVerdict::Missing); + assert_eq!( + judge_wsl_version(Some("2.9.3")), + WslVersionVerdict::Ok("2.9.3".into()) + ); + assert_eq!( + judge_wsl_version(Some("2.10.0")), + WslVersionVerdict::Ok("2.10.0".into()) + ); + assert_eq!( + judge_wsl_version(Some("2.9.2")), + WslVersionVerdict::TooOld { + found: "2.9.2".into() + } + ); + + let english = "\ +WSL version: 2.4.13.0 +Kernel version: 5.15.167.4-1 +WSLg version: 1.0.65 +"; + assert_eq!( + judge_wsl_version(Some(english)), + WslVersionVerdict::TooOld { + found: "2.4.13".into() + } + ); + + let chinese = "WSL 版本: 2.9.3.0\n内核版本: 5.15.167.4-1\n"; + assert_eq!( + judge_wsl_version(Some(chinese)), + WslVersionVerdict::Ok("2.9.3".into()) + ); + + let unlabeled = "Kernel version: 5.15.167.4-1\nWSLg version: 1.0.65\n"; + assert_eq!( + judge_wsl_version(Some(unlabeled)), + WslVersionVerdict::Missing + ); + assert_eq!(format!("{}", MIN_WSL_VERSION), "2.9.3"); + } } diff --git a/src/wslc/setup_log.rs b/src/wslc/setup_log.rs new file mode 100644 index 0000000..2710d61 --- /dev/null +++ b/src/wslc/setup_log.rs @@ -0,0 +1,182 @@ +//! Append-only SDK setup log under the app data directory. +//! +//! File: `%LOCALAPPDATA%\WSLcDesktop\logs\sdk-setup.log` + +use std::fs::{self, OpenOptions}; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::{SystemTime, UNIX_EPOCH}; + +use super::paths::app_data_dir; + +const MAX_BYTES: u64 = 256 * 1024; +const KEEP_TAIL: usize = 128 * 1024; + +pub fn log_dir() -> PathBuf { + app_data_dir().join("logs") +} + +pub fn log_file_path() -> PathBuf { + log_dir().join("sdk-setup.log") +} + +/// Start a setup attempt. Trims the file when it grows past [`MAX_BYTES`]. +pub fn begin_session() -> Result<(), String> { + begin_session_at(&log_file_path()) +} + +pub fn begin_session_at(path: &Path) -> Result<(), String> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|e| format!("create log dir: {e}"))?; + } + trim_if_needed(path)?; + append_raw(path, &format!("\n===== sdk setup {} =====\n", utc_stamp())) +} + +pub fn append_line(line: &str) -> Result<(), String> { + append_line_at(&log_file_path(), line) +} + +pub fn append_line_at(path: &Path, line: &str) -> Result<(), String> { + if line.is_empty() { + return Ok(()); + } + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|e| format!("create log dir: {e}"))?; + } + let stamp = utc_stamp(); + let mut out = String::new(); + for part in line.split('\n') { + let part = part.trim_end_matches('\r'); + if part.is_empty() { + continue; + } + out.push_str(&stamp); + out.push(' '); + out.push_str(part); + out.push('\n'); + } + if out.is_empty() { + return Ok(()); + } + append_raw(path, &out) +} + +/// Open the log directory in the system file manager. +pub fn open_log_dir() -> Result<(), String> { + let dir = log_dir(); + fs::create_dir_all(&dir).map_err(|e| format!("create log dir: {e}"))?; + spawn_folder(&dir) +} + +fn spawn_folder(dir: &Path) -> Result<(), String> { + #[cfg(windows)] + { + Command::new("explorer") + .arg(dir) + .spawn() + .map(|_| ()) + .map_err(|e| format!("explorer: {e}")) + } + #[cfg(not(windows))] + { + Command::new("xdg-open") + .arg(dir) + .spawn() + .map(|_| ()) + .map_err(|e| format!("xdg-open: {e}")) + } +} + +fn trim_if_needed(path: &Path) -> Result<(), String> { + let Ok(meta) = fs::metadata(path) else { + return Ok(()); + }; + if meta.len() <= MAX_BYTES { + return Ok(()); + } + let bytes = fs::read(path).map_err(|e| format!("read log: {e}"))?; + let start = bytes.len().saturating_sub(KEEP_TAIL); + let start = bytes[start..] + .iter() + .position(|b| *b == b'\n') + .map(|i| start + i + 1) + .unwrap_or(start); + fs::write(path, &bytes[start..]).map_err(|e| format!("trim log: {e}")) +} + +fn append_raw(path: &Path, text: &str) -> Result<(), String> { + let mut file = OpenOptions::new() + .create(true) + .append(true) + .open(path) + .map_err(|e| format!("open log: {e}"))?; + file.write_all(text.as_bytes()) + .map_err(|e| format!("write log: {e}"))?; + Ok(()) +} + +fn utc_stamp() -> String { + let secs = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let days = (secs / 86_400) as i64; + let tod = secs % 86_400; + let (y, m, d) = civil_from_days(days); + let hh = tod / 3600; + let mm = (tod % 3600) / 60; + let ss = tod % 60; + format!("{y:04}-{m:02}-{d:02}T{hh:02}:{mm:02}:{ss:02}Z") +} + +/// Days since Unix epoch → civil (year, month, day). Howard Hinnant's algorithm. +fn civil_from_days(z: i64) -> (i32, u32, u32) { + let z = z + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = (z - era * 146_097) as u64; + let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365; + let y = yoe as i64 + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = if m <= 2 { y + 1 } else { y }; + (y as i32, m as u32, d as u32) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn civil_known_days() { + assert_eq!(civil_from_days(0), (1970, 1, 1)); + assert_eq!(civil_from_days(19_723), (2024, 1, 1)); + } + + #[test] + fn append_and_rotate() { + let dir = std::env::temp_dir().join(format!("wslc-setup-log-{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + let path = dir.join("sdk-setup.log"); + + begin_session_at(&path).unwrap(); + append_line_at(&path, "hello").unwrap(); + append_line_at(&path, "line-a\nline-b").unwrap(); + let text = fs::read_to_string(&path).unwrap(); + assert!(text.contains("===== sdk setup ")); + assert!(text.contains("hello")); + assert!(text.contains("line-a")); + assert!(text.contains("line-b")); + + let big = vec![b'x'; (MAX_BYTES as usize) + 1024]; + fs::write(&path, &big).unwrap(); + begin_session_at(&path).unwrap(); + let trimmed = fs::metadata(&path).unwrap().len(); + assert!(trimmed < MAX_BYTES, "trimmed len {trimmed}"); + let _ = fs::remove_dir_all(&dir); + } +} diff --git a/src/wslc/templates.rs b/src/wslc/templates.rs new file mode 100644 index 0000000..5c5bcdd --- /dev/null +++ b/src/wslc/templates.rs @@ -0,0 +1,212 @@ +//! Saved container-create forms. +//! +//! Path: `%LOCALAPPDATA%\WSLcDesktop\templates.json` (next to prefs and catalog). + +use std::fs; +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +use super::paths::{app_data_dir, write_atomic}; + +pub const TEMPLATE_VERSION: u32 = 1; +pub const MAX_TEMPLATE_NAME_CHARS: usize = 64; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UpsertKind { + Created, + Updated, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TemplateEditError { + EmptyName, + NameTooLong, + NameTaken, + Missing, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ContainerTemplate { + pub name: String, + #[serde(default)] + pub image: String, + /// Optional container name field from the create form (not the template name). + #[serde(default)] + pub container_name: String, + #[serde(default)] + pub ports: String, + #[serde(default)] + pub bind_mounts: String, + #[serde(default)] + pub named_volumes: String, + #[serde(default)] + pub auto_remove: bool, + #[serde(default)] + pub command: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct TemplateStore { + #[serde(default = "default_version")] + pub version: u32, + #[serde(default)] + pub templates: Vec, +} + +fn default_version() -> u32 { + TEMPLATE_VERSION +} + +impl Default for TemplateStore { + fn default() -> Self { + Self { + version: TEMPLATE_VERSION, + templates: Vec::new(), + } + } +} + +impl TemplateStore { + pub fn path() -> PathBuf { + app_data_dir().join("templates.json") + } + + pub fn load() -> Self { + Self::load_from(&Self::path()) + } + + pub fn load_from(path: &std::path::Path) -> Self { + let Ok(bytes) = fs::read(path) else { + return Self::default(); + }; + match serde_json::from_slice::(&bytes) { + Ok(mut store) => { + store.version = TEMPLATE_VERSION; + store.templates.retain(|t| !t.name.trim().is_empty()); + store + } + Err(_) => Self::default(), + } + } + + pub fn save(&self) -> Result<(), String> { + self.save_to(&Self::path()) + } + + pub fn save_to(&self, path: &std::path::Path) -> Result<(), String> { + let json = + serde_json::to_vec_pretty(self).map_err(|e| format!("serialize templates: {e}"))?; + write_atomic(path, &json) + } + + pub fn upsert( + &mut self, + mut template: ContainerTemplate, + ) -> Result<(UpsertKind, String), TemplateEditError> { + template.name = sanitize_name(&template.name); + validate_name(&template.name)?; + let name = template.name.clone(); + if let Some(existing) = self.templates.iter_mut().find(|t| t.name == template.name) { + *existing = template; + Ok((UpsertKind::Updated, name)) + } else { + self.templates.insert(0, template); + Ok((UpsertKind::Created, name)) + } + } + + pub fn rename(&mut self, from: &str, to: &str) -> Result { + let to = sanitize_name(to); + validate_name(&to)?; + if to != from && self.templates.iter().any(|t| t.name == to) { + return Err(TemplateEditError::NameTaken); + } + let Some(item) = self.templates.iter_mut().find(|t| t.name == from) else { + return Err(TemplateEditError::Missing); + }; + item.name = to.clone(); + Ok(to) + } + + pub fn remove(&mut self, name: &str) -> bool { + let before = self.templates.len(); + self.templates.retain(|t| t.name != name); + self.templates.len() != before + } +} + +fn sanitize_name(name: &str) -> String { + name.chars() + .filter(|c| !c.is_control()) + .collect::() + .trim() + .to_string() +} + +fn validate_name(name: &str) -> Result<(), TemplateEditError> { + if name.is_empty() { + return Err(TemplateEditError::EmptyName); + } + if name.chars().count() > MAX_TEMPLATE_NAME_CHARS { + return Err(TemplateEditError::NameTooLong); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample(name: &str) -> ContainerTemplate { + ContainerTemplate { + name: name.into(), + image: "nginx:alpine".into(), + container_name: "web".into(), + ports: "8080:80".into(), + bind_mounts: r"C:\data:/data".into(), + named_volumes: "data:/data".into(), + auto_remove: true, + command: "nginx -g 'daemon off;'".into(), + } + } + + #[test] + fn roundtrip_and_edits() { + let dir = std::env::temp_dir().join(format!("wslc-templates-{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + let path = dir.join("templates.json"); + + let mut store = TemplateStore::default(); + assert_eq!( + store.upsert(sample("nginx")).unwrap().0, + UpsertKind::Created + ); + assert_eq!( + store.upsert(sample("nginx")).unwrap().0, + UpsertKind::Updated + ); + assert_eq!(store.templates.len(), 1); + assert!(store.templates[0].auto_remove); + store.upsert(sample("redis")).unwrap(); + assert_eq!(store.templates[0].name, "redis"); + store.rename("redis", "cache").unwrap(); + assert!(store.rename("cache", "nginx").is_err()); + assert!(store.remove("cache")); + assert!(!store.remove("cache")); + assert_eq!( + store.upsert(sample(" ")).unwrap_err(), + TemplateEditError::EmptyName + ); + + store.save_to(&path).unwrap(); + let loaded = TemplateStore::load_from(&path); + assert_eq!(loaded.templates.len(), 1); + assert_eq!(loaded.templates[0].image, "nginx:alpine"); + assert_eq!(loaded.templates[0].ports, "8080:80"); + assert_eq!(loaded.templates[0].bind_mounts, r"C:\data:/data"); + assert_eq!(loaded.templates[0].named_volumes, "data:/data"); + let _ = fs::remove_dir_all(&dir); + } +} diff --git a/src/wslc/types.rs b/src/wslc/types.rs index c843bc6..961ed5c 100644 --- a/src/wslc/types.rs +++ b/src/wslc/types.rs @@ -116,11 +116,51 @@ pub enum PullEvent { Finished(Result<(), String>), } +/// First-launch steps shown in the setup dialog. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SetupStepId { + WslVersion, + SdkInstall, +} + +/// Why a setup step failed. The UI localizes the known variants. +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(not(windows), allow(dead_code))] +pub enum SetupFault { + /// `wsl --version` missing, failed, or unreadable. + WslMissing, + /// Parsed version is below 2.9.3. + WslTooOld { found: String }, + /// This build is not running on Windows. + NotWindows, + /// Free-form failure (SDK download, extract, or session boot). + Message(String), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(not(windows), allow(dead_code))] +pub enum SetupStepState { + Pending, + Running, + Passed { detail: String }, + Failed(SetupFault), +} + +impl SetupStepState { + pub fn is_failed(&self) -> bool { + matches!(self, Self::Failed(_)) + } +} + /// Progress / log stream while installing or connecting the SDK. pub enum SdkSetupEvent { Status(String), Progress(f32), Log(String), + Step { + id: SetupStepId, + state: SetupStepState, + }, Finished(Result), }