**Motivations:** - Keep the Lapce fork changes replayable via patch files in the monorepo. - Expose a minimal Smart IDE cockpit UX (agents, runs, services, connection) inside Lapce. **Root causes:** - N/A **Correctifs:** - ia-dev-gateway: parse YAML frontmatter (name/description) from agent markdown files. **Evolutions:** - Export new Lapce patch files for the Smart IDE panel + connection helpers. - Add documentation for the Smart IDE panel. **Pages affectées:** - N/A
2279 lines
85 KiB
Diff
2279 lines
85 KiB
Diff
From 43ee9102b485ed6cdc9d67131376d5738446c788 Mon Sep 17 00:00:00 2001
|
|
From: Nicolas Cantu <nicolas.cantu@pm.me>
|
|
Date: Mon, 6 Apr 2026 13:00:09 +0200
|
|
Subject: [PATCH 2/3] feat: add Smart IDE cockpit panel
|
|
|
|
- Add Smart IDE panel with tabs (Agents, Runs, Services, Connection)
|
|
- Add pinned actions bar and command palette entries for pinned actions
|
|
- Execute ia-dev-gateway runs and stream SSE logs in the Runs tab
|
|
- Add connection helpers (health checks and SSH tunnel plan + copy)
|
|
|
|
diff --git a/lapce-app/src/command.rs b/lapce-app/src/command.rs
|
|
index 25f9027..f03d93c 100644
|
|
--- a/lapce-app/src/command.rs
|
|
+++ b/lapce-app/src/command.rs
|
|
@@ -422,6 +422,46 @@ pub enum LapceWorkbenchCommand {
|
|
#[strum(serialize = "smart_ide.agents.list")]
|
|
SmartIdeListAgents,
|
|
|
|
+ #[strum(message = "Smart IDE: Open Panel")]
|
|
+ #[strum(serialize = "smart_ide.open")]
|
|
+ SmartIdeOpenPanel,
|
|
+
|
|
+ #[strum(message = "Smart IDE: Run Pinned Action 1")]
|
|
+ #[strum(serialize = "smart_ide.pinned_1")]
|
|
+ SmartIdePinnedAction1,
|
|
+
|
|
+ #[strum(message = "Smart IDE: Run Pinned Action 2")]
|
|
+ #[strum(serialize = "smart_ide.pinned_2")]
|
|
+ SmartIdePinnedAction2,
|
|
+
|
|
+ #[strum(message = "Smart IDE: Run Pinned Action 3")]
|
|
+ #[strum(serialize = "smart_ide.pinned_3")]
|
|
+ SmartIdePinnedAction3,
|
|
+
|
|
+ #[strum(message = "Smart IDE: Run Pinned Action 4")]
|
|
+ #[strum(serialize = "smart_ide.pinned_4")]
|
|
+ SmartIdePinnedAction4,
|
|
+
|
|
+ #[strum(message = "Smart IDE: Run Pinned Action 5")]
|
|
+ #[strum(serialize = "smart_ide.pinned_5")]
|
|
+ SmartIdePinnedAction5,
|
|
+
|
|
+ #[strum(message = "Smart IDE: Run Pinned Action 6")]
|
|
+ #[strum(serialize = "smart_ide.pinned_6")]
|
|
+ SmartIdePinnedAction6,
|
|
+
|
|
+ #[strum(message = "Smart IDE: Run Pinned Action 7")]
|
|
+ #[strum(serialize = "smart_ide.pinned_7")]
|
|
+ SmartIdePinnedAction7,
|
|
+
|
|
+ #[strum(message = "Smart IDE: Run Pinned Action 8")]
|
|
+ #[strum(serialize = "smart_ide.pinned_8")]
|
|
+ SmartIdePinnedAction8,
|
|
+
|
|
+ #[strum(message = "Smart IDE: Run Pinned Action 9")]
|
|
+ #[strum(serialize = "smart_ide.pinned_9")]
|
|
+ SmartIdePinnedAction9,
|
|
+
|
|
#[strum(serialize = "source_control.checkout_reference")]
|
|
CheckoutReference,
|
|
|
|
diff --git a/lapce-app/src/lib.rs b/lapce-app/src/lib.rs
|
|
index ea3c659..fad1792 100644
|
|
--- a/lapce-app/src/lib.rs
|
|
+++ b/lapce-app/src/lib.rs
|
|
@@ -34,6 +34,7 @@
|
|
pub mod snippet;
|
|
pub mod source_control;
|
|
pub mod smart_ide;
|
|
+pub mod smart_ide_ui;
|
|
pub mod status;
|
|
pub mod terminal;
|
|
pub mod text_area;
|
|
diff --git a/lapce-app/src/panel/data.rs b/lapce-app/src/panel/data.rs
|
|
index b18f06d..ea72f96 100644
|
|
--- a/lapce-app/src/panel/data.rs
|
|
+++ b/lapce-app/src/panel/data.rs
|
|
@@ -26,6 +26,7 @@ pub fn default_panel_order() -> PanelOrder {
|
|
PanelPosition::LeftTop,
|
|
im::vector![
|
|
PanelKind::FileExplorer,
|
|
+ PanelKind::SmartIde,
|
|
PanelKind::Plugin,
|
|
PanelKind::SourceControl,
|
|
PanelKind::Debug,
|
|
diff --git a/lapce-app/src/panel/kind.rs b/lapce-app/src/panel/kind.rs
|
|
index 695990e..e2477d4 100644
|
|
--- a/lapce-app/src/panel/kind.rs
|
|
+++ b/lapce-app/src/panel/kind.rs
|
|
@@ -10,6 +10,7 @@
|
|
pub enum PanelKind {
|
|
Terminal,
|
|
FileExplorer,
|
|
+ SmartIde,
|
|
SourceControl,
|
|
Plugin,
|
|
Search,
|
|
@@ -26,6 +27,7 @@ pub fn svg_name(&self) -> &'static str {
|
|
match &self {
|
|
PanelKind::Terminal => LapceIcons::TERMINAL,
|
|
PanelKind::FileExplorer => LapceIcons::FILE_EXPLORER,
|
|
+ PanelKind::SmartIde => LapceIcons::LIGHTBULB,
|
|
PanelKind::SourceControl => LapceIcons::SCM,
|
|
PanelKind::Plugin => LapceIcons::EXTENSIONS,
|
|
PanelKind::Search => LapceIcons::SEARCH,
|
|
@@ -52,6 +54,7 @@ pub fn default_position(&self) -> PanelPosition {
|
|
match self {
|
|
PanelKind::Terminal => PanelPosition::BottomLeft,
|
|
PanelKind::FileExplorer => PanelPosition::LeftTop,
|
|
+ PanelKind::SmartIde => PanelPosition::LeftTop,
|
|
PanelKind::SourceControl => PanelPosition::LeftTop,
|
|
PanelKind::Plugin => PanelPosition::LeftTop,
|
|
PanelKind::Search => PanelPosition::BottomLeft,
|
|
diff --git a/lapce-app/src/panel/mod.rs b/lapce-app/src/panel/mod.rs
|
|
index 016192e..6324e60 100644
|
|
--- a/lapce-app/src/panel/mod.rs
|
|
+++ b/lapce-app/src/panel/mod.rs
|
|
@@ -10,6 +10,7 @@
|
|
pub mod problem_view;
|
|
pub mod references_view;
|
|
pub mod source_control_view;
|
|
+pub mod smart_ide_view;
|
|
pub mod style;
|
|
pub mod terminal_view;
|
|
pub mod view;
|
|
diff --git a/lapce-app/src/panel/smart_ide_view.rs b/lapce-app/src/panel/smart_ide_view.rs
|
|
new file mode 100644
|
|
index 0000000..65371aa
|
|
--- /dev/null
|
|
+++ b/lapce-app/src/panel/smart_ide_view.rs
|
|
@@ -0,0 +1,1025 @@
|
|
+use std::{rc::Rc, sync::Arc};
|
|
+
|
|
+use floem::{
|
|
+ IntoView,
|
|
+ View,
|
|
+ event::EventListener,
|
|
+ reactive::{
|
|
+ ReadSignal, Scope, SignalGet, SignalUpdate, SignalWith,
|
|
+ create_memo, create_rw_signal,
|
|
+ },
|
|
+ style::CursorStyle,
|
|
+ views::{
|
|
+ Decorators, container, dyn_stack, h_stack, label, scroll, stack,
|
|
+ stack_from_iter, text, virtual_stack,
|
|
+ },
|
|
+};
|
|
+use floem::views::editor::text::SystemClipboard;
|
|
+use im::Vector;
|
|
+use lapce_core::register::Clipboard;
|
|
+
|
|
+use super::{kind::PanelKind, position::PanelPosition};
|
|
+use crate::{
|
|
+ command::InternalCommand,
|
|
+ config::{LapceConfig, color::LapceColor},
|
|
+ smart_ide_ui::{SmartIdeRunLogKind, SmartIdeTab},
|
|
+ text_input::TextInputBuilder,
|
|
+ window_tab::{Focus, WindowTabData},
|
|
+};
|
|
+
|
|
+#[derive(Clone, PartialEq, Eq)]
|
|
+struct SmartIdeAgent {
|
|
+ id: String,
|
|
+ name: String,
|
|
+ summary: String,
|
|
+ category: String,
|
|
+ runnable: bool,
|
|
+}
|
|
+
|
|
+#[derive(Clone, PartialEq, Eq)]
|
|
+enum HealthStatus {
|
|
+ Unknown,
|
|
+ Ok,
|
|
+ Error(String),
|
|
+}
|
|
+
|
|
+const RUNNABLE_AGENT_IDS: [&str; 5] = [
|
|
+ "change-to-all-branches",
|
|
+ "branch-align-by-script-from-test",
|
|
+ "deploy-by-script",
|
|
+ "push-by-script",
|
|
+ "site-generate",
|
|
+];
|
|
+
|
|
+fn category_for_agent_id(id: &str) -> &'static str {
|
|
+ match id {
|
|
+ "site-generate" => "Web generation",
|
|
+ "deploy-by-script" | "deploy-pprod-or-prod" | "push-by-script"
|
|
+ | "branch-align-by-script-from-test" | "change-to-all-branches"
|
|
+ | "setup-host" => "DevOps",
|
|
+ "fix" | "evol" | "code" | "fix-search" => "Code",
|
|
+ "docupdate" | "fix-lint" | "closure-point-7-justification" => "Docs / Quality",
|
|
+ "git-issues-process" | "agent-loop" | "notary-ai-process"
|
|
+ | "notary-ai-loop" => "Ticketing / Notary",
|
|
+ _ => "Other",
|
|
+ }
|
|
+}
|
|
+
|
|
+fn label_for_action(action: &str) -> String {
|
|
+ match action {
|
|
+ "site-generate" => "Generate site".to_string(),
|
|
+ "push-by-script" => "Push".to_string(),
|
|
+ "deploy-by-script:test" => "Deploy test".to_string(),
|
|
+ "deploy-by-script:pprod" => "Deploy pprod".to_string(),
|
|
+ "deploy-by-script:prod" => "Deploy prod".to_string(),
|
|
+ "branch-align-by-script-from-test:test" => "Align from test".to_string(),
|
|
+ "smart-ide:tunnel" => "SSH tunnel".to_string(),
|
|
+ _ => action.to_string(),
|
|
+ }
|
|
+}
|
|
+
|
|
+fn action_is_runnable(action: &str) -> bool {
|
|
+ if action.starts_with("smart-ide:") {
|
|
+ return true;
|
|
+ }
|
|
+ let agent_id = action
|
|
+ .split_once(':')
|
|
+ .map(|(a, _)| a)
|
|
+ .unwrap_or(action)
|
|
+ .trim();
|
|
+ if agent_id.is_empty() {
|
|
+ return false;
|
|
+ }
|
|
+ RUNNABLE_AGENT_IDS.contains(&agent_id)
|
|
+}
|
|
+
|
|
+fn pill_button(
|
|
+ label_text: impl Fn() -> String + 'static,
|
|
+ on_click: impl Fn() + 'static,
|
|
+ active: impl Fn() -> bool + 'static,
|
|
+ config: ReadSignal<Arc<LapceConfig>>,
|
|
+) -> impl View {
|
|
+ label(label_text)
|
|
+ .on_click_stop(move |_| on_click())
|
|
+ .style(move |s| {
|
|
+ let config = config.get();
|
|
+ s.padding_horiz(10.0)
|
|
+ .padding_vert(6.0)
|
|
+ .border(1.0)
|
|
+ .border_radius(999.0)
|
|
+ .border_color(config.color(LapceColor::LAPCE_BORDER))
|
|
+ .cursor(CursorStyle::Pointer)
|
|
+ .apply_if(active(), |s| {
|
|
+ s.background(config.color(LapceColor::PANEL_HOVERED_ACTIVE_BACKGROUND))
|
|
+ })
|
|
+ .hover(|s| s.background(config.color(LapceColor::PANEL_HOVERED_BACKGROUND)))
|
|
+ })
|
|
+}
|
|
+
|
|
+fn pill_action_button(
|
|
+ label_text: impl Fn() -> String + 'static,
|
|
+ on_click: impl Fn() + 'static,
|
|
+ disabled: impl Fn() -> bool + 'static + Copy,
|
|
+ config: ReadSignal<Arc<LapceConfig>>,
|
|
+) -> impl View {
|
|
+ label(label_text)
|
|
+ .on_click_stop(move |_| on_click())
|
|
+ .disabled(disabled)
|
|
+ .style(move |s| {
|
|
+ let config = config.get();
|
|
+ s.padding_horiz(10.0)
|
|
+ .padding_vert(6.0)
|
|
+ .border(1.0)
|
|
+ .border_radius(999.0)
|
|
+ .border_color(config.color(LapceColor::LAPCE_BORDER))
|
|
+ .cursor(CursorStyle::Pointer)
|
|
+ .hover(|s| s.background(config.color(LapceColor::PANEL_HOVERED_BACKGROUND)))
|
|
+ .active(|s| {
|
|
+ s.background(config.color(LapceColor::PANEL_HOVERED_ACTIVE_BACKGROUND))
|
|
+ })
|
|
+ .disabled(|s| {
|
|
+ s.cursor(CursorStyle::Default)
|
|
+ .color(config.color(LapceColor::EDITOR_DIM))
|
|
+ })
|
|
+ })
|
|
+}
|
|
+
|
|
+pub fn smart_ide_panel(
|
|
+ window_tab_data: Rc<WindowTabData>,
|
|
+ _position: PanelPosition,
|
|
+) -> impl View {
|
|
+ let config = window_tab_data.common.config;
|
|
+ let focus = window_tab_data.common.focus;
|
|
+ let internal_command = window_tab_data.common.internal_command;
|
|
+
|
|
+ let active_tab = window_tab_data.smart_ide.active_tab;
|
|
+ let agents = create_rw_signal(Vector::<SmartIdeAgent>::new());
|
|
+ let agents_loading = create_rw_signal(false);
|
|
+ let agents_error = create_rw_signal(None::<String>);
|
|
+
|
|
+ let health_orch = create_rw_signal(HealthStatus::Unknown);
|
|
+ let health_ia = create_rw_signal(HealthStatus::Unknown);
|
|
+ let health_tools = create_rw_signal(HealthStatus::Unknown);
|
|
+ let health_loading = create_rw_signal(false);
|
|
+
|
|
+ let tunnel_cmd = create_rw_signal(String::new());
|
|
+ let tunnel_hint = create_rw_signal(String::new());
|
|
+ let tunnel_error = create_rw_signal(None::<String>);
|
|
+ let tunnel_loading = create_rw_signal(false);
|
|
+
|
|
+ let cx = Scope::current();
|
|
+ let text_input_view = TextInputBuilder::new().build(
|
|
+ cx,
|
|
+ window_tab_data.main_split.editors,
|
|
+ window_tab_data.common.clone(),
|
|
+ );
|
|
+ let search_doc = text_input_view.doc_signal();
|
|
+
|
|
+ let filtered_agents = create_memo(move |_| {
|
|
+ let pattern = search_doc
|
|
+ .get()
|
|
+ .buffer
|
|
+ .with(|b| b.to_string().trim().to_lowercase());
|
|
+ if pattern.is_empty() {
|
|
+ return agents.get();
|
|
+ }
|
|
+ agents
|
|
+ .get()
|
|
+ .into_iter()
|
|
+ .filter(|a| {
|
|
+ a.id.to_lowercase().contains(&pattern)
|
|
+ || a.name.to_lowercase().contains(&pattern)
|
|
+ || a.summary.to_lowercase().contains(&pattern)
|
|
+ || a.category.to_lowercase().contains(&pattern)
|
|
+ })
|
|
+ .collect()
|
|
+ });
|
|
+
|
|
+ let refresh_agents: Rc<dyn Fn()> = {
|
|
+ let config = config;
|
|
+ let agents = agents.write_only();
|
|
+ let agents_loading = agents_loading;
|
|
+ let agents_error = agents_error;
|
|
+ let scope = window_tab_data.common.scope;
|
|
+ Rc::new(move || {
|
|
+ agents_loading.set(true);
|
|
+ agents_error.set(None);
|
|
+ let cfg = config.get_untracked();
|
|
+ let send = floem::ext_event::create_ext_action(
|
|
+ scope,
|
|
+ move |result: std::result::Result<Vector<SmartIdeAgent>, String>| {
|
|
+ agents_loading.set(false);
|
|
+ match result {
|
|
+ Ok(list) => {
|
|
+ agents.set(list);
|
|
+ agents_error.set(None);
|
|
+ }
|
|
+ Err(err) => {
|
|
+ agents_error.set(Some(err));
|
|
+ }
|
|
+ }
|
|
+ },
|
|
+ );
|
|
+ std::thread::Builder::new()
|
|
+ .name("SmartIdeAgentsRefresh".to_owned())
|
|
+ .spawn(move || {
|
|
+ let parsed = (|| {
|
|
+ let v = crate::smart_ide::ia_dev_list_agents(&cfg)?;
|
|
+ let arr = v
|
|
+ .get("agents")
|
|
+ .and_then(|v| v.as_array())
|
|
+ .ok_or_else(|| {
|
|
+ anyhow::anyhow!(
|
|
+ "Invalid ia-dev-gateway response: missing 'agents' array"
|
|
+ )
|
|
+ })?;
|
|
+ let mut out = Vector::new();
|
|
+ for a in arr {
|
|
+ let Some(id) = a.get("id").and_then(|v| v.as_str()) else {
|
|
+ continue;
|
|
+ };
|
|
+ let name = a
|
|
+ .get("name")
|
|
+ .and_then(|v| v.as_str())
|
|
+ .unwrap_or(id)
|
|
+ .to_string();
|
|
+ let summary = a
|
|
+ .get("description")
|
|
+ .and_then(|v| v.as_str())
|
|
+ .or_else(|| a.get("summary").and_then(|v| v.as_str()))
|
|
+ .unwrap_or("")
|
|
+ .to_string();
|
|
+ let category = category_for_agent_id(id).to_string();
|
|
+ let runnable = RUNNABLE_AGENT_IDS.contains(&id);
|
|
+ out.push_back(SmartIdeAgent {
|
|
+ id: id.to_string(),
|
|
+ name,
|
|
+ summary,
|
|
+ category,
|
|
+ runnable,
|
|
+ });
|
|
+ }
|
|
+ Ok::<_, anyhow::Error>(out)
|
|
+ })()
|
|
+ .map_err(|e| e.to_string());
|
|
+ send(parsed);
|
|
+ })
|
|
+ .ok();
|
|
+ })
|
|
+ };
|
|
+
|
|
+ // Reduce clicks: load the catalog on first open.
|
|
+ refresh_agents();
|
|
+
|
|
+ let refresh_health: Rc<dyn Fn()> = {
|
|
+ let scope = window_tab_data.common.scope;
|
|
+ let config = config;
|
|
+ let health_orch = health_orch;
|
|
+ let health_ia = health_ia;
|
|
+ let health_tools = health_tools;
|
|
+ let health_loading = health_loading;
|
|
+ Rc::new(move || {
|
|
+ health_loading.set(true);
|
|
+ let cfg = config.get_untracked();
|
|
+ let send = floem::ext_event::create_ext_action(
|
|
+ scope,
|
|
+ move |result: (HealthStatus, HealthStatus, HealthStatus)| {
|
|
+ health_orch.set(result.0);
|
|
+ health_ia.set(result.1);
|
|
+ health_tools.set(result.2);
|
|
+ health_loading.set(false);
|
|
+ },
|
|
+ );
|
|
+ std::thread::Builder::new()
|
|
+ .name("SmartIdeHealth".to_owned())
|
|
+ .spawn(move || {
|
|
+ let orch = crate::smart_ide::orchestrator_health(&cfg)
|
|
+ .map(|_| HealthStatus::Ok)
|
|
+ .unwrap_or_else(|e| HealthStatus::Error(e.to_string()));
|
|
+ let ia = crate::smart_ide::ia_dev_gateway_health(&cfg)
|
|
+ .map(|_| HealthStatus::Ok)
|
|
+ .unwrap_or_else(|e| HealthStatus::Error(e.to_string()));
|
|
+ let tools = crate::smart_ide::tools_bridge_health(&cfg)
|
|
+ .map(|_| HealthStatus::Ok)
|
|
+ .unwrap_or_else(|e| HealthStatus::Error(e.to_string()));
|
|
+ send((orch, ia, tools));
|
|
+ })
|
|
+ .ok();
|
|
+ })
|
|
+ };
|
|
+
|
|
+ let refresh_tunnel_plan: Rc<dyn Fn()> = {
|
|
+ let scope = window_tab_data.common.scope;
|
|
+ let config = config;
|
|
+ let workspace_root = window_tab_data.workspace.path.clone();
|
|
+ let tunnel_cmd = tunnel_cmd;
|
|
+ let tunnel_hint = tunnel_hint;
|
|
+ let tunnel_error = tunnel_error;
|
|
+ let tunnel_loading = tunnel_loading;
|
|
+ Rc::new(move || {
|
|
+ tunnel_loading.set(true);
|
|
+ tunnel_error.set(None);
|
|
+ let cfg = config.get_untracked();
|
|
+ let ws = workspace_root.clone();
|
|
+ let send = floem::ext_event::create_ext_action(
|
|
+ scope,
|
|
+ move |result: std::result::Result<(String, String), String>| {
|
|
+ tunnel_loading.set(false);
|
|
+ match result {
|
|
+ Ok((cmd, hint)) => {
|
|
+ tunnel_cmd.set(cmd);
|
|
+ tunnel_hint.set(hint);
|
|
+ tunnel_error.set(None);
|
|
+ }
|
|
+ Err(err) => {
|
|
+ tunnel_error.set(Some(err));
|
|
+ }
|
|
+ }
|
|
+ },
|
|
+ );
|
|
+ std::thread::Builder::new()
|
|
+ .name("SmartIdeTunnelPlan".to_owned())
|
|
+ .spawn(move || {
|
|
+ let res = (|| {
|
|
+ let plan = crate::smart_ide::ssh_tunnel_plan(
|
|
+ &cfg,
|
|
+ ws.as_deref(),
|
|
+ "minimal",
|
|
+ )?;
|
|
+ let cmd = crate::smart_ide::shell_command_from_argv(&plan.argv);
|
|
+ Ok::<_, anyhow::Error>((cmd, plan.hint))
|
|
+ })()
|
|
+ .map_err(|e| e.to_string());
|
|
+ send(res);
|
|
+ })
|
|
+ .ok();
|
|
+ })
|
|
+ };
|
|
+
|
|
+ {
|
|
+ let active_tab = active_tab.read_only();
|
|
+ let refresh_health = refresh_health.clone();
|
|
+ let refresh_tunnel_plan = refresh_tunnel_plan.clone();
|
|
+ let did_auto = create_rw_signal(false);
|
|
+ cx.create_effect(move |_| {
|
|
+ if active_tab.get() == SmartIdeTab::Connection {
|
|
+ if !did_auto.get_untracked() {
|
|
+ refresh_health();
|
|
+ refresh_tunnel_plan();
|
|
+ did_auto.set(true);
|
|
+ }
|
|
+ }
|
|
+ });
|
|
+ }
|
|
+
|
|
+ let open_agent_definition: Rc<dyn Fn(String)> = {
|
|
+ let internal_command = internal_command;
|
|
+ let workspace = window_tab_data.workspace.clone();
|
|
+ Rc::new(move |agent_id: String| {
|
|
+ let root = workspace.path.clone();
|
|
+ let Some(root) = root else {
|
|
+ internal_command.send(InternalCommand::ShowAlert {
|
|
+ title: "Smart IDE".to_string(),
|
|
+ msg: "Workspace folder is required to open agent definitions.".to_string(),
|
|
+ buttons: Vec::new(),
|
|
+ });
|
|
+ return;
|
|
+ };
|
|
+ let path = root
|
|
+ .join("services")
|
|
+ .join("ia_dev")
|
|
+ .join(".smartIde")
|
|
+ .join("agents")
|
|
+ .join(format!("{agent_id}.md"));
|
|
+ internal_command.send(InternalCommand::OpenFile { path });
|
|
+ })
|
|
+ };
|
|
+
|
|
+ let header = h_stack((
|
|
+ text("Smart IDE").style(|s| s.font_bold()),
|
|
+ label(move || {
|
|
+ if agents_loading.get() {
|
|
+ "Loading agents…".to_string()
|
|
+ } else {
|
|
+ "".to_string()
|
|
+ }
|
|
+ })
|
|
+ .style(move |s| {
|
|
+ s.margin_left(10.0)
|
|
+ .color(config.get().color(LapceColor::EDITOR_DIM))
|
|
+ }),
|
|
+ label(move || {
|
|
+ agents_error
|
|
+ .get()
|
|
+ .unwrap_or_default()
|
|
+ .lines()
|
|
+ .next()
|
|
+ .unwrap_or("")
|
|
+ .to_string()
|
|
+ })
|
|
+ .style(move |s| {
|
|
+ s.margin_left(10.0)
|
|
+ .color(config.get().color(LapceColor::LAPCE_ERROR))
|
|
+ }),
|
|
+ ))
|
|
+ .style(|s| s.items_center().width_pct(100.0).padding(10.0));
|
|
+
|
|
+ let pinned_actions = crate::smart_ide::pinned_actions(&config.get_untracked());
|
|
+ let pinned_bar = container(
|
|
+ stack_from_iter(pinned_actions.into_iter().map({
|
|
+ let window_tab_data = window_tab_data.clone();
|
|
+ move |action| {
|
|
+ let label = label_for_action(&action);
|
|
+ let runnable = action_is_runnable(&action);
|
|
+ let wtd = window_tab_data.clone();
|
|
+ pill_action_button(
|
|
+ move || label.clone(),
|
|
+ move || {
|
|
+ wtd.smart_ide_execute_action(action.clone());
|
|
+ },
|
|
+ move || !runnable,
|
|
+ config,
|
|
+ )
|
|
+ .style(|s| s.margin_right(6.0))
|
|
+ .into_any()
|
|
+ }
|
|
+ })),
|
|
+ )
|
|
+ .style(|s| s.width_pct(100.0).padding_horiz(10.0).padding_bottom(6.0))
|
|
+ .debug_name("Smart IDE Pinned Actions");
|
|
+
|
|
+ let default_env =
|
|
+ crate::smart_ide::resolve_env(&config.get_untracked(), window_tab_data.workspace.path.as_deref());
|
|
+
|
|
+ let tabs = h_stack((
|
|
+ pill_button(
|
|
+ || "Agents".to_string(),
|
|
+ {
|
|
+ let active_tab = active_tab;
|
|
+ move || active_tab.set(SmartIdeTab::Agents)
|
|
+ },
|
|
+ {
|
|
+ let active_tab = active_tab.read_only();
|
|
+ move || active_tab.get() == SmartIdeTab::Agents
|
|
+ },
|
|
+ config,
|
|
+ ),
|
|
+ pill_button(
|
|
+ || "Runs".to_string(),
|
|
+ {
|
|
+ let active_tab = active_tab;
|
|
+ move || active_tab.set(SmartIdeTab::Runs)
|
|
+ },
|
|
+ {
|
|
+ let active_tab = active_tab.read_only();
|
|
+ move || active_tab.get() == SmartIdeTab::Runs
|
|
+ },
|
|
+ config,
|
|
+ )
|
|
+ .style(|s| s.margin_left(6.0)),
|
|
+ pill_button(
|
|
+ || "Services".to_string(),
|
|
+ {
|
|
+ let active_tab = active_tab;
|
|
+ move || active_tab.set(SmartIdeTab::Services)
|
|
+ },
|
|
+ {
|
|
+ let active_tab = active_tab.read_only();
|
|
+ move || active_tab.get() == SmartIdeTab::Services
|
|
+ },
|
|
+ config,
|
|
+ )
|
|
+ .style(|s| s.margin_left(6.0)),
|
|
+ pill_button(
|
|
+ || "Connection".to_string(),
|
|
+ {
|
|
+ let active_tab = active_tab;
|
|
+ move || active_tab.set(SmartIdeTab::Connection)
|
|
+ },
|
|
+ {
|
|
+ let active_tab = active_tab.read_only();
|
|
+ move || active_tab.get() == SmartIdeTab::Connection
|
|
+ },
|
|
+ config,
|
|
+ )
|
|
+ .style(|s| s.margin_left(6.0)),
|
|
+ ))
|
|
+ .style(|s| s.items_center().width_pct(100.0).padding_horiz(10.0));
|
|
+
|
|
+ let window_tab_data_for_agents = window_tab_data.clone();
|
|
+ let agents_tab = stack((
|
|
+ container(
|
|
+ h_stack((
|
|
+ text_input_view.style(|s| s.width_pct(100.0)),
|
|
+ pill_button(
|
|
+ || "Refresh".to_string(),
|
|
+ {
|
|
+ let refresh_agents = refresh_agents.clone();
|
|
+ move || refresh_agents()
|
|
+ },
|
|
+ || false,
|
|
+ config,
|
|
+ )
|
|
+ .style(|s| s.margin_left(6.0)),
|
|
+ ))
|
|
+ .style(move |s| {
|
|
+ s.width_pct(100.0)
|
|
+ .padding_right(6.0)
|
|
+ .items_center()
|
|
+ .border(1.0)
|
|
+ .border_radius(6.0)
|
|
+ .border_color(config.get().color(LapceColor::LAPCE_BORDER))
|
|
+ }),
|
|
+ )
|
|
+ .style(|s| s.width_pct(100.0).padding(10.0)),
|
|
+ container(scroll(virtual_stack(
|
|
+ move || filtered_agents.get(),
|
|
+ |a| a.id.clone(),
|
|
+ move |agent| {
|
|
+ let agent_id_label = agent.id.clone();
|
|
+ let agent_id_open = agent.id.clone();
|
|
+ let agent_id_run = agent.id.clone();
|
|
+ let desc = agent.summary.clone();
|
|
+ let category = agent.category.clone();
|
|
+ let runnable = agent.runnable;
|
|
+ stack((
|
|
+ h_stack((
|
|
+ stack((
|
|
+ label(move || agent_id_label.clone())
|
|
+ .style(|s| s.font_bold().margin_right(8.0)),
|
|
+ label(move || category.clone()).style(move |s| {
|
|
+ s.color(config.get().color(LapceColor::EDITOR_DIM))
|
|
+ }),
|
|
+ ))
|
|
+ .style(|s| s.flex_col().min_width(0.0).flex_grow(1.0)),
|
|
+ label(move || {
|
|
+ if runnable {
|
|
+ "runnable".to_string()
|
|
+ } else {
|
|
+ "guided".to_string()
|
|
+ }
|
|
+ })
|
|
+ .style(move |s| {
|
|
+ s.margin_left(8.0)
|
|
+ .padding_horiz(8.0)
|
|
+ .padding_vert(4.0)
|
|
+ .border(1.0)
|
|
+ .border_radius(999.0)
|
|
+ .border_color(
|
|
+ config.get().color(LapceColor::LAPCE_BORDER),
|
|
+ )
|
|
+ .color(config.get().color(LapceColor::EDITOR_DIM))
|
|
+ }),
|
|
+ pill_action_button(
|
|
+ || "Run".to_string(),
|
|
+ {
|
|
+ let wtd = window_tab_data_for_agents.clone();
|
|
+ let action = if agent_id_run == "deploy-by-script" {
|
|
+ format!("deploy-by-script:{default_env}")
|
|
+ } else if agent_id_run == "branch-align-by-script-from-test" {
|
|
+ format!("branch-align-by-script-from-test:{default_env}")
|
|
+ } else {
|
|
+ agent_id_run.clone()
|
|
+ };
|
|
+ move || {
|
|
+ wtd.smart_ide_execute_action(action.clone());
|
|
+ }
|
|
+ },
|
|
+ move || !runnable,
|
|
+ config,
|
|
+ )
|
|
+ .style(|s| s.margin_left(8.0)),
|
|
+ pill_button(
|
|
+ || "Open".to_string(),
|
|
+ {
|
|
+ let open_agent_definition = open_agent_definition.clone();
|
|
+ let agent_id_open = agent_id_open.clone();
|
|
+ move || open_agent_definition(agent_id_open.clone())
|
|
+ },
|
|
+ || false,
|
|
+ config,
|
|
+ )
|
|
+ .style(|s| s.margin_left(8.0)),
|
|
+ ))
|
|
+ .style(|s| s.items_center().width_pct(100.0)),
|
|
+ label(move || desc.clone()).style(move |s| {
|
|
+ s.margin_top(4.0)
|
|
+ .color(config.get().color(LapceColor::EDITOR_DIM))
|
|
+ .min_width(0.0)
|
|
+ .text_ellipsis()
|
|
+ }),
|
|
+ ))
|
|
+ .style(move |s| {
|
|
+ s.width_pct(100.0)
|
|
+ .padding(10.0)
|
|
+ .border_bottom(1.0)
|
|
+ .border_color(config.get().color(LapceColor::LAPCE_BORDER))
|
|
+ .hover(|s| {
|
|
+ s.cursor(CursorStyle::Pointer).background(
|
|
+ config.get().color(LapceColor::PANEL_HOVERED_BACKGROUND),
|
|
+ )
|
|
+ })
|
|
+ })
|
|
+ .on_event_cont(EventListener::PointerDown, {
|
|
+ let focus = focus;
|
|
+ move |_| {
|
|
+ focus.set(Focus::Panel(PanelKind::SmartIde));
|
|
+ }
|
|
+ })
|
|
+ },
|
|
+ )))
|
|
+ .style(|s| s.size_pct(100.0, 100.0)),
|
|
+ ))
|
|
+ .style(|s| s.size_pct(100.0, 100.0).flex_col())
|
|
+ .debug_name("Smart IDE Agents Tab");
|
|
+
|
|
+ let active_tab = active_tab.read_only();
|
|
+
|
|
+ let runs = window_tab_data.smart_ide.runs;
|
|
+ let selected_run_id = window_tab_data.smart_ide.selected_run_id;
|
|
+ let runs_tab = stack((
|
|
+ container(text("Runs").style(|s| s.font_bold()))
|
|
+ .style(|s| s.width_pct(100.0).padding(10.0)),
|
|
+ container(scroll(dyn_stack(
|
|
+ move || runs.get(),
|
|
+ |r| r.run_id.clone(),
|
|
+ move |r| {
|
|
+ let run_id_for_cmp = r.run_id.clone();
|
|
+ let run_id_label = r.run_id.clone();
|
|
+ let run_id_click = r.run_id.clone();
|
|
+ let agent_id = r.agent_id.clone();
|
|
+ let status = r.status.clone();
|
|
+ let is_selected = {
|
|
+ let selected_run_id = selected_run_id.read_only();
|
|
+ let run_id_for_cmp = run_id_for_cmp.clone();
|
|
+ move || {
|
|
+ selected_run_id.get().as_deref() == Some(run_id_for_cmp.as_str())
|
|
+ }
|
|
+ };
|
|
+ let selected_run_id = selected_run_id;
|
|
+ stack((
|
|
+ label(move || format!("{agent_id}")).style(|s| s.font_bold()),
|
|
+ label(move || format!("{status}")).style(move |s| {
|
|
+ s.margin_left(10.0)
|
|
+ .color(config.get().color(LapceColor::EDITOR_DIM))
|
|
+ }),
|
|
+ label(move || format!("{run_id_label}")).style(move |s| {
|
|
+ s.margin_left(10.0)
|
|
+ .color(config.get().color(LapceColor::EDITOR_DIM))
|
|
+ .min_width(0.0)
|
|
+ .text_ellipsis()
|
|
+ }),
|
|
+ ))
|
|
+ .on_click_stop(move |_| {
|
|
+ selected_run_id.set(Some(run_id_click.clone()));
|
|
+ })
|
|
+ .style(move |s| {
|
|
+ s.width_pct(100.0)
|
|
+ .padding(10.0)
|
|
+ .border_bottom(1.0)
|
|
+ .border_color(config.get().color(LapceColor::LAPCE_BORDER))
|
|
+ .apply_if(is_selected(), |s| {
|
|
+ s.background(config.get().color(
|
|
+ LapceColor::PANEL_HOVERED_ACTIVE_BACKGROUND,
|
|
+ ))
|
|
+ })
|
|
+ .hover(|s| {
|
|
+ s.cursor(CursorStyle::Pointer).background(
|
|
+ config.get().color(LapceColor::PANEL_HOVERED_BACKGROUND),
|
|
+ )
|
|
+ })
|
|
+ })
|
|
+ },
|
|
+ )))
|
|
+ .style(|s| s.size_pct(100.0, 40.0)),
|
|
+ container(
|
|
+ scroll(label(move || {
|
|
+ let runs = runs.get();
|
|
+ let Some(sel) = selected_run_id.get() else {
|
|
+ return "Select a run to see its logs.".to_string();
|
|
+ };
|
|
+ let Some(run) = runs.iter().find(|r| r.run_id == sel) else {
|
|
+ return "Selected run not found.".to_string();
|
|
+ };
|
|
+ let mut out = String::new();
|
|
+ for line in &run.logs {
|
|
+ match line.kind {
|
|
+ SmartIdeRunLogKind::Stdout => out.push_str("[out] "),
|
|
+ SmartIdeRunLogKind::Stderr => out.push_str("[err] "),
|
|
+ SmartIdeRunLogKind::Event => {}
|
|
+ }
|
|
+ out.push_str(&line.text);
|
|
+ out.push('\n');
|
|
+ }
|
|
+ out
|
|
+ }))
|
|
+ .style(|s| s.padding(10.0).width_pct(100.0)),
|
|
+ )
|
|
+ .style(|s| s.size_pct(100.0, 60.0)),
|
|
+ ))
|
|
+ .style(|s| s.size_pct(100.0, 100.0).flex_col())
|
|
+ .debug_name("Smart IDE Runs Tab");
|
|
+
|
|
+ let workspace_root = window_tab_data.workspace.path.clone();
|
|
+ let cfg = config.get_untracked();
|
|
+ let env = crate::smart_ide::resolve_env(&cfg, workspace_root.as_deref());
|
|
+ let project_id = crate::smart_ide::resolve_project_id(&cfg, workspace_root.as_deref())
|
|
+ .unwrap_or_else(|_| "<missing project>".to_string());
|
|
+ let preview_url = crate::smart_ide::resolve_preview_url(&cfg, workspace_root.as_deref(), &env)
|
|
+ .ok()
|
|
+ .flatten()
|
|
+ .unwrap_or_default();
|
|
+ let has_preview_url = !preview_url.is_empty();
|
|
+ let anythingllm_url = "http://127.0.0.1:3001/".to_string();
|
|
+ let local_office_url = "http://127.0.0.1:8000/".to_string();
|
|
+
|
|
+ let open_web: Rc<dyn Fn(String)> = {
|
|
+ let internal_command = internal_command;
|
|
+ Rc::new(move |url: String| {
|
|
+ internal_command.send(InternalCommand::OpenWebUri { uri: url });
|
|
+ })
|
|
+ };
|
|
+
|
|
+ let env_label = env.clone();
|
|
+ let project_id_label = project_id.clone();
|
|
+ let env_for_preview_label = env.clone();
|
|
+ let project_id_for_preview_label = project_id.clone();
|
|
+
|
|
+ let services_tab = stack((
|
|
+ container(text("Services").style(|s| s.font_bold()))
|
|
+ .style(|s| s.width_pct(100.0).padding(10.0)),
|
|
+ container(label(move || {
|
|
+ format!("project={project_id_label} env={env_label}")
|
|
+ }))
|
|
+ .style(move |s| {
|
|
+ s.width_pct(100.0)
|
|
+ .padding_horiz(10.0)
|
|
+ .padding_bottom(10.0)
|
|
+ .color(config.get().color(LapceColor::EDITOR_DIM))
|
|
+ }),
|
|
+ container(
|
|
+ stack((
|
|
+ text("Preview").style(|s| s.font_bold()),
|
|
+ label(move || {
|
|
+ if has_preview_url {
|
|
+ format!(
|
|
+ "Open the {env_for_preview_label} preview URL (from projects/<id>/conf.json)."
|
|
+ )
|
|
+ } else {
|
|
+ format!(
|
|
+ "Missing preview URL: projects/{project_id_for_preview_label}/conf.json -> smart_ide.preview_urls.{env_for_preview_label}"
|
|
+ )
|
|
+ }
|
|
+ })
|
|
+ .style(move |s| s.margin_top(6.0).color(config.get().color(LapceColor::EDITOR_DIM))),
|
|
+ pill_action_button(
|
|
+ || "Open preview".to_string(),
|
|
+ {
|
|
+ let open_web = open_web.clone();
|
|
+ let url = preview_url.clone();
|
|
+ move || open_web(url.clone())
|
|
+ },
|
|
+ move || !has_preview_url,
|
|
+ config,
|
|
+ )
|
|
+ .style(|s| s.margin_top(10.0)),
|
|
+ ))
|
|
+ .style(|s| s.flex_col()),
|
|
+ )
|
|
+ .style(|s| s.width_pct(100.0).padding(10.0)),
|
|
+ container(
|
|
+ stack((
|
|
+ text("AnythingLLM").style(|s| s.font_bold()),
|
|
+ label(|| "Opens the AnythingLLM web UI (requires tunnel mode=all).".to_string())
|
|
+ .style(move |s| s.margin_top(6.0).color(config.get().color(LapceColor::EDITOR_DIM))),
|
|
+ pill_action_button(
|
|
+ || "Open AnythingLLM".to_string(),
|
|
+ {
|
|
+ let open_web = open_web.clone();
|
|
+ let url = anythingllm_url.clone();
|
|
+ move || open_web(url.clone())
|
|
+ },
|
|
+ || false,
|
|
+ config,
|
|
+ )
|
|
+ .style(|s| s.margin_top(10.0)),
|
|
+ ))
|
|
+ .style(|s| s.flex_col()),
|
|
+ )
|
|
+ .style(|s| s.width_pct(100.0).padding(10.0)),
|
|
+ container(
|
|
+ stack((
|
|
+ text("Local Office").style(|s| s.font_bold()),
|
|
+ label(|| "Opens the local-office HTTP service (requires tunnel mode=all).".to_string())
|
|
+ .style(move |s| s.margin_top(6.0).color(config.get().color(LapceColor::EDITOR_DIM))),
|
|
+ pill_action_button(
|
|
+ || "Open local-office".to_string(),
|
|
+ {
|
|
+ let open_web = open_web.clone();
|
|
+ let url = local_office_url.clone();
|
|
+ move || open_web(url.clone())
|
|
+ },
|
|
+ || false,
|
|
+ config,
|
|
+ )
|
|
+ .style(|s| s.margin_top(10.0)),
|
|
+ ))
|
|
+ .style(|s| s.flex_col()),
|
|
+ )
|
|
+ .style(|s| s.width_pct(100.0).padding(10.0)),
|
|
+ ))
|
|
+ .style(|s| s.size_pct(100.0, 100.0).flex_col())
|
|
+ .debug_name("Smart IDE Services Tab");
|
|
+ let orch_base = crate::smart_ide::orchestrator_base_url(&config.get_untracked());
|
|
+ let ia_base = crate::smart_ide::ia_dev_gateway_base_url(&config.get_untracked());
|
|
+ let tools_base = crate::smart_ide::tools_bridge_base_url(&config.get_untracked());
|
|
+
|
|
+ let connection_tab = stack((
|
|
+ container(text("Connection").style(|s| s.font_bold()))
|
|
+ .style(|s| s.width_pct(100.0).padding(10.0)),
|
|
+ container(h_stack((
|
|
+ pill_action_button(
|
|
+ || "Refresh health".to_string(),
|
|
+ {
|
|
+ let refresh_health = refresh_health.clone();
|
|
+ move || refresh_health()
|
|
+ },
|
|
+ move || health_loading.get(),
|
|
+ config,
|
|
+ ),
|
|
+ pill_action_button(
|
|
+ || "Refresh tunnel plan".to_string(),
|
|
+ {
|
|
+ let refresh_tunnel_plan = refresh_tunnel_plan.clone();
|
|
+ move || refresh_tunnel_plan()
|
|
+ },
|
|
+ move || tunnel_loading.get(),
|
|
+ config,
|
|
+ )
|
|
+ .style(|s| s.margin_left(8.0)),
|
|
+ )))
|
|
+ .style(|s| s.width_pct(100.0).padding_horiz(10.0).padding_bottom(10.0)),
|
|
+ container(
|
|
+ stack((
|
|
+ text("Health").style(|s| s.font_bold()),
|
|
+ label(move || format!("orchestrator: {orch_base}")).style(move |s| {
|
|
+ s.margin_top(6.0)
|
|
+ .color(config.get().color(LapceColor::EDITOR_DIM))
|
|
+ }),
|
|
+ label(move || {
|
|
+ match health_orch.get() {
|
|
+ HealthStatus::Unknown => "orchestrator status: unknown".to_string(),
|
|
+ HealthStatus::Ok => "orchestrator status: ok".to_string(),
|
|
+ HealthStatus::Error(e) => {
|
|
+ let first = e.lines().next().unwrap_or("error");
|
|
+ format!("orchestrator status: error ({first})")
|
|
+ }
|
|
+ }
|
|
+ })
|
|
+ .style(move |s| {
|
|
+ s.margin_top(6.0).apply_if(
|
|
+ matches!(health_orch.get(), HealthStatus::Error(_)),
|
|
+ |s| s.color(config.get().color(LapceColor::LAPCE_ERROR)),
|
|
+ )
|
|
+ }),
|
|
+ label(move || format!("ia-dev-gateway: {ia_base}")).style(move |s| {
|
|
+ s.margin_top(10.0)
|
|
+ .color(config.get().color(LapceColor::EDITOR_DIM))
|
|
+ }),
|
|
+ label(move || {
|
|
+ match health_ia.get() {
|
|
+ HealthStatus::Unknown => "ia-dev-gateway status: unknown".to_string(),
|
|
+ HealthStatus::Ok => "ia-dev-gateway status: ok".to_string(),
|
|
+ HealthStatus::Error(e) => {
|
|
+ let first = e.lines().next().unwrap_or("error");
|
|
+ format!("ia-dev-gateway status: error ({first})")
|
|
+ }
|
|
+ }
|
|
+ })
|
|
+ .style(move |s| {
|
|
+ s.margin_top(6.0).apply_if(
|
|
+ matches!(health_ia.get(), HealthStatus::Error(_)),
|
|
+ |s| s.color(config.get().color(LapceColor::LAPCE_ERROR)),
|
|
+ )
|
|
+ }),
|
|
+ label(move || format!("tools-bridge: {tools_base}")).style(move |s| {
|
|
+ s.margin_top(10.0)
|
|
+ .color(config.get().color(LapceColor::EDITOR_DIM))
|
|
+ }),
|
|
+ label(move || {
|
|
+ match health_tools.get() {
|
|
+ HealthStatus::Unknown => "tools-bridge status: unknown".to_string(),
|
|
+ HealthStatus::Ok => "tools-bridge status: ok".to_string(),
|
|
+ HealthStatus::Error(e) => {
|
|
+ let first = e.lines().next().unwrap_or("error");
|
|
+ format!("tools-bridge status: error ({first})")
|
|
+ }
|
|
+ }
|
|
+ })
|
|
+ .style(move |s| {
|
|
+ s.margin_top(6.0).apply_if(
|
|
+ matches!(health_tools.get(), HealthStatus::Error(_)),
|
|
+ |s| s.color(config.get().color(LapceColor::LAPCE_ERROR)),
|
|
+ )
|
|
+ }),
|
|
+ label(move || {
|
|
+ if health_loading.get() {
|
|
+ "Health check running…".to_string()
|
|
+ } else {
|
|
+ "".to_string()
|
|
+ }
|
|
+ })
|
|
+ .style(move |s| {
|
|
+ s.margin_top(10.0)
|
|
+ .color(config.get().color(LapceColor::EDITOR_DIM))
|
|
+ }),
|
|
+ ))
|
|
+ .style(|s| s.flex_col()),
|
|
+ )
|
|
+ .style(|s| s.width_pct(100.0).padding(10.0)),
|
|
+ container(
|
|
+ stack((
|
|
+ text("SSH tunnel").style(|s| s.font_bold()),
|
|
+ label(move || {
|
|
+ tunnel_error
|
|
+ .get()
|
|
+ .unwrap_or_default()
|
|
+ .lines()
|
|
+ .next()
|
|
+ .unwrap_or("")
|
|
+ .to_string()
|
|
+ })
|
|
+ .style(move |s| {
|
|
+ s.margin_top(6.0)
|
|
+ .color(config.get().color(LapceColor::LAPCE_ERROR))
|
|
+ .apply_if(tunnel_error.get().is_none(), |s| s.hide())
|
|
+ }),
|
|
+ label(move || tunnel_cmd.get()).style(move |s| {
|
|
+ s.margin_top(6.0)
|
|
+ .padding(8.0)
|
|
+ .border(1.0)
|
|
+ .border_radius(6.0)
|
|
+ .border_color(config.get().color(LapceColor::LAPCE_BORDER))
|
|
+ .background(config.get().color(LapceColor::PANEL_BACKGROUND))
|
|
+ }),
|
|
+ pill_action_button(
|
|
+ || "Copy command".to_string(),
|
|
+ {
|
|
+ let tunnel_cmd = tunnel_cmd;
|
|
+ move || {
|
|
+ let cmd = tunnel_cmd.get();
|
|
+ if cmd.is_empty() {
|
|
+ return;
|
|
+ }
|
|
+ let mut clipboard = SystemClipboard::new();
|
|
+ clipboard.put_string(&cmd);
|
|
+ }
|
|
+ },
|
|
+ move || tunnel_cmd.get().is_empty(),
|
|
+ config,
|
|
+ )
|
|
+ .style(|s| s.margin_top(10.0)),
|
|
+ label(move || tunnel_hint.get()).style(move |s| {
|
|
+ s.margin_top(10.0)
|
|
+ .color(config.get().color(LapceColor::EDITOR_DIM))
|
|
+ }),
|
|
+ label(move || {
|
|
+ if tunnel_loading.get() {
|
|
+ "Generating tunnel plan…".to_string()
|
|
+ } else {
|
|
+ "".to_string()
|
|
+ }
|
|
+ })
|
|
+ .style(move |s| {
|
|
+ s.margin_top(10.0)
|
|
+ .color(config.get().color(LapceColor::EDITOR_DIM))
|
|
+ }),
|
|
+ ))
|
|
+ .style(|s| s.flex_col()),
|
|
+ )
|
|
+ .style(|s| s.width_pct(100.0).padding(10.0)),
|
|
+ ))
|
|
+ .style(|s| s.size_pct(100.0, 100.0).flex_col())
|
|
+ .debug_name("Smart IDE Connection Tab");
|
|
+
|
|
+ let content = stack((
|
|
+ agents_tab.style(move |s| {
|
|
+ s.apply_if(active_tab.get() != SmartIdeTab::Agents, |s| s.hide())
|
|
+ }),
|
|
+ runs_tab.style(move |s| {
|
|
+ s.apply_if(active_tab.get() != SmartIdeTab::Runs, |s| s.hide())
|
|
+ }),
|
|
+ services_tab.style(move |s| {
|
|
+ s.apply_if(active_tab.get() != SmartIdeTab::Services, |s| s.hide())
|
|
+ }),
|
|
+ connection_tab.style(move |s| {
|
|
+ s.apply_if(active_tab.get() != SmartIdeTab::Connection, |s| s.hide())
|
|
+ }),
|
|
+ ))
|
|
+ .style(|s| s.size_pct(100.0, 100.0));
|
|
+
|
|
+ stack((header, pinned_bar, tabs, content))
|
|
+ .style(|s| s.absolute().size_pct(100.0, 100.0).flex_col())
|
|
+ .debug_name("Smart IDE Panel")
|
|
+}
|
|
+
|
|
diff --git a/lapce-app/src/panel/view.rs b/lapce-app/src/panel/view.rs
|
|
index d9a9b0d..52929f5 100644
|
|
--- a/lapce-app/src/panel/view.rs
|
|
+++ b/lapce-app/src/panel/view.rs
|
|
@@ -24,6 +24,7 @@
|
|
position::{PanelContainerPosition, PanelPosition},
|
|
problem_view::problem_panel,
|
|
source_control_view::source_control_panel,
|
|
+ smart_ide_view::smart_ide_panel,
|
|
terminal_view::terminal_panel,
|
|
};
|
|
use crate::{
|
|
@@ -477,6 +478,9 @@ fn panel_view(
|
|
PanelKind::FileExplorer => {
|
|
file_explorer_panel(window_tab_data.clone(), position).into_any()
|
|
}
|
|
+ PanelKind::SmartIde => {
|
|
+ smart_ide_panel(window_tab_data.clone(), position).into_any()
|
|
+ }
|
|
PanelKind::SourceControl => {
|
|
source_control_panel(window_tab_data.clone(), position)
|
|
.into_any()
|
|
@@ -554,6 +558,7 @@ fn panel_picker(
|
|
let tooltip = match p {
|
|
PanelKind::Terminal => "Terminal",
|
|
PanelKind::FileExplorer => "File Explorer",
|
|
+ PanelKind::SmartIde => "Smart IDE",
|
|
PanelKind::SourceControl => "Source Control",
|
|
PanelKind::Plugin => "Plugins",
|
|
PanelKind::Search => "Search",
|
|
diff --git a/lapce-app/src/smart_ide.rs b/lapce-app/src/smart_ide.rs
|
|
index 44f6478..51173b3 100644
|
|
--- a/lapce-app/src/smart_ide.rs
|
|
+++ b/lapce-app/src/smart_ide.rs
|
|
@@ -1,7 +1,14 @@
|
|
use std::time::Duration;
|
|
+use std::{
|
|
+ fs,
|
|
+ io::{BufRead, BufReader},
|
|
+ path::{Path, PathBuf},
|
|
+ process::Command,
|
|
+};
|
|
|
|
use anyhow::{Context, Result, anyhow};
|
|
use reqwest::blocking::Client;
|
|
+use serde::Deserialize;
|
|
use serde_json::{Value, json};
|
|
|
|
use crate::config::LapceConfig;
|
|
@@ -14,6 +21,14 @@
|
|
pub const KEY_IA_DEV_GATEWAY_URL: &str = "ia_dev_gateway_url";
|
|
pub const KEY_IA_DEV_GATEWAY_TOKEN: &str = "ia_dev_gateway_token";
|
|
|
|
+pub const KEY_TOOLS_BRIDGE_URL: &str = "tools_bridge_url";
|
|
+pub const KEY_TOOLS_BRIDGE_TOKEN: &str = "tools_bridge_token";
|
|
+
|
|
+pub const KEY_PROJECT_ID: &str = "project_id";
|
|
+pub const KEY_ENV: &str = "env";
|
|
+
|
|
+pub const KEY_PINNED_ACTIONS: &str = "pinned_actions";
|
|
+
|
|
fn plugin_string(config: &LapceConfig, key: &str) -> Option<String> {
|
|
config
|
|
.plugins
|
|
@@ -27,6 +42,54 @@ fn normalize_base_url(raw: &str) -> String {
|
|
raw.trim_end_matches('/').to_string()
|
|
}
|
|
|
|
+fn plugin_string_csv(config: &LapceConfig, key: &str) -> Vec<String> {
|
|
+ let raw = plugin_string(config, key).unwrap_or_default();
|
|
+ raw.split(',')
|
|
+ .map(|s| s.trim())
|
|
+ .filter(|s| !s.is_empty())
|
|
+ .map(|s| s.to_string())
|
|
+ .collect()
|
|
+}
|
|
+
|
|
+fn dir_exists(p: &Path) -> bool {
|
|
+ fs::metadata(p).map(|m| m.is_dir()).unwrap_or(false)
|
|
+}
|
|
+
|
|
+fn is_smart_ide_repo_root(p: &Path) -> bool {
|
|
+ dir_exists(&p.join("projects"))
|
|
+ && dir_exists(&p.join("services"))
|
|
+ && dir_exists(&p.join("scripts"))
|
|
+}
|
|
+
|
|
+fn find_smart_ide_repo_root(start: &Path) -> Option<PathBuf> {
|
|
+ let mut cur: Option<&Path> = Some(start);
|
|
+ for _ in 0..10 {
|
|
+ let Some(p) = cur else {
|
|
+ break;
|
|
+ };
|
|
+ if is_smart_ide_repo_root(p) {
|
|
+ return Some(p.to_path_buf());
|
|
+ }
|
|
+ cur = p.parent();
|
|
+ }
|
|
+ None
|
|
+}
|
|
+
|
|
+fn resolve_repo_root(workspace_root: Option<&Path>) -> Result<PathBuf> {
|
|
+ let Some(ws) = workspace_root else {
|
|
+ return Err(anyhow!(
|
|
+ "Workspace folder is required (open the smart_ide repo root)."
|
|
+ ));
|
|
+ };
|
|
+ find_smart_ide_repo_root(ws).ok_or_else(|| {
|
|
+ anyhow!(
|
|
+ "Could not locate smart_ide repo root from workspace path: {ws:?}\n\
|
|
+\n\
|
|
+Open the smart_ide repo root so 'projects/', 'services/' and 'scripts/' are available."
|
|
+ )
|
|
+ })
|
|
+}
|
|
+
|
|
pub fn orchestrator_base_url(config: &LapceConfig) -> String {
|
|
plugin_string(config, KEY_ORCHESTRATOR_URL)
|
|
.map(|s| normalize_base_url(&s))
|
|
@@ -71,6 +134,135 @@ pub fn ia_dev_gateway_token(config: &LapceConfig) -> Result<String> {
|
|
})
|
|
}
|
|
|
|
+pub fn tools_bridge_base_url(config: &LapceConfig) -> String {
|
|
+ plugin_string(config, KEY_TOOLS_BRIDGE_URL)
|
|
+ .map(|s| normalize_base_url(&s))
|
|
+ .unwrap_or_else(|| "http://127.0.0.1:37147".to_string())
|
|
+}
|
|
+
|
|
+pub fn tools_bridge_token(config: &LapceConfig) -> Result<String> {
|
|
+ plugin_string(config, KEY_TOOLS_BRIDGE_TOKEN).ok_or_else(|| {
|
|
+ anyhow!(
|
|
+ "Missing setting: [smart-ide].{KEY_TOOLS_BRIDGE_TOKEN}\n\
|
|
+\n\
|
|
+Add it to your settings file.\n\
|
|
+\n\
|
|
+Example:\n\
|
|
+[smart-ide]\n\
|
|
+tools_bridge_url = \"http://127.0.0.1:37147\"\n\
|
|
+tools_bridge_token = \"...\""
|
|
+ )
|
|
+ })
|
|
+}
|
|
+
|
|
+pub fn pinned_actions(config: &LapceConfig) -> Vec<String> {
|
|
+ let actions = plugin_string_csv(config, KEY_PINNED_ACTIONS);
|
|
+ if !actions.is_empty() {
|
|
+ return actions;
|
|
+ }
|
|
+ vec![
|
|
+ "site-generate".to_string(),
|
|
+ "push-by-script".to_string(),
|
|
+ "deploy-by-script:test".to_string(),
|
|
+ "branch-align-by-script-from-test:test".to_string(),
|
|
+ "smart-ide:tunnel".to_string(),
|
|
+ ]
|
|
+}
|
|
+
|
|
+fn read_active_project_file(repo_root: &Path) -> Result<Value> {
|
|
+ let p = repo_root.join("projects").join("active-project.json");
|
|
+ let text =
|
|
+ fs::read_to_string(&p).with_context(|| format!("Failed to read {p:?}"))?;
|
|
+ serde_json::from_str(&text)
|
|
+ .with_context(|| format!("Failed to parse JSON in {p:?}"))
|
|
+}
|
|
+
|
|
+pub fn resolve_project_id(
|
|
+ config: &LapceConfig,
|
|
+ workspace_root: Option<&Path>,
|
|
+) -> Result<String> {
|
|
+ if let Some(id) = plugin_string(config, KEY_PROJECT_ID) {
|
|
+ if !id.trim().is_empty() {
|
|
+ return Ok(id.trim().to_string());
|
|
+ }
|
|
+ }
|
|
+ if let Some(root) = workspace_root {
|
|
+ let repo_root = find_smart_ide_repo_root(root).unwrap_or_else(|| root.to_path_buf());
|
|
+ if let Ok(v) = read_active_project_file(&repo_root) {
|
|
+ if let Some(id) = v.get("id").and_then(|v| v.as_str()) {
|
|
+ if !id.trim().is_empty() {
|
|
+ return Ok(id.trim().to_string());
|
|
+ }
|
|
+ }
|
|
+ }
|
|
+ }
|
|
+ Err(anyhow!(
|
|
+ "Smart IDE project is not configured.\n\
|
|
+\n\
|
|
+Set one of:\n\
|
|
+- [smart-ide].{KEY_PROJECT_ID} in settings.toml\n\
|
|
+- projects/active-project.json (copy from projects/active-project.json.example)\n\
|
|
+\n\
|
|
+Example:\n\
|
|
+[smart-ide]\n\
|
|
+project_id = \"smart_ide\""
|
|
+ ))
|
|
+}
|
|
+
|
|
+pub fn resolve_env(
|
|
+ config: &LapceConfig,
|
|
+ workspace_root: Option<&Path>,
|
|
+) -> String {
|
|
+ let from_settings = plugin_string(config, KEY_ENV)
|
|
+ .unwrap_or_default()
|
|
+ .trim()
|
|
+ .to_string();
|
|
+ if matches!(from_settings.as_str(), "test" | "pprod" | "prod") {
|
|
+ return from_settings;
|
|
+ }
|
|
+ if let Some(root) = workspace_root {
|
|
+ let repo_root = find_smart_ide_repo_root(root).unwrap_or_else(|| root.to_path_buf());
|
|
+ if let Ok(v) = read_active_project_file(&repo_root) {
|
|
+ if let Some(env) = v.get("default_env").and_then(|v| v.as_str()) {
|
|
+ let env = env.trim();
|
|
+ if matches!(env, "test" | "pprod" | "prod") {
|
|
+ return env.to_string();
|
|
+ }
|
|
+ }
|
|
+ }
|
|
+ }
|
|
+ "test".to_string()
|
|
+}
|
|
+
|
|
+fn read_project_conf(repo_root: &Path, project_id: &str) -> Result<Value> {
|
|
+ let p = repo_root
|
|
+ .join("projects")
|
|
+ .join(project_id)
|
|
+ .join("conf.json");
|
|
+ let text =
|
|
+ fs::read_to_string(&p).with_context(|| format!("Failed to read {p:?}"))?;
|
|
+ serde_json::from_str(&text)
|
|
+ .with_context(|| format!("Failed to parse JSON in {p:?}"))
|
|
+}
|
|
+
|
|
+pub fn resolve_preview_url(
|
|
+ config: &LapceConfig,
|
|
+ workspace_root: Option<&Path>,
|
|
+ env: &str,
|
|
+) -> Result<Option<String>> {
|
|
+ let repo_root = resolve_repo_root(workspace_root)?;
|
|
+ let project_id = resolve_project_id(config, Some(&repo_root))?;
|
|
+ let v = read_project_conf(&repo_root, &project_id)?;
|
|
+ let url = v
|
|
+ .get("smart_ide")
|
|
+ .and_then(|v| v.get("preview_urls"))
|
|
+ .and_then(|v| v.get(env))
|
|
+ .and_then(|v| v.as_str())
|
|
+ .map(|s| s.trim().to_string())
|
|
+ .filter(|s| !s.is_empty());
|
|
+ Ok(url)
|
|
+}
|
|
+
|
|
fn http_client() -> Result<Client> {
|
|
Client::builder()
|
|
.timeout(Duration::from_secs(20))
|
|
@@ -78,6 +270,113 @@ fn http_client() -> Result<Client> {
|
|
.context("Failed to build HTTP client")
|
|
}
|
|
|
|
+fn health_check(url: &str) -> Result<()> {
|
|
+ let client = http_client()?;
|
|
+ let resp = client.get(url).send().context("Health check request failed")?;
|
|
+ let status = resp.status();
|
|
+ if !status.is_success() {
|
|
+ let text = resp.text().unwrap_or_default();
|
|
+ return Err(anyhow!(
|
|
+ "Health check failed (HTTP {status}):\n{text}"
|
|
+ ));
|
|
+ }
|
|
+ Ok(())
|
|
+}
|
|
+
|
|
+pub fn orchestrator_health(config: &LapceConfig) -> Result<()> {
|
|
+ health_check(&format!("{}/health", orchestrator_base_url(config)))
|
|
+}
|
|
+
|
|
+pub fn ia_dev_gateway_health(config: &LapceConfig) -> Result<()> {
|
|
+ health_check(&format!("{}/health", ia_dev_gateway_base_url(config)))
|
|
+}
|
|
+
|
|
+pub fn tools_bridge_health(config: &LapceConfig) -> Result<()> {
|
|
+ health_check(&format!("{}/health", tools_bridge_base_url(config)))
|
|
+}
|
|
+
|
|
+#[derive(Debug, Clone, Deserialize)]
|
|
+pub struct SshTunnelPlan {
|
|
+ #[serde(rename = "projectId")]
|
|
+ pub project_id: String,
|
|
+ pub env: String,
|
|
+ #[serde(rename = "sshHostAlias")]
|
|
+ pub ssh_host_alias: String,
|
|
+ pub mode: String,
|
|
+ pub argv: Vec<String>,
|
|
+ pub hint: String,
|
|
+}
|
|
+
|
|
+pub fn ssh_tunnel_plan(
|
|
+ config: &LapceConfig,
|
|
+ workspace_root: Option<&Path>,
|
|
+ mode: &str,
|
|
+) -> Result<SshTunnelPlan> {
|
|
+ let repo_root = resolve_repo_root(workspace_root)?;
|
|
+ let project_id = resolve_project_id(config, Some(&repo_root))?;
|
|
+ let env = resolve_env(config, Some(&repo_root));
|
|
+ let script = repo_root
|
|
+ .join("scripts")
|
|
+ .join("smart-ide-ssh-tunnel-plan.sh");
|
|
+
|
|
+ let out = Command::new("bash")
|
|
+ .arg(script)
|
|
+ .arg("--project")
|
|
+ .arg(&project_id)
|
|
+ .arg("--env")
|
|
+ .arg(&env)
|
|
+ .arg("--mode")
|
|
+ .arg(mode)
|
|
+ .arg("--json")
|
|
+ .output()
|
|
+ .context("Failed to run smart-ide-ssh-tunnel-plan.sh")?;
|
|
+
|
|
+ if !out.status.success() {
|
|
+ let stdout = String::from_utf8_lossy(&out.stdout);
|
|
+ let stderr = String::from_utf8_lossy(&out.stderr);
|
|
+ return Err(anyhow!(
|
|
+ "Tunnel plan script failed (exit={}):\n\nstdout:\n{}\n\nstderr:\n{}",
|
|
+ out.status.code().unwrap_or(1),
|
|
+ stdout,
|
|
+ stderr
|
|
+ ));
|
|
+ }
|
|
+
|
|
+ let text = String::from_utf8(out.stdout).context("Tunnel plan output is not UTF-8")?;
|
|
+ serde_json::from_str(&text).context("Failed to parse tunnel plan JSON")
|
|
+}
|
|
+
|
|
+fn shell_escape(arg: &str) -> String {
|
|
+ let safe = arg
|
|
+ .chars()
|
|
+ .all(|c| c.is_ascii_alphanumeric() || "_@%+=:,./-".contains(c));
|
|
+ if safe && !arg.is_empty() {
|
|
+ return arg.to_string();
|
|
+ }
|
|
+ let mut out = String::new();
|
|
+ out.push('\'');
|
|
+ for ch in arg.chars() {
|
|
+ if ch == '\'' {
|
|
+ out.push_str("'\\''");
|
|
+ } else {
|
|
+ out.push(ch);
|
|
+ }
|
|
+ }
|
|
+ out.push('\'');
|
|
+ out
|
|
+}
|
|
+
|
|
+pub fn shell_command_from_argv(argv: &[String]) -> String {
|
|
+ let mut out = String::new();
|
|
+ for (i, a) in argv.iter().enumerate() {
|
|
+ if i > 0 {
|
|
+ out.push(' ');
|
|
+ }
|
|
+ out.push_str(&shell_escape(a));
|
|
+ }
|
|
+ out
|
|
+}
|
|
+
|
|
pub fn orchestrator_execute(config: &LapceConfig, intent: &str) -> Result<Value> {
|
|
let base_url = orchestrator_base_url(config);
|
|
let token = orchestrator_token(config)?;
|
|
@@ -152,3 +451,137 @@ pub fn ia_dev_list_agents(config: &LapceConfig) -> Result<Value> {
|
|
serde_json::from_str(&text).context("Failed to parse ia-dev-gateway JSON response")
|
|
}
|
|
|
|
+#[derive(Debug, Clone, Deserialize)]
|
|
+pub struct IaDevRunStartResponse {
|
|
+ #[serde(rename = "runId")]
|
|
+ pub run_id: String,
|
|
+ pub status: String,
|
|
+}
|
|
+
|
|
+pub fn ia_dev_start_run(
|
|
+ config: &LapceConfig,
|
|
+ agent_id: &str,
|
|
+ project_id: &str,
|
|
+ intent: &str,
|
|
+ env: Option<&str>,
|
|
+ payload: Option<&Value>,
|
|
+) -> Result<IaDevRunStartResponse> {
|
|
+ let base_url = ia_dev_gateway_base_url(config);
|
|
+ let token = ia_dev_gateway_token(config)?;
|
|
+
|
|
+ let url = format!("{base_url}/v1/runs");
|
|
+ let mut body = json!({
|
|
+ "agentId": agent_id,
|
|
+ "projectId": project_id,
|
|
+ "intent": intent,
|
|
+ });
|
|
+ if let Some(env) = env {
|
|
+ body["env"] = json!(env);
|
|
+ }
|
|
+ if let Some(payload) = payload {
|
|
+ body["payload"] = payload.clone();
|
|
+ }
|
|
+
|
|
+ let client = http_client()?;
|
|
+ let resp = client
|
|
+ .post(url)
|
|
+ .bearer_auth(token)
|
|
+ .json(&body)
|
|
+ .send()
|
|
+ .context("IA-dev-gateway start run request failed")?;
|
|
+
|
|
+ let status = resp.status();
|
|
+ let text = resp.text().context("Failed to read ia-dev-gateway response body")?;
|
|
+ if !status.is_success() {
|
|
+ return Err(anyhow!(
|
|
+ "IA-dev-gateway error (HTTP {status}):\n{text}"
|
|
+ ));
|
|
+ }
|
|
+
|
|
+ serde_json::from_str(&text).context("Failed to parse ia-dev-gateway start run response")
|
|
+}
|
|
+
|
|
+pub fn ia_dev_stream_run_events(
|
|
+ config: &LapceConfig,
|
|
+ run_id: &str,
|
|
+ last_event_id: Option<i64>,
|
|
+ mut on_event: impl FnMut(Value),
|
|
+) -> Result<()> {
|
|
+ let base_url = ia_dev_gateway_base_url(config);
|
|
+ let token = ia_dev_gateway_token(config)?;
|
|
+ let url = format!("{base_url}/v1/runs/{run_id}/events");
|
|
+
|
|
+ let client = http_client()?;
|
|
+ let mut req = client.get(url).bearer_auth(token);
|
|
+ if let Some(last_id) = last_event_id {
|
|
+ req = req.header("Last-Event-ID", last_id.to_string());
|
|
+ }
|
|
+ let resp = req
|
|
+ .send()
|
|
+ .context("IA-dev-gateway events stream request failed")?;
|
|
+
|
|
+ let status = resp.status();
|
|
+ if !status.is_success() {
|
|
+ let text = resp
|
|
+ .text()
|
|
+ .context("Failed to read ia-dev-gateway events error body")?;
|
|
+ return Err(anyhow!(
|
|
+ "IA-dev-gateway error (HTTP {status}):\n{text}"
|
|
+ ));
|
|
+ }
|
|
+
|
|
+ let mut reader = BufReader::new(resp);
|
|
+ let mut data_buf = String::new();
|
|
+ let mut has_data = false;
|
|
+
|
|
+ loop {
|
|
+ let mut line = String::new();
|
|
+ let n = reader
|
|
+ .read_line(&mut line)
|
|
+ .context("Failed to read SSE line")?;
|
|
+ if n == 0 {
|
|
+ break;
|
|
+ }
|
|
+ let line = line.strip_suffix('\n').unwrap_or(&line);
|
|
+ let line = line.strip_suffix('\r').unwrap_or(line);
|
|
+
|
|
+ if line.is_empty() {
|
|
+ if has_data {
|
|
+ let v: Value =
|
|
+ serde_json::from_str(&data_buf).context("Invalid SSE JSON data")?;
|
|
+ on_event(v);
|
|
+ }
|
|
+ data_buf.clear();
|
|
+ has_data = false;
|
|
+ continue;
|
|
+ }
|
|
+
|
|
+ if line.starts_with(':') {
|
|
+ continue;
|
|
+ }
|
|
+
|
|
+ if line.starts_with("event:") {
|
|
+ continue;
|
|
+ }
|
|
+ if let Some(rest) = line.strip_prefix("data:") {
|
|
+ if !data_buf.is_empty() {
|
|
+ data_buf.push('\n');
|
|
+ }
|
|
+ data_buf.push_str(rest.trim_start());
|
|
+ has_data = true;
|
|
+ continue;
|
|
+ }
|
|
+ if line.starts_with("id:") || line.starts_with("retry:") {
|
|
+ continue;
|
|
+ }
|
|
+ }
|
|
+
|
|
+ // Ensure we flush any trailing event without blank line (defensive).
|
|
+ if has_data {
|
|
+ let v: Value = serde_json::from_str(&data_buf).context("Invalid SSE JSON data")?;
|
|
+ on_event(v);
|
|
+ }
|
|
+
|
|
+ Ok(())
|
|
+}
|
|
+
|
|
diff --git a/lapce-app/src/smart_ide_ui.rs b/lapce-app/src/smart_ide_ui.rs
|
|
new file mode 100644
|
|
index 0000000..7a9d451
|
|
--- /dev/null
|
|
+++ b/lapce-app/src/smart_ide_ui.rs
|
|
@@ -0,0 +1,192 @@
|
|
+use std::sync::mpsc::{Sender, channel};
|
|
+
|
|
+use floem::{
|
|
+ ext_event::create_signal_from_channel,
|
|
+ reactive::{RwSignal, Scope, SignalUpdate, SignalWith},
|
|
+};
|
|
+
|
|
+use im::Vector;
|
|
+
|
|
+const MAX_RUNS: usize = 50;
|
|
+const MAX_LOG_LINES_PER_RUN: usize = 5_000;
|
|
+
|
|
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
+pub enum SmartIdeTab {
|
|
+ Agents,
|
|
+ Runs,
|
|
+ Services,
|
|
+ Connection,
|
|
+}
|
|
+
|
|
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
+pub enum SmartIdeRunLogKind {
|
|
+ Event,
|
|
+ Stdout,
|
|
+ Stderr,
|
|
+}
|
|
+
|
|
+#[derive(Clone, Debug, PartialEq, Eq)]
|
|
+pub struct SmartIdeRunLogLine {
|
|
+ pub id: i64,
|
|
+ pub kind: SmartIdeRunLogKind,
|
|
+ pub text: String,
|
|
+}
|
|
+
|
|
+#[derive(Clone, Debug, PartialEq, Eq)]
|
|
+pub struct SmartIdeRun {
|
|
+ pub run_id: String,
|
|
+ pub agent_id: String,
|
|
+ pub project_id: String,
|
|
+ pub intent: String,
|
|
+ pub env: Option<String>,
|
|
+ pub status: String,
|
|
+ pub started_at: String,
|
|
+ pub finished_at: Option<String>,
|
|
+ pub exit_code: Option<i64>,
|
|
+ pub summary: Option<String>,
|
|
+ pub error: Option<String>,
|
|
+ pub logs: Vector<SmartIdeRunLogLine>,
|
|
+}
|
|
+
|
|
+#[derive(Clone, Debug)]
|
|
+pub enum SmartIdeUiEvent {
|
|
+ UpsertRun {
|
|
+ run_id: String,
|
|
+ agent_id: String,
|
|
+ project_id: String,
|
|
+ intent: String,
|
|
+ env: Option<String>,
|
|
+ status: String,
|
|
+ started_at: String,
|
|
+ finished_at: Option<String>,
|
|
+ exit_code: Option<i64>,
|
|
+ summary: Option<String>,
|
|
+ error: Option<String>,
|
|
+ },
|
|
+ AppendLog {
|
|
+ run_id: String,
|
|
+ line: SmartIdeRunLogLine,
|
|
+ },
|
|
+}
|
|
+
|
|
+#[derive(Clone)]
|
|
+pub struct SmartIdeData {
|
|
+ pub active_tab: RwSignal<SmartIdeTab>,
|
|
+ pub selected_run_id: RwSignal<Option<String>>,
|
|
+ pub runs: RwSignal<Vector<SmartIdeRun>>,
|
|
+ tx: Sender<SmartIdeUiEvent>,
|
|
+}
|
|
+
|
|
+impl SmartIdeData {
|
|
+ pub fn new(cx: Scope) -> Self {
|
|
+ let active_tab = cx.create_rw_signal(SmartIdeTab::Agents);
|
|
+ let selected_run_id = cx.create_rw_signal(None);
|
|
+ let runs = cx.create_rw_signal(Vector::<SmartIdeRun>::new());
|
|
+
|
|
+ let (tx, rx) = channel::<SmartIdeUiEvent>();
|
|
+ let notification = create_signal_from_channel(rx);
|
|
+
|
|
+ {
|
|
+ let selected_run_id = selected_run_id;
|
|
+ let runs_signal = runs;
|
|
+ cx.create_effect(move |_| {
|
|
+ notification.with(|ev| {
|
|
+ let Some(ev) = ev.as_ref() else {
|
|
+ return;
|
|
+ };
|
|
+ match ev {
|
|
+ SmartIdeUiEvent::UpsertRun {
|
|
+ run_id,
|
|
+ agent_id,
|
|
+ project_id,
|
|
+ intent,
|
|
+ env,
|
|
+ status,
|
|
+ started_at,
|
|
+ finished_at,
|
|
+ exit_code,
|
|
+ summary,
|
|
+ error,
|
|
+ } => {
|
|
+ runs_signal.update(|runs| {
|
|
+ let mut next = Vector::new();
|
|
+ let mut found = false;
|
|
+ for r in runs.iter() {
|
|
+ if r.run_id == *run_id {
|
|
+ let mut updated = r.clone();
|
|
+ updated.agent_id = agent_id.clone();
|
|
+ updated.project_id = project_id.clone();
|
|
+ updated.intent = intent.clone();
|
|
+ updated.env = env.clone();
|
|
+ updated.status = status.clone();
|
|
+ updated.started_at = started_at.clone();
|
|
+ updated.finished_at = finished_at.clone();
|
|
+ updated.exit_code = *exit_code;
|
|
+ updated.summary = summary.clone();
|
|
+ updated.error = error.clone();
|
|
+ next.push_back(updated);
|
|
+ found = true;
|
|
+ } else {
|
|
+ next.push_back(r.clone());
|
|
+ }
|
|
+ }
|
|
+ if !found {
|
|
+ next.push_back(SmartIdeRun {
|
|
+ run_id: run_id.clone(),
|
|
+ agent_id: agent_id.clone(),
|
|
+ project_id: project_id.clone(),
|
|
+ intent: intent.clone(),
|
|
+ env: env.clone(),
|
|
+ status: status.clone(),
|
|
+ started_at: started_at.clone(),
|
|
+ finished_at: finished_at.clone(),
|
|
+ exit_code: *exit_code,
|
|
+ summary: summary.clone(),
|
|
+ error: error.clone(),
|
|
+ logs: Vector::new(),
|
|
+ });
|
|
+ selected_run_id.set(Some(run_id.clone()));
|
|
+ }
|
|
+
|
|
+ while next.len() > MAX_RUNS {
|
|
+ next.pop_front();
|
|
+ }
|
|
+ *runs = next;
|
|
+ });
|
|
+ }
|
|
+ SmartIdeUiEvent::AppendLog { run_id, line } => {
|
|
+ runs_signal.update(|runs| {
|
|
+ let mut next = Vector::new();
|
|
+ for r in runs.iter() {
|
|
+ if r.run_id == *run_id {
|
|
+ let mut updated = r.clone();
|
|
+ updated.logs.push_back(line.clone());
|
|
+ while updated.logs.len() > MAX_LOG_LINES_PER_RUN {
|
|
+ updated.logs.pop_front();
|
|
+ }
|
|
+ next.push_back(updated);
|
|
+ } else {
|
|
+ next.push_back(r.clone());
|
|
+ }
|
|
+ }
|
|
+ *runs = next;
|
|
+ });
|
|
+ }
|
|
+ }
|
|
+ });
|
|
+ });
|
|
+ }
|
|
+
|
|
+ Self {
|
|
+ active_tab,
|
|
+ selected_run_id,
|
|
+ runs,
|
|
+ tx,
|
|
+ }
|
|
+ }
|
|
+
|
|
+ pub fn tx(&self) -> Sender<SmartIdeUiEvent> {
|
|
+ self.tx.clone()
|
|
+ }
|
|
+}
|
|
+
|
|
diff --git a/lapce-app/src/window_tab.rs b/lapce-app/src/window_tab.rs
|
|
index 857a215..69a942a 100644
|
|
--- a/lapce-app/src/window_tab.rs
|
|
+++ b/lapce-app/src/window_tab.rs
|
|
@@ -48,7 +48,7 @@
|
|
CodeActionOrCommand, CodeLens, Diagnostic, ProgressParams, ProgressToken,
|
|
ShowMessageParams,
|
|
};
|
|
-use serde_json::Value;
|
|
+use serde_json::{Value, json};
|
|
use tracing::{Level, debug, error, event};
|
|
|
|
use crate::{
|
|
@@ -72,6 +72,10 @@
|
|
hover::HoverData,
|
|
id::WindowTabId,
|
|
inline_completion::InlineCompletionData,
|
|
+ smart_ide_ui::{
|
|
+ SmartIdeData, SmartIdeRunLogKind, SmartIdeRunLogLine, SmartIdeTab,
|
|
+ SmartIdeUiEvent,
|
|
+ },
|
|
keypress::{EventRef, KeyPressData, KeyPressFocus, condition::Condition},
|
|
listener::Listener,
|
|
lsp::path_from_url,
|
|
@@ -181,6 +185,7 @@ pub struct WindowTabData {
|
|
pub source_control: SourceControlData,
|
|
pub rename: RenameData,
|
|
pub global_search: GlobalSearchData,
|
|
+ pub smart_ide: SmartIdeData,
|
|
pub call_hierarchy_data: CallHierarchyData,
|
|
pub about_data: AboutData,
|
|
pub alert_data: AlertBoxData,
|
|
@@ -511,6 +516,7 @@ pub fn new(
|
|
|
|
let rename = RenameData::new(cx, main_split.editors, common.clone());
|
|
let global_search = GlobalSearchData::new(cx, main_split.clone());
|
|
+ let smart_ide = SmartIdeData::new(cx);
|
|
|
|
let plugin = PluginData::new(
|
|
cx,
|
|
@@ -558,6 +564,7 @@ pub fn new(
|
|
plugin,
|
|
rename,
|
|
global_search,
|
|
+ smart_ide,
|
|
call_hierarchy_data: CallHierarchyData {
|
|
root: cx.create_rw_signal(None),
|
|
common: common.clone(),
|
|
@@ -807,6 +814,339 @@ fn smart_ide_list_agents(&self) {
|
|
.ok();
|
|
}
|
|
|
|
+ fn smart_ide_open_panel(&self) {
|
|
+ self.panel.show_panel(&PanelKind::SmartIde);
|
|
+ self.common.focus.set(Focus::Panel(PanelKind::SmartIde));
|
|
+ }
|
|
+
|
|
+ fn smart_ide_run_pinned_action(&self, index: usize) {
|
|
+ let config = self.common.config.get_untracked();
|
|
+ let actions = crate::smart_ide::pinned_actions(&config);
|
|
+ let Some(action) = actions.get(index) else {
|
|
+ self.common.internal_command.send(InternalCommand::ShowAlert {
|
|
+ title: "Smart IDE".to_string(),
|
|
+ msg: format!("No pinned action configured at position {}.", index + 1),
|
|
+ buttons: Vec::new(),
|
|
+ });
|
|
+ return;
|
|
+ };
|
|
+ self.smart_ide_execute_action(action.clone());
|
|
+ }
|
|
+
|
|
+ pub fn smart_ide_execute_action(&self, action: String) {
|
|
+ if let Some(rest) = action.strip_prefix("smart-ide:") {
|
|
+ match rest {
|
|
+ "tunnel" => {
|
|
+ self.smart_ide_open_panel();
|
|
+ self.smart_ide.active_tab.set(SmartIdeTab::Connection);
|
|
+ return;
|
|
+ }
|
|
+ "open" => {
|
|
+ self.smart_ide_open_panel();
|
|
+ return;
|
|
+ }
|
|
+ _ => {
|
|
+ self.common.internal_command.send(InternalCommand::ShowAlert {
|
|
+ title: "Smart IDE".to_string(),
|
|
+ msg: format!("Unknown Smart IDE action: {action}"),
|
|
+ buttons: Vec::new(),
|
|
+ });
|
|
+ return;
|
|
+ }
|
|
+ }
|
|
+ }
|
|
+
|
|
+ let (agent_id, env_override) = if let Some((a, b)) = action.split_once(':') {
|
|
+ let b = b.trim();
|
|
+ if b == "test" || b == "pprod" || b == "prod" {
|
|
+ (a.trim().to_string(), Some(b.to_string()))
|
|
+ } else {
|
|
+ (action.trim().to_string(), None)
|
|
+ }
|
|
+ } else {
|
|
+ (action.trim().to_string(), None)
|
|
+ };
|
|
+
|
|
+ if agent_id.is_empty() {
|
|
+ self.common.internal_command.send(InternalCommand::ShowAlert {
|
|
+ title: "Smart IDE".to_string(),
|
|
+ msg: "Invalid action: empty agent id.".to_string(),
|
|
+ buttons: Vec::new(),
|
|
+ });
|
|
+ return;
|
|
+ }
|
|
+
|
|
+ if agent_id == "deploy-by-script" {
|
|
+ let Some(env) = env_override.clone() else {
|
|
+ self.common.internal_command.send(InternalCommand::ShowAlert {
|
|
+ title: "Smart IDE".to_string(),
|
|
+ msg: "Deploy requires an environment (test|pprod|prod).".to_string(),
|
|
+ buttons: Vec::new(),
|
|
+ });
|
|
+ return;
|
|
+ };
|
|
+ if env == "pprod" || env == "prod" {
|
|
+ let this = self.clone();
|
|
+ let label = format!("Deploy to {env}");
|
|
+ self.common.internal_command.send(InternalCommand::ShowAlert {
|
|
+ title: "Smart IDE".to_string(),
|
|
+ msg: format!("You are about to deploy to {env}. Continue?"),
|
|
+ buttons: vec![AlertButton {
|
|
+ text: label,
|
|
+ action: Rc::new(move || {
|
|
+ this.common
|
|
+ .internal_command
|
|
+ .send(InternalCommand::HideAlert);
|
|
+ this.smart_ide_start_ia_dev_run(
|
|
+ agent_id.clone(),
|
|
+ Some(env.clone()),
|
|
+ None,
|
|
+ );
|
|
+ }),
|
|
+ }],
|
|
+ });
|
|
+ return;
|
|
+ }
|
|
+ }
|
|
+
|
|
+ let payload = if agent_id == "push-by-script" {
|
|
+ let commit_message = format!(
|
|
+ "chore: push by script\n\nTriggered from Smart IDE pinned action.\nAction: {action}\n"
|
|
+ );
|
|
+ Some(json!({ "commitMessage": commit_message }))
|
|
+ } else {
|
|
+ None
|
|
+ };
|
|
+
|
|
+ self.smart_ide_start_ia_dev_run(agent_id, env_override, payload);
|
|
+ }
|
|
+
|
|
+ fn smart_ide_start_ia_dev_run(
|
|
+ &self,
|
|
+ agent_id: String,
|
|
+ env_override: Option<String>,
|
|
+ payload: Option<Value>,
|
|
+ ) {
|
|
+ self.smart_ide_open_panel();
|
|
+ self.smart_ide.active_tab.set(SmartIdeTab::Runs);
|
|
+
|
|
+ let config = self.common.config.get_untracked();
|
|
+ let tx = self.smart_ide.tx();
|
|
+ let workspace_root = self.workspace.path.clone();
|
|
+
|
|
+ let internal_command = self.common.internal_command;
|
|
+ let send_alert = create_ext_action(self.common.scope, move |msg: String| {
|
|
+ internal_command.send(InternalCommand::ShowAlert {
|
|
+ title: "Smart IDE".to_string(),
|
|
+ msg,
|
|
+ buttons: Vec::new(),
|
|
+ });
|
|
+ });
|
|
+
|
|
+ std::thread::Builder::new()
|
|
+ .name("SmartIdeRunAgent".to_owned())
|
|
+ .spawn(move || {
|
|
+ let project_id = match crate::smart_ide::resolve_project_id(
|
|
+ &config,
|
|
+ workspace_root.as_deref(),
|
|
+ ) {
|
|
+ Ok(id) => id,
|
|
+ Err(e) => {
|
|
+ send_alert(e.to_string());
|
|
+ return;
|
|
+ }
|
|
+ };
|
|
+
|
|
+ let env_effective = match env_override.as_deref() {
|
|
+ Some("test" | "pprod" | "prod") => env_override.clone(),
|
|
+ Some(_) => None,
|
|
+ None => Some(crate::smart_ide::resolve_env(&config, workspace_root.as_deref())),
|
|
+ };
|
|
+
|
|
+ let intent = match env_effective.as_deref() {
|
|
+ Some(env) => format!("smart-ide:{agent_id}:{env}"),
|
|
+ None => format!("smart-ide:{agent_id}"),
|
|
+ };
|
|
+
|
|
+ let started_at = chrono::Utc::now().to_rfc3339();
|
|
+ let started = match crate::smart_ide::ia_dev_start_run(
|
|
+ &config,
|
|
+ &agent_id,
|
|
+ &project_id,
|
|
+ &intent,
|
|
+ env_effective.as_deref(),
|
|
+ payload.as_ref(),
|
|
+ ) {
|
|
+ Ok(v) => v,
|
|
+ Err(e) => {
|
|
+ send_alert(e.to_string());
|
|
+ return;
|
|
+ }
|
|
+ };
|
|
+
|
|
+ let _ = tx.send(SmartIdeUiEvent::UpsertRun {
|
|
+ run_id: started.run_id.clone(),
|
|
+ agent_id: agent_id.clone(),
|
|
+ project_id: project_id.clone(),
|
|
+ intent: intent.clone(),
|
|
+ env: env_effective.clone(),
|
|
+ status: started.status.clone(),
|
|
+ started_at: started_at.clone(),
|
|
+ finished_at: None,
|
|
+ exit_code: None,
|
|
+ summary: None,
|
|
+ error: None,
|
|
+ });
|
|
+
|
|
+ let run_id = started.run_id.clone();
|
|
+ let started_at_for_events = started_at.clone();
|
|
+ let env_for_events = env_effective.clone();
|
|
+
|
|
+ let stream_result = crate::smart_ide::ia_dev_stream_run_events(
|
|
+ &config,
|
|
+ &run_id,
|
|
+ None,
|
|
+ |ev| {
|
|
+ let ev_id = ev.get("id").and_then(|v| v.as_i64()).unwrap_or(0);
|
|
+ let ev_type = ev
|
|
+ .get("type")
|
|
+ .and_then(|v| v.as_str())
|
|
+ .unwrap_or("event");
|
|
+ let at = ev
|
|
+ .get("at")
|
|
+ .and_then(|v| v.as_str())
|
|
+ .unwrap_or("");
|
|
+ let data = ev.get("data").cloned().unwrap_or(Value::Null);
|
|
+
|
|
+ let mut status_update: Option<(String, Option<i64>, Option<String>)> =
|
|
+ None;
|
|
+
|
|
+ let (kind, text) = match ev_type {
|
|
+ "script_stdout" => (
|
|
+ SmartIdeRunLogKind::Stdout,
|
|
+ data.get("line")
|
|
+ .and_then(|v| v.as_str())
|
|
+ .unwrap_or("")
|
|
+ .to_string(),
|
|
+ ),
|
|
+ "script_stderr" => (
|
|
+ SmartIdeRunLogKind::Stderr,
|
|
+ data.get("line")
|
|
+ .and_then(|v| v.as_str())
|
|
+ .unwrap_or("")
|
|
+ .to_string(),
|
|
+ ),
|
|
+ "started" => {
|
|
+ status_update = Some((
|
|
+ "running".to_string(),
|
|
+ None,
|
|
+ None,
|
|
+ ));
|
|
+ (
|
|
+ SmartIdeRunLogKind::Event,
|
|
+ format!("[{at}] started"),
|
|
+ )
|
|
+ }
|
|
+ "script_started" => {
|
|
+ let display = data
|
|
+ .get("displayName")
|
|
+ .and_then(|v| v.as_str())
|
|
+ .unwrap_or("script");
|
|
+ (
|
|
+ SmartIdeRunLogKind::Event,
|
|
+ format!("[{at}] script_started: {display}"),
|
|
+ )
|
|
+ }
|
|
+ "completed" => {
|
|
+ let exit_code = data.get("exitCode").and_then(|v| v.as_i64());
|
|
+ status_update = Some((
|
|
+ "completed".to_string(),
|
|
+ exit_code,
|
|
+ None,
|
|
+ ));
|
|
+ (
|
|
+ SmartIdeRunLogKind::Event,
|
|
+ format!("[{at}] completed (exitCode={})", exit_code.unwrap_or(0)),
|
|
+ )
|
|
+ }
|
|
+ "failed" => {
|
|
+ let exit_code = data.get("exitCode").and_then(|v| v.as_i64());
|
|
+ let err = data
|
|
+ .get("error")
|
|
+ .and_then(|v| v.as_str())
|
|
+ .map(|s| s.to_string());
|
|
+ status_update = Some((
|
|
+ "failed".to_string(),
|
|
+ exit_code,
|
|
+ err.clone(),
|
|
+ ));
|
|
+ (
|
|
+ SmartIdeRunLogKind::Event,
|
|
+ format!(
|
|
+ "[{at}] failed (exitCode={}, error={})",
|
|
+ exit_code.unwrap_or(1),
|
|
+ err.unwrap_or_else(|| "unknown".to_string())
|
|
+ ),
|
|
+ )
|
|
+ }
|
|
+ _ => (
|
|
+ SmartIdeRunLogKind::Event,
|
|
+ format!("[{at}] {ev_type}"),
|
|
+ ),
|
|
+ };
|
|
+
|
|
+ if !text.is_empty() {
|
|
+ let _ = tx.send(SmartIdeUiEvent::AppendLog {
|
|
+ run_id: run_id.clone(),
|
|
+ line: SmartIdeRunLogLine {
|
|
+ id: ev_id,
|
|
+ kind,
|
|
+ text,
|
|
+ },
|
|
+ });
|
|
+ }
|
|
+
|
|
+ if let Some((status, exit_code, error)) = status_update {
|
|
+ let finished_at = if status == "completed" || status == "failed" {
|
|
+ if at.is_empty() {
|
|
+ Some(chrono::Utc::now().to_rfc3339())
|
|
+ } else {
|
|
+ Some(at.to_string())
|
|
+ }
|
|
+ } else {
|
|
+ None
|
|
+ };
|
|
+ let _ = tx.send(SmartIdeUiEvent::UpsertRun {
|
|
+ run_id: run_id.clone(),
|
|
+ agent_id: agent_id.clone(),
|
|
+ project_id: project_id.clone(),
|
|
+ intent: intent.clone(),
|
|
+ env: env_for_events.clone(),
|
|
+ status,
|
|
+ started_at: started_at_for_events.clone(),
|
|
+ finished_at,
|
|
+ exit_code,
|
|
+ summary: None,
|
|
+ error,
|
|
+ });
|
|
+ }
|
|
+ },
|
|
+ );
|
|
+
|
|
+ if let Err(e) = stream_result {
|
|
+ let _ = tx.send(SmartIdeUiEvent::AppendLog {
|
|
+ run_id: started.run_id.clone(),
|
|
+ line: SmartIdeRunLogLine {
|
|
+ id: -1,
|
|
+ kind: SmartIdeRunLogKind::Event,
|
|
+ text: format!("events stream error: {}", e),
|
|
+ },
|
|
+ });
|
|
+ }
|
|
+ })
|
|
+ .ok();
|
|
+ }
|
|
+
|
|
pub fn run_workbench_command(
|
|
&self,
|
|
cmd: LapceWorkbenchCommand,
|
|
@@ -1239,6 +1579,16 @@ pub fn run_workbench_command(
|
|
// ==== Smart IDE ====
|
|
SmartIdeTimeline => self.smart_ide_open_timeline(),
|
|
SmartIdeListAgents => self.smart_ide_list_agents(),
|
|
+ SmartIdeOpenPanel => self.smart_ide_open_panel(),
|
|
+ SmartIdePinnedAction1 => self.smart_ide_run_pinned_action(0),
|
|
+ SmartIdePinnedAction2 => self.smart_ide_run_pinned_action(1),
|
|
+ SmartIdePinnedAction3 => self.smart_ide_run_pinned_action(2),
|
|
+ SmartIdePinnedAction4 => self.smart_ide_run_pinned_action(3),
|
|
+ SmartIdePinnedAction5 => self.smart_ide_run_pinned_action(4),
|
|
+ SmartIdePinnedAction6 => self.smart_ide_run_pinned_action(5),
|
|
+ SmartIdePinnedAction7 => self.smart_ide_run_pinned_action(6),
|
|
+ SmartIdePinnedAction8 => self.smart_ide_run_pinned_action(7),
|
|
+ SmartIdePinnedAction9 => self.smart_ide_run_pinned_action(8),
|
|
|
|
// ==== Running / Debugging ====
|
|
RunAndDebugRestart => {
|
|
@@ -2734,7 +3084,10 @@ fn toggle_panel_focus(&self, kind: PanelKind) {
|
|
// in those cases.
|
|
self.panel.is_panel_visible(&kind)
|
|
}
|
|
- PanelKind::Terminal | PanelKind::SourceControl | PanelKind::Search => {
|
|
+ PanelKind::Terminal
|
|
+ | PanelKind::SourceControl
|
|
+ | PanelKind::Search
|
|
+ | PanelKind::SmartIde => {
|
|
self.is_panel_focused(kind)
|
|
}
|
|
};
|