From 43ee9102b485ed6cdc9d67131376d5738446c788 Mon Sep 17 00:00:00 2001 From: Nicolas Cantu 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>, +) -> 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>, +) -> 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, + _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::::new()); + let agents_loading = create_rw_signal(false); + let agents_error = create_rw_signal(None::); + + 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::); + 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 = { + 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, 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 = { + 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 = { + 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 = { + 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(|_| "".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 = { + 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//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 { 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 { + 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 { + 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 { + 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 { }) } +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 { + 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 { + 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 { + 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 { + 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 { + 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> { + 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::builder() .timeout(Duration::from_secs(20)) @@ -78,6 +270,113 @@ fn http_client() -> Result { .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, + pub hint: String, +} + +pub fn ssh_tunnel_plan( + config: &LapceConfig, + workspace_root: Option<&Path>, + mode: &str, +) -> Result { + 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 { 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 { 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 { + 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, + 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, + pub status: String, + pub started_at: String, + pub finished_at: Option, + pub exit_code: Option, + pub summary: Option, + pub error: Option, + pub logs: Vector, +} + +#[derive(Clone, Debug)] +pub enum SmartIdeUiEvent { + UpsertRun { + run_id: String, + agent_id: String, + project_id: String, + intent: String, + env: Option, + status: String, + started_at: String, + finished_at: Option, + exit_code: Option, + summary: Option, + error: Option, + }, + AppendLog { + run_id: String, + line: SmartIdeRunLogLine, + }, +} + +#[derive(Clone)] +pub struct SmartIdeData { + pub active_tab: RwSignal, + pub selected_run_id: RwSignal>, + pub runs: RwSignal>, + tx: Sender, +} + +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::::new()); + + let (tx, rx) = channel::(); + 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 { + 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, + payload: Option, + ) { + 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, Option)> = + 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) } };