diff --git a/nebula_app/src/ai_providers.rs b/nebula_app/src/ai_providers.rs new file mode 100644 index 0000000..9fbc97f --- /dev/null +++ b/nebula_app/src/ai_providers.rs @@ -0,0 +1,454 @@ +//! AI provider metadata and credential references. +//! +//! Provider metadata is deliberately kept separate from `nebula_settings.txt`: +//! the latter is a user-editable runtime file, while provider credentials need +//! a stable identity and must never be serialized beside ordinary settings. + +use std::io; +use std::path::PathBuf; +use std::time::{Duration, Instant}; + +use serde::{Deserialize, Serialize}; +use zeroize::Zeroizing; + +use crate::event::{Event, EventType}; + +const STORE_FILE: &str = "nebula_providers.json"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ProviderKind { + OpenAi, + Anthropic, + Google, + Ollama, + OpenRouter, + Qwen, + DeepSeek, + Kimi, + Zhipu, + Doubao, + Mimo, + AzureOpenAi, + Custom, +} + +impl ProviderKind { + pub const PRESETS: [Self; 13] = [ + Self::OpenAi, + Self::Anthropic, + Self::Google, + Self::Ollama, + Self::OpenRouter, + Self::Qwen, + Self::DeepSeek, + Self::Kimi, + Self::Zhipu, + Self::Doubao, + Self::Mimo, + Self::AzureOpenAi, + Self::Custom, + ]; + + pub fn label(self) -> &'static str { + match self { + Self::OpenAi => "OpenAI", + Self::Anthropic => "Anthropic", + Self::Google => "Google", + Self::Ollama => "Ollama", + Self::OpenRouter => "OpenRouter", + Self::Qwen => "Qwen", + Self::DeepSeek => "DeepSeek", + Self::Kimi => "Kimi", + Self::Zhipu => "Zhipu", + Self::Doubao => "Doubao", + Self::Mimo => "Xiaomi MiMo", + Self::AzureOpenAi => "Azure OpenAI", + Self::Custom => "OpenAI Compatible", + } + } + + pub fn default_base_url(self) -> &'static str { + match self { + Self::OpenAi => "https://api.openai.com/v1", + Self::Anthropic => "https://api.anthropic.com/v1", + Self::Google => "https://generativelanguage.googleapis.com/v1beta", + Self::Ollama => "http://localhost:11434/v1", + Self::OpenRouter => "https://openrouter.ai/api/v1", + Self::Qwen => "https://dashscope.aliyuncs.com/compatible-mode/v1", + Self::DeepSeek => "https://api.deepseek.com/v1", + Self::Kimi => "https://api.moonshot.ai/v1", + Self::Zhipu => "https://open.bigmodel.cn/api/paas/v4", + Self::Doubao => "https://ark.cn-beijing.volces.com/api/v3", + Self::Mimo => "https://api.xiaomimimo.com/v1", + Self::AzureOpenAi => "https://{resource}.openai.azure.com/openai/deployments", + Self::Custom => "", + } + } + + pub fn default_model(self) -> &'static str { + match self { + Self::OpenAi => "gpt-5.4-mini", + Self::Anthropic => "claude-sonnet-4-5", + Self::Google => "gemini-2.5-flash", + Self::Ollama => "qwen3", + Self::OpenRouter => "openai/gpt-5.4-mini", + Self::Qwen => "qwen3.7-plus", + Self::DeepSeek => "deepseek-chat", + Self::Kimi => "kimi-k2.6", + Self::Zhipu => "glm-5.1", + Self::Doubao => "doubao-seed-2-0-pro-260215", + Self::Mimo => "mimo-v2.5-pro", + Self::AzureOpenAi => "gpt-4o", + Self::Custom => "", + } + } + + pub fn requires_api_key(self) -> bool { + !matches!(self, Self::Ollama) + } + + pub fn uses_openai_protocol(self) -> bool { + !matches!(self, Self::Anthropic | Self::Google) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AiProvider { + pub id: String, + pub name: String, + #[serde(default)] + pub note: String, + #[serde(default)] + pub website_url: String, + pub kind: ProviderKind, + pub base_url: String, + pub model: String, + #[serde(default)] + pub enabled: bool, + /// A credential reference only. The actual key is held by the OS store. + #[serde(default)] + pub api_key_set: bool, + #[serde(default)] + pub api_key_hint: String, + #[serde(default)] + pub full_url: bool, + /// Optional Codex feature flags applied only by the explicit Codex action. + #[serde(default)] + pub codex_goals: bool, + #[serde(default)] + pub codex_remote_compaction: bool, +} + +impl AiProvider { + pub fn preset(kind: ProviderKind, id: impl Into) -> Self { + let kind = kind; + Self { + id: id.into(), + name: kind.label().to_owned(), + note: String::new(), + website_url: String::new(), + kind, + base_url: kind.default_base_url().to_owned(), + model: kind.default_model().to_owned(), + enabled: true, + api_key_set: false, + api_key_hint: String::new(), + full_url: false, + codex_goals: false, + codex_remote_compaction: false, + } + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProviderStore { + #[serde(default)] + pub active_id: String, + #[serde(default)] + pub providers: Vec, +} + +#[derive(Debug, Clone)] +pub struct ProviderTestRequest { + pub request_id: u64, + pub provider: AiProvider, +} + +pub fn store_path() -> PathBuf { + crate::display::nebula_data_dir().join(STORE_FILE) +} + +pub fn load() -> ProviderStore { + let mut store = std::fs::read_to_string(store_path()) + .ok() + .and_then(|text| serde_json::from_str(&text).ok()) + .unwrap_or_default(); + normalize(&mut store); + store +} + +/// Older builds only materialized a preset after it was clicked. Keep those +/// records intact, but fill the missing presets so the visible list can be a +/// real, index-addressable collection and multiple custom entries remain +/// independently editable. +fn normalize(store: &mut ProviderStore) { + let mut existing = std::mem::take(&mut store.providers); + let mut ordered = Vec::with_capacity(ProviderKind::PRESETS.len().max(existing.len())); + for kind in ProviderKind::PRESETS { + if kind == ProviderKind::Custom { + continue; + } + if let Some(index) = existing.iter().position(|provider| provider.kind == kind) { + ordered.push(existing.remove(index)); + } else { + ordered.push(AiProvider::preset(kind, preset_id(kind))); + } + } + let had_custom = existing.iter().any(|provider| provider.kind == ProviderKind::Custom); + ordered.extend(existing.into_iter().filter(|provider| provider.kind == ProviderKind::Custom)); + if !had_custom { + ordered.push(AiProvider::preset(ProviderKind::Custom, preset_id(ProviderKind::Custom))); + } + store.providers = ordered; + if !store.providers.iter().any(|provider| provider.id == store.active_id) { + store.active_id = + store.providers.first().map(|provider| provider.id.clone()).unwrap_or_default(); + } +} + +pub fn preset_id(kind: ProviderKind) -> String { + format!("preset-{}", kind.label().to_ascii_lowercase().replace(' ', "-")) +} + +pub fn next_custom_id(store: &ProviderStore) -> String { + let mut sequence = 1usize; + loop { + let id = format!("custom-{sequence}"); + if !store.providers.iter().any(|provider| provider.id == id) { + return id; + } + sequence += 1; + } +} + +pub fn active_enabled(store: &ProviderStore) -> Option<&AiProvider> { + store.providers.iter().find(|provider| provider.id == store.active_id && provider.enabled) +} + +pub fn save(store: &ProviderStore) -> io::Result<()> { + let path = store_path(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let text = serde_json::to_string_pretty(store) + .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?; + std::fs::write(path, text) +} + +pub fn credential_target(id: &str) -> String { + format!("Nebula/AI/{id}") +} + +pub fn api_key_hint(key: &str) -> String { + let key = key.trim(); + if key.is_empty() { + return String::new(); + } + let tail: String = key.chars().rev().take(4).collect::().chars().rev().collect(); + format!("••••{tail}") +} + +pub fn save_api_key(id: &str, key: &str) -> io::Result { + let key = key.trim(); + if key.is_empty() { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "API key is empty")); + } + #[cfg(windows)] + crate::ssh_credentials::store_generic_secret(&credential_target(id), key.as_bytes())?; + #[cfg(not(windows))] + return Err(io::Error::new( + io::ErrorKind::Unsupported, + "system credential storage is not available on this build", + )); + Ok(api_key_hint(key)) +} + +pub fn delete_api_key(id: &str) -> io::Result<()> { + #[cfg(windows)] + crate::ssh_credentials::delete_generic_secret(&credential_target(id))?; + #[cfg(not(windows))] + return Err(io::Error::new( + io::ErrorKind::Unsupported, + "system credential storage is not available on this build", + )); + Ok(()) +} + +pub fn load_api_key(id: &str) -> io::Result>> { + #[cfg(windows)] + return crate::ssh_credentials::load_generic_secret(&credential_target(id)); + #[cfg(not(windows))] + { + let _ = id; + Ok(None) + } +} + +fn test_url(provider: &AiProvider) -> Result { + let base = provider.base_url.trim().trim_end_matches('/'); + if !(base.starts_with("https://") || base.starts_with("http://")) { + return Err("请求地址必须以 http:// 或 https:// 开头".to_owned()); + } + if provider.full_url { + return Ok(base.to_owned()); + } + Ok(match provider.kind { + ProviderKind::AzureOpenAi => { + let resource = base.strip_suffix("/openai/deployments").unwrap_or(base); + format!("{resource}/openai/models?api-version=2024-10-21") + }, + _ => format!("{base}/models"), + }) +} + +fn sanitized_error(error: &ureq::Error) -> String { + let message = match error { + ureq::Error::Timeout(_) => "连接超时".to_owned(), + ureq::Error::HostNotFound => "找不到服务器".to_owned(), + ureq::Error::ConnectionFailed => "无法连接服务器".to_owned(), + ureq::Error::Io(err) => format!("网络错误:{}", err.kind()), + ureq::Error::Tls(_) => "TLS 握手失败".to_owned(), + _ => "请求失败".to_owned(), + }; + message.chars().take(180).collect() +} + +fn run_test(provider: &AiProvider) -> (bool, String, u64) { + let started = Instant::now(); + let elapsed = || started.elapsed().as_millis().min(u64::MAX as u128) as u64; + let url = match test_url(provider) { + Ok(url) => url, + Err(message) => return (false, message, elapsed()), + }; + if provider.model.trim().is_empty() { + return (false, "请先填写默认模型".to_owned(), elapsed()); + } + let key = if provider.kind.requires_api_key() { + let secret = match load_api_key(&provider.id) { + Ok(Some(secret)) if !secret.is_empty() => secret, + Ok(_) => return (false, "请先保存 API Key".to_owned(), elapsed()), + Err(_) => return (false, "无法从凭据管理器读取 API Key".to_owned(), elapsed()), + }; + match String::from_utf8(secret) { + Ok(key) => Zeroizing::new(key), + Err(_) => return (false, "凭据管理器中的 API Key 编码无效".to_owned(), elapsed()), + } + } else { + Zeroizing::new(String::new()) + }; + let config = ureq::config::Config::builder() + .timeout_global(Some(Duration::from_secs(12))) + .http_status_as_error(false) + .build(); + let agent: ureq::Agent = config.new_agent(); + let mut request = agent.get(&url); + request = match provider.kind { + ProviderKind::Anthropic => { + request.header("x-api-key", key.as_str()).header("anthropic-version", "2023-06-01") + }, + ProviderKind::Google => request.header("x-goog-api-key", key.as_str()), + ProviderKind::AzureOpenAi => request.header("api-key", key.as_str()), + _ if key.is_empty() => request, + _ => { + let bearer = Zeroizing::new(format!("Bearer {}", key.as_str())); + request.header("Authorization", bearer.as_str()) + }, + }; + let response = match request.call() { + Ok(response) => response, + Err(error) => return (false, sanitized_error(&error), elapsed()), + }; + let status = response.status().as_u16(); + let (ok, message) = match status { + 200..=299 => (true, format!("连接成功(HTTP {status})")), + 401 | 403 => (false, format!("鉴权失败(HTTP {status}),请检查 API Key")), + 404 => (false, "接口不存在(HTTP 404),请检查请求地址".to_owned()), + 429 => (false, "服务限流(HTTP 429),连接可达但当前无法验证额度".to_owned()), + _ => (false, format!("服务返回 HTTP {status}")), + }; + (ok, message, elapsed()) +} + +pub fn spawn_test( + request: ProviderTestRequest, + proxy: winit::event_loop::EventLoopProxy, + window_id: winit::window::WindowId, +) -> io::Result<()> { + std::thread::Builder::new() + .name("nebula-provider-test".into()) + .spawn(move || { + let provider_id = request.provider.id.clone(); + let (ok, message, elapsed_ms) = run_test(&request.provider); + let _ = proxy.send_event(Event::new( + EventType::ProviderTestDone { + request_id: request.request_id, + provider_id, + ok, + message, + elapsed_ms, + }, + window_id, + )); + }) + .map(|_| ()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn presets_have_safe_defaults() { + for kind in ProviderKind::PRESETS { + let provider = AiProvider::preset(kind, "test"); + if kind != ProviderKind::Custom { + assert!(!provider.base_url.is_empty()); + } + assert!(!provider.api_key_set); + } + } + + #[test] + fn key_hint_never_contains_the_full_secret() { + let hint = api_key_hint("sk-this-is-a-long-secret"); + assert_eq!(hint, "••••cret"); + assert!(!hint.contains("long-secret")); + } + + #[test] + fn normalization_preserves_multiple_custom_entries() { + let mut store = ProviderStore { + active_id: "custom-2".to_owned(), + providers: vec![ + AiProvider::preset(ProviderKind::Custom, "custom-1"), + AiProvider::preset(ProviderKind::Custom, "custom-2"), + ], + }; + normalize(&mut store); + assert_eq!( + store.providers.iter().filter(|provider| provider.kind == ProviderKind::Custom).count(), + 2 + ); + assert_eq!(store.active_id, "custom-2"); + assert_eq!(next_custom_id(&store), "custom-3"); + } + + #[test] + fn provider_test_urls_follow_each_protocol() { + let openai = AiProvider::preset(ProviderKind::OpenAi, "openai"); + assert_eq!(test_url(&openai).unwrap(), "https://api.openai.com/v1/models"); + let azure = AiProvider::preset(ProviderKind::AzureOpenAi, "azure"); + assert!(test_url(&azure).unwrap().contains("/openai/models?api-version=")); + } +} diff --git a/nebula_app/src/codex_config.rs b/nebula_app/src/codex_config.rs new file mode 100644 index 0000000..a7ed2b1 --- /dev/null +++ b/nebula_app/src/codex_config.rs @@ -0,0 +1,167 @@ +//! Explicit export of one Nebula provider to Codex CLI configuration. +//! +//! Nebula normally keeps API keys in the Windows Credential Manager. Codex's +//! file auth mode consumes `OPENAI_API_KEY` from `auth.json`, so this module is +//! called only after a second user confirmation and always backs up both live +//! files before replacing them. + +use std::ffi::OsString; +use std::io; +use std::path::{Path, PathBuf}; + +use serde_json::{Map, Value as JsonValue}; +use toml_edit::{DocumentMut, value}; +use zeroize::Zeroizing; + +use crate::ai_providers::AiProvider; + +pub fn apply_provider(provider: &AiProvider) -> Result { + if !provider.kind.uses_openai_protocol() { + return Err("Codex 需要 Responses/OpenAI 兼容供应商".to_owned()); + } + let secret = crate::ai_providers::load_api_key(&provider.id) + .map_err(|_| "无法从凭据管理器读取 API Key".to_owned())? + .ok_or_else(|| "请先保存 API Key".to_owned())?; + let key = Zeroizing::new( + String::from_utf8(secret).map_err(|_| "凭据管理器中的 API Key 编码无效".to_owned())?, + ); + let home = codex_home().ok_or_else(|| "无法确定 Codex 配置目录".to_owned())?; + apply_to_dir(&home, provider, key.as_str()).map_err(|error| error.to_string())?; + Ok(home) +} + +fn codex_home() -> Option { + std::env::var_os("CODEX_HOME").map(PathBuf::from).or_else(|| { + std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }) + .map(PathBuf::from) + .map(|home| home.join(".codex")) + }) +} + +fn provider_key(provider: &AiProvider) -> String { + let mut key = String::from("nebula_"); + for character in provider.id.chars() { + if character.is_ascii_alphanumeric() { + key.push(character.to_ascii_lowercase()); + } else if !key.ends_with('_') { + key.push('_'); + } + } + key.trim_end_matches('_').to_owned() +} + +fn backup_path(path: &Path) -> PathBuf { + let mut name: OsString = path.as_os_str().to_owned(); + name.push(".nebula.bak"); + PathBuf::from(name) +} + +fn backup(path: &Path) -> io::Result<()> { + if path.exists() { + std::fs::copy(path, backup_path(path))?; + } + Ok(()) +} + +fn restore(path: &Path, old: Option<&[u8]>) { + match old { + Some(bytes) => { + let _ = std::fs::write(path, bytes); + }, + None => { + let _ = std::fs::remove_file(path); + }, + } +} + +fn apply_to_dir(home: &Path, provider: &AiProvider, api_key: &str) -> io::Result<()> { + std::fs::create_dir_all(home)?; + let auth_path = home.join("auth.json"); + let config_path = home.join("config.toml"); + let old_auth = std::fs::read(&auth_path).ok(); + let old_config = std::fs::read(&config_path).ok(); + backup(&auth_path)?; + backup(&config_path)?; + + let mut auth = match old_auth.as_deref() { + Some(bytes) => serde_json::from_slice::(bytes) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?, + None => JsonValue::Object(Map::new()), + }; + let object = auth.as_object_mut().ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidData, "Codex auth.json 顶层必须是 JSON 对象") + })?; + object.insert("OPENAI_API_KEY".to_owned(), JsonValue::String(api_key.to_owned())); + let auth_text = Zeroizing::new( + serde_json::to_string_pretty(&auth) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?, + ); + + let config_text = match old_config.as_deref() { + Some(bytes) => std::str::from_utf8(bytes) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?, + None => "", + }; + let mut document = if config_text.trim().is_empty() { + DocumentMut::new() + } else { + config_text + .parse::() + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))? + }; + let provider_key = provider_key(provider); + document["model_provider"] = value(provider_key.clone()); + document["model"] = value(provider.model.trim()); + document["model_providers"][provider_key.as_str()]["name"] = value(provider.name.trim()); + document["model_providers"][provider_key.as_str()]["base_url"] = + value(provider.base_url.trim_end_matches('/')); + document["model_providers"][provider_key.as_str()]["wire_api"] = value("responses"); + document["model_providers"][provider_key.as_str()]["env_key"] = value("OPENAI_API_KEY"); + document["model_providers"][provider_key.as_str()]["requires_openai_auth"] = value(true); + document["features"]["goals"] = value(provider.codex_goals); + document["features"]["remote_compaction_v2"] = value(provider.codex_remote_compaction); + + std::fs::write(&auth_path, auth_text.as_bytes())?; + if let Err(error) = std::fs::write(&config_path, document.to_string().as_bytes()) { + restore(&auth_path, old_auth.as_deref()); + restore(&config_path, old_config.as_deref()); + return Err(error); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ai_providers::ProviderKind; + + #[test] + fn apply_preserves_unknown_config_and_auth_fields() { + let temp = tempfile::tempdir().unwrap(); + std::fs::write( + temp.path().join("config.toml"), + "# keep this comment\nunknown_setting = 7\n\n[features]\napps = true\n", + ) + .unwrap(); + std::fs::write(temp.path().join("auth.json"), r#"{"tokens":{"access_token":"keep"}}"#) + .unwrap(); + let mut provider = AiProvider::preset(ProviderKind::Custom, "custom-7"); + provider.name = "Example".to_owned(); + provider.base_url = "https://example.test/v1".to_owned(); + provider.model = "gpt-test".to_owned(); + provider.codex_goals = true; + apply_to_dir(temp.path(), &provider, "sk-secret").unwrap(); + + let config = std::fs::read_to_string(temp.path().join("config.toml")).unwrap(); + assert!(config.contains("# keep this comment")); + assert!(config.contains("unknown_setting = 7")); + assert!(config.contains("apps = true")); + assert!(config.contains("goals = true")); + let auth: JsonValue = + serde_json::from_slice(&std::fs::read(temp.path().join("auth.json")).unwrap()).unwrap(); + assert_eq!(auth["OPENAI_API_KEY"], "sk-secret"); + assert_eq!(auth["tokens"]["access_token"], "keep"); + assert!(backup_path(&temp.path().join("config.toml")).exists()); + assert!(backup_path(&temp.path().join("auth.json")).exists()); + } +} diff --git a/nebula_app/src/display/context_menu.rs b/nebula_app/src/display/context_menu.rs index 7d0ec26..3da99e0 100644 --- a/nebula_app/src/display/context_menu.rs +++ b/nebula_app/src/display/context_menu.rs @@ -4,6 +4,7 @@ //! hit-testing and animation live together here so pointer targets cannot drift //! away from the pixels the user sees at non-integer DPI scales. +use super::ui::keycap; use super::ui::surface::{self, fade as alpha}; use super::ui::tokens::{control, space}; use super::*; @@ -469,7 +470,11 @@ pub(super) fn hit_test( return ContextMenuHit::Action(*action); } } - if contains(layout.panel, x, y) { ContextMenuHit::Panel } else { ContextMenuHit::Outside } + if contains(layout.panel, x, y) { + ContextMenuHit::Panel + } else { + ContextMenuHit::Outside + } } fn fade_ink(base: Rgba, ink: Rgb, opacity: f32) -> Rgb { @@ -554,6 +559,20 @@ pub(super) fn draw(display: &mut Display) { alpha(Rgba::new(swatch.r, swatch.g, swatch.b, 255), progress), )); } + // 右键菜单和命令面板共用同一颗键帽组件;快捷键不再是贴在右缘的一串 + // 灰字,独立小键帽给出清晰的可按暗示,也让两类浮层形成同一套视觉语法。 + for row in &layout.rows { + if !row.hint.is_empty() { + let combo = keycap::layout_combo( + row.hint, + row.rect.0 + row.rect.2 - s(10.0), + row.rect.1 + row.rect.3 * 0.5, + size.cell_width(), + scale, + ); + keycap::push_combo_with_progress(&mut quads, &sk, &combo, scale, progress); + } + } display.renderer.draw_ui(&size, &quads); // Text has no alpha channel in the glyph pipeline, so fade it toward the @@ -581,15 +600,27 @@ pub(super) fn draw(display: &mut Display) { &mut display.glyph_cache, ); if !row.hint.is_empty() { - let hint_w = row.hint.chars().count() as f32 * size.cell_width(); - display.renderer.draw_chrome_text( - &size, - row.rect.0 + row.rect.2 - hint_w - s(10.0), - y, - dim, + let combo = keycap::layout_combo( row.hint, - &mut display.glyph_cache, + row.rect.0 + row.rect.2 - s(10.0), + row.rect.1 + row.rect.3 * 0.5, + size.cell_width(), + scale, ); + let (_, key_y, _, key_h) = combo.bounds; + let key_text_y = key_y + (key_h - cell_h) * 0.5; + for (chip_x, chip_w, key) in &combo.chips { + let key_w = key.chars().map(|c| c.width().unwrap_or(0)).sum::() as f32 + * size.cell_width(); + display.renderer.draw_chrome_text( + &size, + chip_x + (chip_w - key_w) * 0.5, + key_text_y, + dim, + key, + &mut display.glyph_cache, + ); + } } } if let Some((x, y)) = layout.color_label { diff --git a/nebula_app/src/display/mod.rs b/nebula_app/src/display/mod.rs index bf9528f..b1c5147 100644 --- a/nebula_app/src/display/mod.rs +++ b/nebula_app/src/display/mod.rs @@ -4229,8 +4229,7 @@ impl Display { // 多级 fallback 列表(issue #33)在目录里以主族身份参与匹配与高亮; // 链本身仍原样保存在设置值里。 let primary_family = - crate::renderer::text::glyph_cache::primary_font_family(&self.nebula_font_family) - .to_owned(); + crate::renderer::primary_font_family(&self.nebula_font_family).to_owned(); let catalog = crate::font_install::font_catalog( &system, &imported, @@ -4591,12 +4590,11 @@ impl Display { // 字体选择器只换主族;用户手写的多级 fallback 链(逗号分隔, // issue #33)原样保留在新值后面。 let family = { - let rest: Vec<&str> = - crate::renderer::text::glyph_cache::split_font_families(&self.nebula_font_family) - .into_iter() - .skip(1) - .filter(|fallback| *fallback != family) - .collect(); + let rest: Vec<&str> = crate::renderer::split_font_families(&self.nebula_font_family) + .into_iter() + .skip(1) + .filter(|fallback| *fallback != family) + .collect(); if rest.is_empty() { family } else { format!("{family}, {}", rest.join(", ")) } }; self.nebula_font_family = family; diff --git a/nebula_app/src/display/settings.rs b/nebula_app/src/display/settings.rs index 338ae7f..a4d8dfc 100644 --- a/nebula_app/src/display/settings.rs +++ b/nebula_app/src/display/settings.rs @@ -1853,12 +1853,13 @@ pub(crate) fn opacity_from_pointer(pointer_x: f32, slider: (f32, f32, f32, f32)) ((pointer_x - slider.0) / slider.2.max(1.0)).clamp(0.0, 1.0) } -/// 主机行右缘的三枚方形图标槽(连接 / 编辑 / 隐藏)。绘制与命中共用这一处 -/// 推导,是"看得见的按钮点不中"的唯一防线。 +/// 主机行右缘的动作槽。绘制与命中共用这一处推导,是"看得见的按钮点不中"的 +/// 唯一防线。 /// -/// 2026-08-11:从 54px 宽的描边文字按钮改成方形图标槽。文字按钮常态占掉右侧 -/// 约 180px,把主机名挤到一半宽;图标槽只在 hover 时显形,静态那一行只剩身份 -/// 信息。命中区保持正方且比墨迹宽,指针容差不被视觉尺寸绑死。 +/// 2026-08-11 对齐原型:**主动作「连接」是文字按钮**(白底+描边+"连接"二字), +/// 编辑/隐藏是方形图标槽。原型里唯一带底的就是主动作——一行五个等价图标会让 +/// 人逐个悬停去猜哪个是"进去",文字消掉这次猜测。三个槽都只在 hover 时显形, +/// 静态那一行只剩身份信息。命中区比墨迹宽,指针容差不被视觉尺寸绑死。 fn ssh_host_action_rect( row: (f32, f32, f32, f32), scale: f32, @@ -1866,13 +1867,26 @@ fn ssh_host_action_rect( ) -> (f32, f32, f32, f32) { let s = |v: f32| v * scale; let (x, y, w, h) = row; - let gap = s(2.0); - let right = x + w - s(12.0); - let slot = s(28.0).min((h - s(10.0)).max(s(20.0))); - let bx = right - - slot * (3usize.saturating_sub(action) as f32) - - gap * (2usize.saturating_sub(action) as f32); - (bx, widgets::centered_y(y, h, slot), slot, slot) + let gap = s(4.0); + let right = x + w - s(14.0); + let slot = s(26.0).min((h - s(10.0)).max(s(18.0))); + let connect_w = s(52.0); + // 从右往左排:隐藏、编辑、连接。连接最宽,所以单独算。 + match action { + 2 => { + let bx = right - slot; + (bx, widgets::centered_y(y, h, slot), slot, slot) + }, + 1 => { + let bx = right - slot * 2.0 - gap; + (bx, widgets::centered_y(y, h, slot), slot, slot) + }, + _ => { + let bh = s(26.0).min((h - s(10.0)).max(s(18.0))); + let bx = right - slot * 2.0 - gap * 2.0 - connect_w; + (bx, widgets::centered_y(y, h, bh), connect_w, bh) + }, + } } /// Compact trailing command button inside an otherwise non-clickable settings row. @@ -3218,8 +3232,7 @@ fn dropdown_selected_index(view: &SettingsView, dropdown: SettingsDropdown) -> O // 加一:弹层第 0 行是搜索框,候选整体下移一行。 SettingsDropdown::Font => { // 多级 fallback 列表按主族高亮(issue #33)。 - let primary = - crate::renderer::text::glyph_cache::primary_font_family(&view.font_family); + let primary = crate::renderer::primary_font_family(&view.font_family); view.fonts.iter().position(|family| family == primary).map(|slot| slot + 1) }, SettingsDropdown::BackgroundFit => { @@ -4009,6 +4022,10 @@ pub(super) fn push_quads( // 2026-08-11 用户裁定:常态不画卡片底。原来每行都铺 accent_soft, // 五行灰块叠起来就是「灰蒙蒙」的来源。底色只在 hover 时出现, // 静态列表回到纯净的白底 + 文字层级。 + // + // hover 底用 `surface`(8%)而不是 `hover`(13%):整行是个很大 + // 的面,同一个 alpha 铺在 26px 的图标槽上是"轻轻一提",铺满一 + // 整行就成了灰条。大面积要更淡,小面积才允许更重。 let row_hovered = matches!( view.hover, SettingsHit::SshHostRow(i) @@ -4020,7 +4037,7 @@ pub(super) fn push_quads( if row_hovered { clip( quads, - UiQuad::solid(rx, ry, rw, rh, s(tokens::radius::OVERLAY), sk.hover), + UiQuad::solid(rx, ry, rw, rh, s(tokens::radius::OVERLAY), sk.surface), ); } // 行左缘画主机的 OS 图标(文字 pass,os_icons 字形),不再是 @@ -4028,21 +4045,26 @@ pub(super) fn push_quads( // 主机模型依旧没有可靠的在线态,图标标注的是「这是哪台机器」, // 不发明绿点。 // - // 三枚动作图标同样只在 hover 时显形,且不再套描边方框——方框 - // 加文字常态吃掉右侧 180px,把主机名挤到一半宽。命中区始终存在 - // (见 ssh_host_action_rect),只是墨迹不画。 + // 右缘动作整组只在 hover 时显形。命中区始终存在(见 + // ssh_host_action_rect),只是墨迹不画。 if row_hovered { - // 眼睛图标要挖空,挖空色必须是这一枚图标脚下的实际底色, - // 否则半透明的 hover washes 叠起来会在瞳孔周围留一圈脏边。 - let row_bg = surface::over(sk.hover, sk.panel); - for (action, icon) in [ - (0usize, icons::RowActionIcon::Connect), - (1usize, icons::RowActionIcon::Edit), - (2usize, icons::RowActionIcon::Hide), - ] { + let row_bg = surface::over(sk.surface, sk.panel); + // 主动作「连接」:白底 + 描边的真按钮,是这一行唯一带底的 + // 东西,也是原型里唯一带底的那个。 + let connect = ssh_host_action_rect(row, scale, 0); + let connect_hot = view.hover == SettingsHit::SshHostConnect(index); + let mut button = Vec::new(); + widgets::push_outline_button(&mut button, connect, scale, &sk, connect_hot); + for quad in button { + clip(quads, quad); + } + // 次级动作用图标,挖空色取图标脚下的实际底色——半透明的 + // hover wash 叠起来若用错基色,眼睛的瞳孔周围会留一圈脏边。 + for (action, icon) in + [(1usize, icons::RowActionIcon::Edit), (2usize, icons::RowActionIcon::Hide)] + { let rect = ssh_host_action_rect(row, scale, action); let icon_hovered = match (action, view.hover) { - (0, SettingsHit::SshHostConnect(i)) => i == index, (1, SettingsHit::SshHostEdit(i)) => i == index, (2, SettingsHit::SshHostHide(i)) => i == index, _ => false, @@ -4056,22 +4078,15 @@ pub(super) fn push_quads( rect.2, rect.3, s(tokens::radius::CONTROL), - sk.hover_strong, + sk.hover, ), ); - (sk.ink.into(), surface::over(sk.hover_strong, row_bg)) + (Rgba::opaque(sk.ink), surface::over(sk.hover, row_bg)) } else { - (sk.ink_dim.into(), row_bg) + (Rgba::opaque(sk.ink_faint), row_bg) }; let mut ink_quads = Vec::new(); - icons::push_row_action_icon( - &mut ink_quads, - icon, - rect, - scale, - ink, - cutout, - ); + icons::push_row_action_icon(&mut ink_quads, icon, rect, scale, ink, cutout); for quad in ink_quads { clip(quads, quad); } @@ -6358,15 +6373,6 @@ pub(super) fn draw_text( gc, ); } - let draw_centered_action = |r: &mut Renderer, - gc: &mut GlyphCache, - rect: (f32, f32, f32, f32), - label: &str, - ink: Rgb| { - let cols = label.chars().map(|ch| ch.width().unwrap_or(1)).sum::(); - let tx = rect.0 + (rect.2 - cols as f32 * cell_w) * 0.5; - r.draw_chrome_text(size, tx, rect.1 + (rect.3 - cell_h) / 2.0, ink, label, gc); - }; for (index, host) in view.ssh_hosts.iter().enumerate() { let row = ( host_x, @@ -6410,8 +6416,34 @@ pub(super) fn draw_text( if host.pinned { r.draw_chrome_text(size, row.0 + s(27.0), title_y, sk.accent, "\u{eab4}", gc); } - // 三枚动作图标改由 quad pass 绘制(icons::push_row_action_icon), - // 文字 pass 这里不再画「连接 / 编辑 / 隐藏」三个词。 + // 编辑 / 隐藏是纯图标(quad pass 画),主动作「连接」保留文字: + // 一行全是等价图标会逼人逐个悬停去猜哪个是"进去"。整组同样只在 + // hover 时显形。 + let row_hovered = matches!( + view.hover, + SettingsHit::SshHostRow(i) + | SettingsHit::SshHostConnect(i) + | SettingsHit::SshHostEdit(i) + | SettingsHit::SshHostHide(i) + if i == index + ); + if row_hovered { + let connect = ssh_host_action_rect(row, scale, 0); + let label = language.pick("连接", "Connect"); + let cols = label.chars().map(|ch| ch.width().unwrap_or(1)).sum::(); + r.draw_chrome_text( + size, + connect.0 + (connect.2 - cols as f32 * cell_w) * 0.5, + widgets::centered_y(connect.1, connect.3, cell_h), + if view.hover == SettingsHit::SshHostConnect(index) { + sk.accent + } else { + sk.ink + }, + label, + gc, + ); + } } if visible(geometry.ssh_import_config.1, geometry.ssh_import_config.3) { row_label( diff --git a/nebula_app/src/display/ui/icons.rs b/nebula_app/src/display/ui/icons.rs index e01a1e6..6f4c817 100644 --- a/nebula_app/src/display/ui/icons.rs +++ b/nebula_app/src/display/ui/icons.rs @@ -335,13 +335,14 @@ pub(crate) fn push_check( ); } -/// 主机行右缘的动作图标:hover 才显形,替代原来三个描边文字按钮。 -/// 与设置导航图标同一套配方(线性圆润、真墨迹居中、整像素吸附), -/// 尺寸统一按 `rect` 短边推导,因此三枚在任何 DPI 下都等重。 +/// 主机行右缘的动作图标:hover 才显形。主动作「连接」是文字按钮(原型里 +/// 唯一带底的那个),这里只画次级动作——一行全是等价图标会逼人逐个悬停去 +/// 猜哪个是"进去"。 +/// +/// 与设置导航图标同一套配方:线性圆润、真墨迹居中、外沿整像素吸附,尺寸统 +/// 一按 `rect` 推导,因此几枚在任何 DPI 下都等重。 #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum RowActionIcon { - /// 连接:右向三角,播放/前往的通用语义。 - Connect, /// 编辑:斜置笔杆 + 笔尖。 Edit, /// 隐藏:眼睛加一道斜杠。 @@ -362,44 +363,26 @@ pub(crate) fn push_row_action_icon( } let cx = (x + width * 0.5).round(); let cy = (y + height * 0.5).round(); - let stroke = (1.3 * scale).max(1.0); + let stroke = (1.25 * scale).max(1.0); match icon { - // 实心三角比描边箭头更稳:这一枚尺寸最小,描边到 13px 见方时 - // 两条斜边会各自吃掉半像素,糊成灰块。 - RowActionIcon::Connect => { - let half_h = 4.4 * scale; - let reach = 4.0 * scale; - let left = cx - 2.6 * scale; - let steps = (half_h * 2.0).round().max(2.0) as i32; - for i in 0..steps { - let t = i as f32 / steps as f32; - let row_y = cy - half_h + t * half_h * 2.0; - // 三角在垂直中线处最宽,向上下两端线性收窄。 - let w = reach * (1.0 - (t - 0.5).abs() * 2.0); - if w <= 0.0 { - continue; - } - quads.push(UiQuad::solid(left, row_y.round(), w.max(1.0), 1.0, 0.0, ink)); - } - }, RowActionIcon::Edit => { - // 笔杆:从左下到右上的一道斜线,末端补一小段更粗的笔尖。 - let tip = (cx - 4.6 * scale, cy + 4.6 * scale); - let tail = (cx + 4.2 * scale, cy - 4.2 * scale); + // 笔杆:左下到右上一道斜线;笔尖再补一小段加粗,让它读成"笔" + // 而不是"斜杠"(斜杠已经被 Hide 占用,两枚不能撞形)。 + let tip = (cx - 4.4 * scale, cy + 4.4 * scale); + let tail = (cx + 4.4 * scale, cy - 4.4 * scale); push_segment(quads, tip, tail, stroke, ink); - // 笔尖的小三角:一个贴着 tip 的短粗段,让斜线读成"笔"而非"斜杠"。 push_segment( quads, - (tip.0, tip.1), - (tip.0 + 1.9 * scale, tip.1 - 1.9 * scale), - stroke * 2.0, + tip, + (tip.0 + 1.8 * scale, tip.1 - 1.8 * scale), + stroke * 2.1, ink, ); }, RowActionIcon::Hide => { - // 眼睛:外圈椭圆(用圆角矩形逼近)挖空 + 中心瞳孔,再压一道斜杠。 - let w = 11.0 * scale; - let h = 6.6 * scale; + // 眼睛:圆角胶囊挖空当外圈轮廓 + 中心瞳孔,再压一道斜杠。 + let w = 11.5 * scale; + let h = 7.0 * scale; let ex = (cx - w * 0.5).round(); let ey = (cy - h * 0.5).round(); quads.push(UiQuad::solid(ex, ey, w, h, h * 0.5, ink)); @@ -411,7 +394,7 @@ pub(crate) fn push_row_action_icon( (h * 0.5 - stroke).max(0.0), cutout, )); - let pupil = 3.0 * scale; + let pupil = 2.8 * scale; quads.push(UiQuad::solid( cx - pupil * 0.5, cy - pupil * 0.5, @@ -420,10 +403,10 @@ pub(crate) fn push_row_action_icon( pupil * 0.5, ink, )); - // 斜杠先用底色垫一条更宽的,避免与眼睛的墨迹糊在一起。 - let a = (cx - 5.4 * scale, cy + 5.4 * scale); - let b = (cx + 5.4 * scale, cy - 5.4 * scale); - push_segment(quads, a, b, stroke * 2.6, cutout); + // 斜杠先垫一条底色的更宽段,避免与眼睛墨迹糊成一团黑。 + let a = (cx - 5.6 * scale, cy + 5.6 * scale); + let b = (cx + 5.6 * scale, cy - 5.6 * scale); + push_segment(quads, a, b, stroke * 2.8, cutout); push_segment(quads, a, b, stroke, ink); }, } diff --git a/nebula_app/src/display/ui/keycap.rs b/nebula_app/src/display/ui/keycap.rs index d555583..a956c64 100644 --- a/nebula_app/src/display/ui/keycap.rs +++ b/nebula_app/src/display/ui/keycap.rs @@ -1,17 +1,8 @@ //! 键帽规范:所有展示键位的地方共用一颗 chip——hairline 圈边 + //! panel/surface 叠底、小圆角;不再各处自造键帽样式,也不叠加额外装饰。 //! -//! # 为什么是**一整串**而不是逐键拆分 -//! -//! 2026-07-29 用户裁定,推翻了本文件的上一版。逐键 chip 是 macOS 的形式: -//! 那里修饰键是 `⌘ ⇧ ⌥` 单字符图形,一符号一方块天然成立、宽度还整齐。 -//! Windows 上修饰键是 `Ctrl` `Shift` `Alt` 这种**单词**,拆开会得到一排 -//! 宽度参差的方块,画面反而更碎。 -//! -//! 更重要的是心智迁移:Windows 用户熟悉把组合键显示成 -//! `Ctrl+Shift+P` 一整串。沿用这套系统惯例,用户能直接复用既有识别习惯; -//! 自造形式则要他们先解析 -//! 一遍"这几个方块是一个组合还是三个选项"。 +//! 组合键按物理按键拆成相邻的小键帽。每颗键帽都有独立的表面、底边和 +//! 呼吸缝,长组合键因此仍然可扫读,不会变成一块写满文字的灰色胶囊。 //! //! `layout_combo` 是几何唯一来源:quad pass 与 text pass 各调一次拿到 //! 相同矩形(同 settings 键位行 quad/text 共用几何的既有约定)。 @@ -25,7 +16,7 @@ use crate::renderer::ui::UiQuad; /// Chip height in logical px(与 palette 底栏既有键帽一致)。 pub const KEY_H: f32 = 20.0; -/// chip 内的左右呼吸缝。整串比单键长,边距太小会显得字挤在框上。 +/// 每颗键帽内的左右呼吸缝。单键也要留出足够边距,避免文字贴住轮廓。 const PAD_X: f32 = 7.0; fn cols(text: &str) -> usize { @@ -42,8 +33,8 @@ pub struct ComboChips { pub bounds: (f32, f32, f32, f32), } -/// Lay out a `Ctrl+Shift+P` style combo as one chip, right-aligned so it ends -/// at `right_x`, vertically centered on `center_y`. +/// Lay out a `Ctrl+Shift+P` style combo as separate keycaps, right-aligned so +/// the final cap ends at `right_x`, vertically centered on `center_y`. pub fn layout_combo( combo: &str, right_x: f32, @@ -54,12 +45,24 @@ pub fn layout_combo( let s = |v: f32| v * scale; let key_h = s(KEY_H); let key_y = center_y - key_h / 2.0; - // 规范化:丢掉空段后用 "+" 重新连接。畸形输入(末尾多一个 "+"、 - // 连续 "++")因此得到稳定结果,与上一版逐键实现的取舍保持一致。 - let label = combo.split('+').filter(|k| !k.is_empty()).collect::>().join("+"); - let width = cols(&label) as f32 * cell_w + s(PAD_X) * 2.0; - let x = right_x - width; - ComboChips { chips: vec![(x, width, label)], bounds: (x, key_y, width, key_h) } + // 丢掉空段,避免配置里的末尾/连续 `+` 生成空键帽;加号是键帽之间的 + // 视觉连接,不占任何一颗键帽的文字宽度。 + let labels: Vec<&str> = combo.split('+').filter(|key| !key.is_empty()).collect(); + let gap = s(4.0); + let widths: Vec = + labels.iter().map(|label| cols(label) as f32 * cell_w + s(PAD_X) * 2.0).collect(); + let total = widths.iter().sum::() + gap * widths.len().saturating_sub(1) as f32; + let mut x = right_x - total; + let chips = labels + .into_iter() + .zip(widths) + .map(|(label, width)| { + let chip = (x, width, label.to_owned()); + x += width + gap; + chip + }) + .collect(); + ComboChips { chips, bounds: (right_x - total, key_y, total, key_h) } } /// The one chip recipe: hairline ring + panel + surface, chip radius. @@ -117,6 +120,23 @@ pub fn push_combo(quads: &mut Vec, sk: &Skin, combo: &ComboChips, scale: push_combo_with_hover(quads, sk, combo, scale, false); } +/// Push a combo whose surface follows a transient overlay's alpha. Context +/// menus animate their panel and labels together; fading the three keycap +/// layers here prevents the shortcut caps from popping in a frame early. +pub fn push_combo_with_progress( + quads: &mut Vec, + sk: &Skin, + combo: &ComboChips, + scale: f32, + progress: f32, +) { + let mut faded = *sk; + faded.panel = super::surface::fade(faded.panel, progress); + faded.surface = super::surface::fade(faded.surface, progress); + faded.hairline = super::surface::fade(faded.hairline, progress); + push_combo(quads, &faded, combo, scale); +} + /// Push every chip of a laid-out combo, with a subtle hover color only. pub fn push_combo_with_hover( quads: &mut Vec, @@ -136,24 +156,26 @@ mod tests { use super::*; #[test] - fn combo_is_one_chip_carrying_the_whole_string() { - // Windows 心智:`Ctrl+K` 是一颗键帽,不是两颗方块加一个加号。 + fn combo_is_split_into_keycaps_and_right_aligned() { let combo = layout_combo("Ctrl+K", 500.0, 100.0, 10.0, 1.0); - assert_eq!(combo.chips.len(), 1); + assert_eq!(combo.chips.len(), 2); let (x, w, ref label) = combo.chips[0]; - assert_eq!(label, "Ctrl+K"); - assert!((x + w - 500.0).abs() < 0.01, "chip 右缘落在 right_x"); - assert_eq!(w, 6.0 * 10.0 + PAD_X * 2.0, "宽 = 整串列宽 + 左右呼吸缝"); + assert_eq!(label, "Ctrl"); + assert!((combo.chips[1].0 + combo.chips[1].1 - 500.0).abs() < 0.01); + assert!(combo.chips[1].0 > x + w, "键帽之间要有稳定呼吸缝"); let (bx, _, bw, bh) = combo.bounds; assert!((bx + bw - 500.0).abs() < 0.01); assert_eq!(bh, KEY_H); } #[test] - fn three_key_combo_stays_a_single_chip() { + fn three_key_combo_has_three_caps() { let combo = layout_combo("Ctrl+Shift+P", 400.0, 50.0, 8.0, 1.0); - assert_eq!(combo.chips.len(), 1); - assert_eq!(combo.chips[0].2, "Ctrl+Shift+P"); + assert_eq!(combo.chips.len(), 3); + assert_eq!( + combo.chips.iter().map(|chip| chip.2.as_str()).collect::>(), + ["Ctrl", "Shift", "P"] + ); } #[test] @@ -161,7 +183,7 @@ mod tests { // 末尾多一个 "+"、连续 "++" 都不该画出空键帽。 for raw in ["Ctrl+", "+Ctrl", "Ctrl++K"] { let combo = layout_combo(raw, 300.0, 50.0, 8.0, 1.0); - assert_eq!(combo.chips.len(), 1); + assert!(!combo.chips.is_empty()); assert!(!combo.chips[0].2.is_empty()); assert!(!combo.chips[0].2.ends_with('+')); } diff --git a/nebula_app/src/display/ui/theme.rs b/nebula_app/src/display/ui/theme.rs index 0463477..628bcf3 100644 --- a/nebula_app/src/display/ui/theme.rs +++ b/nebula_app/src/display/ui/theme.rs @@ -307,23 +307,18 @@ impl NebulaTheme { shell_bg: Rgb::new(34, 38, 48), }, // Cool silver — the light half of the steel pair. Chrome layers - // follow the premium-light sheet: sidebar over app-bg, terminal - // pure white for maximum contrast. + // follow the premium-light sheet: sidebar #f3f4f6 over app-bg + // #f9fafb, terminal pure white for maximum contrast. Self::SilverLight => NebulaPalette { // Neutral silver, blue removed: the panel/tab surfaces sit on a // true-neutral gray ramp (was Tailwind's blue-leaning gray-100), // and the active-tab halo is a soft neutral shadow instead of a // blue wash — a pure-white pill lifting off a flat gray gutter. - // - // 2026-08-11:整条灰阶上移约 7 级。原来外壳落在 gray-100 - // (243),与纯白终端差 12 级——这么大面积上的 12 级读出来 - // 不是"另一层表面",是蒙了一层灰。外壳提到 250 后卡片与 - // 沟槽的分离仍然看得出,但整窗回到"浅"而不是"灰"。 - panel: Rgba::new(252, 252, 253, 236), - pill: Rgba::new(241, 241, 243, 230), - tab_stroke_l: Rgba::new(208, 208, 211, 140), + panel: Rgba::new(245, 245, 246, 236), + pill: Rgba::new(233, 233, 234, 230), + tab_stroke_l: Rgba::new(198, 198, 200, 150), tab_bg_l: Rgba::new(255, 255, 255, 242), - tab_bg_r: Rgba::new(253, 253, 254, 236), + tab_bg_r: Rgba::new(250, 250, 251, 236), edge_l: Rgba::new(110, 112, 116, 170), edge_r: Rgba::new(118, 121, 126, 180), edge_glow_l: Rgba::new(118, 121, 126, 18), @@ -337,8 +332,9 @@ impl NebulaTheme { // Pure white terminal on every light theme (premium-light // sheet): highest contrast for the Primer ANSI ink set. term_bg: Rgb::new(255, 255, 255), - // 近白的银灰:白色终端卡片浮在它上面。 - shell_bg: Rgb::new(250, 250, 251), + // Premium-light app-bg layer (#f3f4f6-ish): the white terminal + // card floats on this neutral silver. + shell_bg: Rgb::new(243, 244, 246), }, // Warm limestone — the light half of the coal pair. Self::LimestoneLight => NebulaPalette { diff --git a/nebula_app/src/input/chrome.rs b/nebula_app/src/input/chrome.rs index fcfdbde..9e4fcd0 100644 --- a/nebula_app/src/input/chrome.rs +++ b/nebula_app/src/input/chrome.rs @@ -1083,6 +1083,9 @@ impl> Processor { self.ctx.mark_dirty(); return; }, + // 主机行本体只承载 hover(底色 + 右缘三枚图标显形),本身 + // 不是动作:点空白处不该悄悄连上一台机器。 + crate::display::SettingsHit::SshHostRow(_) => return, crate::display::SettingsHit::SshHostConnect(index) => { let host = self.ctx.display().nebula_ssh_hosts.get(index).cloned(); if let Some(host) = host { diff --git a/nebula_app/src/input/mouse.rs b/nebula_app/src/input/mouse.rs index 5a70f2d..dfce5cc 100644 --- a/nebula_app/src/input/mouse.rs +++ b/nebula_app/src/input/mouse.rs @@ -470,6 +470,7 @@ impl> Processor { | crate::display::SettingsHit::SshJumpHostDropdown | crate::display::SettingsHit::SshJumpHostOption(_) | crate::display::SettingsHit::SshProxyOverrideEdit(_) + | crate::display::SettingsHit::SshHostRow(_) | crate::display::SettingsHit::Reset => { self.ctx.window().set_mouse_cursor(CursorIcon::Pointer); return; diff --git a/nebula_app/src/renderer/mod.rs b/nebula_app/src/renderer/mod.rs index 80f29dc..9e46065 100644 --- a/nebula_app/src/renderer/mod.rs +++ b/nebula_app/src/renderer/mod.rs @@ -38,6 +38,7 @@ pub mod ui; #[cfg(feature = "wgpu-foundation")] pub mod wgpu_foundation; pub(crate) use text::Rasterizer; +pub(crate) use text::glyph_cache::{primary_font_family, split_font_families}; pub use text::{GlyphCache, LoaderApi}; diff --git a/nebula_app/src/runtime_api.rs b/nebula_app/src/runtime_api.rs new file mode 100644 index 0000000..13364d0 --- /dev/null +++ b/nebula_app/src/runtime_api.rs @@ -0,0 +1,1300 @@ +//! Versioned local control plane shared by Nebula's CLI, agents, and future plugins. +//! +//! The transport is loopback TCP plus a per-instance discovery token. Requests +//! are JSON Lines so clients in any language can use the API without linking +//! Rust types. GUI and PTY mutations are dispatched onto winit's event thread; +//! transport workers only validate, wait, and serialize. + +use std::error::Error; +use std::fmt; +use std::io::{BufRead, BufReader, Error as IoError, Read, Write}; +use std::net::{Ipv4Addr, Shutdown, SocketAddr, TcpListener, TcpStream}; +use std::path::PathBuf; +use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; +use std::sync::mpsc::{self, Receiver, RecvTimeoutError, SyncSender, TrySendError}; +use std::sync::{Arc, Mutex, MutexGuard}; +use std::time::{Duration, Instant}; + +use log::{info, warn}; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use winit::event_loop::EventLoopProxy; + +use crate::cli::{ + ControlCommand as CliCommand, ControlOptions, ControlSplitDirection, ControlWaitState, +}; +use crate::event::{Event, EventType}; + +pub const PROTOCOL_NAME: &str = "nebula.runtime"; +pub const PROTOCOL_VERSION: u16 = 1; +pub const SUPPORTED_VERSIONS: &[u16] = &[PROTOCOL_VERSION]; + +const CONNECT_TIMEOUT: Duration = Duration::from_millis(500); +const IO_TIMEOUT: Duration = Duration::from_secs(5); +const COMMAND_TIMEOUT: Duration = Duration::from_secs(30); +const MAX_REQUEST_BYTES: usize = 128 * 1024; +const MAX_PROMPT_BYTES: usize = 32 * 1024; +const MAX_CLIENTS: usize = 64; +const MAX_WAIT: Duration = Duration::from_secs(24 * 60 * 60); + +static NEXT_REQUEST_ID: AtomicU64 = AtomicU64::new(1); + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct ApiRequest { + pub protocol: String, + pub version: u16, + pub id: String, + pub token: String, + pub method: String, + #[serde(default = "empty_object")] + pub params: Value, +} + +impl ApiRequest { + fn new(token: String, method: impl Into, params: Value) -> Self { + let sequence = NEXT_REQUEST_ID.fetch_add(1, Ordering::Relaxed); + Self { + protocol: PROTOCOL_NAME.to_owned(), + version: PROTOCOL_VERSION, + id: format!("{}-{sequence}", std::process::id()), + token, + method: method.into(), + params, + } + } +} + +fn empty_object() -> Value { + Value::Object(Default::default()) +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ApiResponse { + pub protocol: String, + pub version: u16, + pub id: String, + pub ok: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +impl ApiResponse { + fn success(id: impl Into, result: Value) -> Self { + Self { + protocol: PROTOCOL_NAME.to_owned(), + version: PROTOCOL_VERSION, + id: id.into(), + ok: true, + result: Some(result), + error: None, + } + } + + fn failure(id: impl Into, error: ApiError) -> Self { + Self { + protocol: PROTOCOL_NAME.to_owned(), + version: PROTOCOL_VERSION, + id: id.into(), + ok: false, + result: None, + error: Some(error), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ApiEvent { + pub protocol: String, + pub version: u16, + pub event: String, + pub revision: u64, + pub data: RuntimeSnapshot, +} + +impl ApiEvent { + fn snapshot(snapshot: RuntimeSnapshot) -> Self { + Self { + protocol: PROTOCOL_NAME.to_owned(), + version: PROTOCOL_VERSION, + event: "runtime.snapshot".to_owned(), + revision: snapshot.revision, + data: snapshot, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ApiError { + pub code: String, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub details: Option, +} + +impl ApiError { + pub(crate) fn new(code: impl Into, message: impl Into) -> Self { + Self { code: code.into(), message: message.into(), details: None } + } + + pub(crate) fn details(mut self, details: Value) -> Self { + self.details = Some(details); + self + } + + fn invalid_params(message: impl Into) -> Self { + Self::new("invalid_params", message) + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum RuntimeTaskState { + Idle, + Running, + WaitingInput, + Attention, + Finished, + Failed, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum RuntimeSplitDirection { + LeftRight, + TopBottom, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct RuntimeSnapshot { + pub revision: u64, + pub process_id: u32, + pub app_version: String, + pub protocol_version: u16, + pub detached_windows: usize, + pub windows: Vec, +} + +impl RuntimeSnapshot { + pub(crate) fn new(detached_windows: usize, windows: Vec) -> Self { + Self { + revision: 0, + process_id: std::process::id(), + app_version: env!("VERSION").to_owned(), + protocol_version: PROTOCOL_VERSION, + detached_windows, + windows, + } + } + + fn pane(&self, window_id: Option, pane_id: u64) -> Result<&RuntimePane, ApiError> { + let matches: Vec<_> = self + .windows + .iter() + .filter(|window| window_id.is_none_or(|id| window.id == id)) + .flat_map(|window| window.tabs.iter()) + .flat_map(|tab| tab.panes.iter()) + .filter(|pane| pane.id == pane_id) + .collect(); + match matches.as_slice() { + [pane] => Ok(*pane), + [] => Err(ApiError::new( + "target_not_found", + format!("pane {pane_id} was not found in the requested window"), + )), + _ => Err(ApiError::new( + "ambiguous_target", + format!("pane id {pane_id} exists in multiple windows; provide window_id"), + )), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct RuntimeWindow { + pub id: u64, + pub focused: bool, + pub session_exempt: bool, + pub active_tab: usize, + pub focused_pane_id: Option, + pub tabs: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct RuntimeTab { + pub index: usize, + pub active: bool, + pub label: String, + pub kind: String, + pub bell: bool, + pub focused_pane_id: Option, + pub layout: Option, + pub panes: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum RuntimeLayout { + Pane { + pane_id: u64, + }, + Split { + direction: RuntimeSplitDirection, + ratio: f32, + first: Box, + second: Box, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct RuntimePane { + pub id: u64, + pub active: bool, + pub title: String, + pub cwd: String, + pub branch: String, + pub ssh_destination: Option, + pub running_program: Option, + pub task_state: RuntimeTaskState, + /// Monotonic count of this pane's task-state transitions. A state value on + /// its own cannot answer "did anything happen since I submitted?", so + /// callers that sent work compare against a baseline captured at submit + /// time. Stamped by [`RuntimeHub::publish`]; projection callers leave it 0. + #[serde(default)] + pub state_change_seq: u64, +} + +#[derive(Debug, Clone)] +pub enum RuntimeCommand { + Snapshot, + NewWindow, + Focus { window_id: Option, pane_id: Option }, + NewTab { window_id: Option }, + Split { window_id: Option, direction: RuntimeSplitDirection }, + Prompt { window_id: Option, pane_id: u64, text: String, submit: bool }, +} + +#[derive(Debug)] +pub struct RuntimeDispatch { + pub command: RuntimeCommand, + reply: SyncSender>, +} + +impl RuntimeDispatch { + fn new(command: RuntimeCommand) -> (Arc, Receiver>) { + let (reply, receiver) = mpsc::sync_channel(1); + (Arc::new(Self { command, reply }), receiver) + } + + pub(crate) fn respond(&self, response: Result) { + let _ = self.reply.send(response); + } +} + +#[derive(Clone, Default)] +pub struct RuntimeHub { + inner: Arc>, +} + +/// Carry each pane's transition counter forward, incrementing only where the +/// task state actually changed. Pane ids are window-local, so the identity key +/// is (window, pane) — matching on the pane id alone would let a same-numbered +/// pane in another window inherit an unrelated counter. +fn stamp_state_change_seq(previous: Option<&RuntimeSnapshot>, next: &mut RuntimeSnapshot) { + let mut before = std::collections::HashMap::new(); + if let Some(previous) = previous { + for window in &previous.windows { + for tab in &window.tabs { + for pane in &tab.panes { + before.insert((window.id, pane.id), (pane.task_state, pane.state_change_seq)); + } + } + } + } + + for window in &mut next.windows { + let window_id = window.id; + for tab in &mut window.tabs { + for pane in &mut tab.panes { + pane.state_change_seq = match before.get(&(window_id, pane.id)) { + Some((state, seq)) if *state == pane.task_state => *seq, + Some((_, seq)) => seq.saturating_add(1), + // A pane observed for the first time starts at 1, so a + // baseline of 0 always reads as "not yet seen". + None => 1, + }; + } + } + } +} + +#[derive(Default)] +struct HubState { + current: Option, + next_subscription: u64, + subscribers: Vec<(u64, SyncSender)>, +} + +impl RuntimeHub { + pub fn new() -> Self { + Self::default() + } + + fn lock(&self) -> MutexGuard<'_, HubState> { + self.inner.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) + } + + /// Publish only semantic changes. Repeated event-loop wakeups do not burn + /// revisions or flood subscribers with identical snapshots. + pub(crate) fn publish(&self, mut snapshot: RuntimeSnapshot) -> RuntimeSnapshot { + let mut state = self.lock(); + // Stamp per-pane transition counters before the dedup compare. Panes + // whose state is unchanged keep their previous counter, so an + // otherwise-identical projection still compares equal here. + stamp_state_change_seq(state.current.as_ref(), &mut snapshot); + if let Some(current) = &state.current { + snapshot.revision = current.revision; + if current == &snapshot { + return current.clone(); + } + snapshot.revision = current.revision.saturating_add(1); + } else { + snapshot.revision = 1; + } + + state.current = Some(snapshot.clone()); + state.subscribers.retain(|(_, sender)| match sender.try_send(snapshot.clone()) { + Ok(()) => true, + // A subscriber that cannot keep up is disconnected instead of + // back-pressuring the GUI event thread. + Err(TrySendError::Full(_)) | Err(TrySendError::Disconnected(_)) => false, + }); + snapshot + } + + fn subscribe(&self) -> (u64, Option, Receiver) { + let (sender, receiver) = mpsc::sync_channel(16); + let mut state = self.lock(); + state.next_subscription = state.next_subscription.saturating_add(1); + let id = state.next_subscription; + let current = state.current.clone(); + state.subscribers.push((id, sender)); + (id, current, receiver) + } +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct TargetParams { + #[serde(default)] + window_id: Option, + #[serde(default)] + pane_id: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct WindowParams { + #[serde(default)] + window_id: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct SplitParams { + #[serde(default)] + window_id: Option, + direction: RuntimeSplitDirection, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct PromptParams { + #[serde(default)] + window_id: Option, + pane_id: u64, + text: String, + #[serde(default = "default_true")] + submit: bool, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct SubscribeParams { + #[serde(default)] + since_revision: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct WaitParams { + #[serde(default)] + window_id: Option, + pane_id: u64, + state: RuntimeWaitState, + timeout_ms: u64, + /// Baseline transition counter captured when the client submitted work. + /// When present, the wait additionally requires the pane's counter to + /// advance past it — so a pane that was already in the target state does + /// not satisfy "wait until it settles again". + #[serde(default)] + after_seq: Option, +} + +#[derive(Debug, Clone, Copy, Deserialize)] +#[serde(rename_all = "snake_case")] +enum RuntimeWaitState { + Idle, + Running, + WaitingInput, + Attention, + Finished, + Failed, + Settled, +} + +fn default_true() -> bool { + true +} + +impl RuntimeCommand { + fn from_request(request: &ApiRequest) -> Result { + match request.method.as_str() { + "runtime.snapshot" => Ok(Self::Snapshot), + "window.create" => Ok(Self::NewWindow), + "window.focus" => { + let params: TargetParams = parse_params(&request.params)?; + Ok(Self::Focus { window_id: params.window_id, pane_id: params.pane_id }) + }, + "tab.new" => { + let params: WindowParams = parse_params(&request.params)?; + Ok(Self::NewTab { window_id: params.window_id }) + }, + "pane.split" => { + let params: SplitParams = parse_params(&request.params)?; + Ok(Self::Split { window_id: params.window_id, direction: params.direction }) + }, + "pane.prompt" => { + let params: PromptParams = parse_params(&request.params)?; + validate_prompt(¶ms.text)?; + Ok(Self::Prompt { + window_id: params.window_id, + pane_id: params.pane_id, + text: params.text, + submit: params.submit, + }) + }, + method => Err(ApiError::new( + "method_not_found", + format!("runtime API method {method:?} does not exist"), + )), + } + } +} + +fn parse_params(value: &Value) -> Result { + serde_json::from_value(value.clone()) + .map_err(|error| ApiError::invalid_params(format!("invalid method parameters: {error}"))) +} + +pub(crate) fn validate_prompt(text: &str) -> Result<(), ApiError> { + if text.is_empty() { + return Err(ApiError::invalid_params("prompt text must not be empty")); + } + if text.len() > MAX_PROMPT_BYTES { + return Err(ApiError::invalid_params(format!( + "prompt text exceeds the {MAX_PROMPT_BYTES}-byte limit" + ))); + } + if text.chars().any(char::is_control) { + return Err(ApiError::invalid_params( + "prompt text contains control characters; pane.prompt accepts one plain-text line", + )); + } + Ok(()) +} + +/// A wait is satisfied only when the pane both reads as the requested state and +/// has moved past the caller's baseline. Without the counter check, waiting on +/// an already-idle pane returns immediately and the caller concludes its work +/// finished before the shell even saw it. +fn wait_matches(pane: &RuntimePane, expected: RuntimeWaitState, after_seq: Option) -> bool { + after_seq.is_none_or(|baseline| pane.state_change_seq > baseline) + && wait_state_matches(pane.task_state, expected) +} + +fn wait_state_matches(actual: RuntimeTaskState, expected: RuntimeWaitState) -> bool { + match expected { + RuntimeWaitState::Idle => actual == RuntimeTaskState::Idle, + RuntimeWaitState::Running => actual == RuntimeTaskState::Running, + RuntimeWaitState::WaitingInput => actual == RuntimeTaskState::WaitingInput, + RuntimeWaitState::Attention => actual == RuntimeTaskState::Attention, + RuntimeWaitState::Finished => actual == RuntimeTaskState::Finished, + RuntimeWaitState::Failed => actual == RuntimeTaskState::Failed, + RuntimeWaitState::Settled => actual != RuntimeTaskState::Running, + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct Endpoint { + port: u16, + token: String, +} + +fn port_file() -> PathBuf { + crate::display::nebula_data_dir().join("runtime.port") +} + +fn legacy_port_file() -> PathBuf { + crate::display::nebula_data_dir().join("mux.port") +} + +fn read_endpoint() -> Option { + read_endpoint_from(port_file()) +} + +fn read_endpoint_from(path: PathBuf) -> Option { + let data = std::fs::read_to_string(path).ok()?; + let mut parts = data.split_whitespace(); + Some(Endpoint { port: parts.next()?.parse().ok()?, token: parts.next()?.to_owned() }) +} + +fn endpoint_addr(endpoint: &Endpoint) -> SocketAddr { + SocketAddr::from((Ipv4Addr::LOCALHOST, endpoint.port)) +} + +fn fresh_token() -> String { + use std::hash::{BuildHasher, Hasher, RandomState}; + let mut a = RandomState::new().build_hasher(); + let mut b = RandomState::new().build_hasher(); + a.write_u32(std::process::id()); + b.write_u128( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or(0), + ); + format!("{:016x}{:016x}", a.finish(), b.finish()) +} + +/// Hand a plain launch to the resident instance. This legacy verb remains +/// supported while both paths share the same authenticated endpoint. +pub fn try_attach_existing() -> bool { + legacy_request("ATTACH").is_some() +} + +fn legacy_request(verb: &str) -> Option<()> { + read_endpoint() + .and_then(|endpoint| legacy_request_to(verb, &endpoint)) + // Upgrade compatibility: an already-running pre-v1 Nebula still + // publishes mux.port. A plain launch can attach to it and exit; a new + // resident process writes runtime.port after the old process ends. + .or_else(|| { + read_endpoint_from(legacy_port_file()) + .and_then(|endpoint| legacy_request_to(verb, &endpoint)) + }) +} + +fn legacy_request_to(verb: &str, endpoint: &Endpoint) -> Option<()> { + let mut stream = TcpStream::connect_timeout(&endpoint_addr(&endpoint), CONNECT_TIMEOUT).ok()?; + stream.set_read_timeout(Some(IO_TIMEOUT)).ok()?; + stream.set_write_timeout(Some(IO_TIMEOUT)).ok()?; + stream.write_all(format!("{verb} {}\n", endpoint.token).as_bytes()).ok()?; + let mut line = String::new(); + BufReader::new(stream).read_line(&mut line).ok()?; + (line.trim() == "OK").then_some(()) +} + +/// Resident versioned runtime API server. +pub struct RuntimeServer { + endpoint: Endpoint, + port_file: PathBuf, +} + +impl RuntimeServer { + pub fn spawn(proxy: EventLoopProxy, hub: RuntimeHub) -> Option { + if read_endpoint().and_then(|endpoint| legacy_request_to("PING", &endpoint)).is_some() { + info!("Runtime API server already running; this instance stays client-only"); + return None; + } + + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).ok()?; + let endpoint = Endpoint { port: listener.local_addr().ok()?.port(), token: fresh_token() }; + let path = port_file(); + let contents = format!("{} {} {}\n", endpoint.port, endpoint.token, PROTOCOL_VERSION); + if crate::atomic_file::write(&path, contents.as_bytes()).is_err() { + warn!("Runtime API: cannot write {path:?}; control plane disabled"); + return None; + } + + let server_token = endpoint.token.clone(); + let spawned = std::thread::Builder::new() + .name("nebula-runtime-api".into()) + .spawn(move || serve(listener, server_token, proxy, hub)) + .is_ok(); + spawned.then(|| Self { endpoint, port_file: path }) + } +} + +impl Drop for RuntimeServer { + fn drop(&mut self) { + // Do not delete another process's newer discovery record. + if read_endpoint().as_ref() == Some(&self.endpoint) { + let _ = std::fs::remove_file(&self.port_file); + } + } +} + +fn serve(listener: TcpListener, token: String, proxy: EventLoopProxy, hub: RuntimeHub) { + let active = Arc::new(AtomicUsize::new(0)); + for stream in listener.incoming() { + let Ok(stream) = stream else { continue }; + if active.fetch_add(1, Ordering::AcqRel) >= MAX_CLIENTS { + active.fetch_sub(1, Ordering::AcqRel); + continue; + } + let active = active.clone(); + let token = token.clone(); + let proxy = proxy.clone(); + let hub = hub.clone(); + let _ = std::thread::Builder::new().name("nebula-runtime-client".into()).spawn(move || { + let _guard = ActiveClient(active); + if let Err(error) = handle_connection(stream, &token, &proxy, &hub) { + log::debug!("Runtime API client disconnected: {error}"); + } + }); + } +} + +struct ActiveClient(Arc); + +impl Drop for ActiveClient { + fn drop(&mut self) { + self.0.fetch_sub(1, Ordering::AcqRel); + } +} + +fn handle_connection( + mut stream: TcpStream, + token: &str, + proxy: &EventLoopProxy, + hub: &RuntimeHub, +) -> Result<(), IoError> { + stream.set_read_timeout(Some(IO_TIMEOUT))?; + stream.set_write_timeout(Some(IO_TIMEOUT))?; + let reader = BufReader::new(stream.try_clone()?); + let mut limited = reader.take((MAX_REQUEST_BYTES + 1) as u64); + let mut line = String::new(); + limited.read_line(&mut line)?; + if line.len() > MAX_REQUEST_BYTES { + return write_response( + &mut stream, + &ApiResponse::failure( + "unknown", + ApiError::new("request_too_large", "request too large"), + ), + ); + } + + if !line.trim_start().starts_with('{') { + return handle_legacy(&mut stream, &line, token, proxy); + } + + let raw: Value = match serde_json::from_str(&line) { + Ok(raw) => raw, + Err(error) => { + return write_response( + &mut stream, + &ApiResponse::failure( + "unknown", + ApiError::new("invalid_request", format!("invalid JSON: {error}")), + ), + ); + }, + }; + // Authentication precedes detailed validation so an unauthenticated local + // process cannot use parse errors as a protocol oracle. + if raw.get("token").and_then(Value::as_str) != Some(token) { + return Ok(()); + } + let id = raw.get("id").and_then(Value::as_str).unwrap_or("unknown").to_owned(); + let request: ApiRequest = match serde_json::from_value(raw) { + Ok(request) => request, + Err(error) => { + return write_response( + &mut stream, + &ApiResponse::failure( + id, + ApiError::new("invalid_request", format!("invalid request envelope: {error}")), + ), + ); + }, + }; + + if request.protocol != PROTOCOL_NAME || request.version != PROTOCOL_VERSION { + let error = ApiError::new( + "protocol_version_mismatch", + format!( + "requested protocol {:?} v{}; this runtime supports {} v{}", + request.protocol, request.version, PROTOCOL_NAME, PROTOCOL_VERSION + ), + ) + .details(json!({ "supported_versions": SUPPORTED_VERSIONS })); + return write_response(&mut stream, &ApiResponse::failure(request.id, error)); + } + + match request.method.as_str() { + "runtime.describe" => { + write_response(&mut stream, &ApiResponse::success(request.id, runtime_description())) + }, + "events.subscribe" => subscribe_connection(&mut stream, request, hub), + "pane.wait" => wait_connection(&mut stream, request, hub), + _ => dispatch_connection(&mut stream, request, proxy), + } +} + +fn handle_legacy( + stream: &mut TcpStream, + line: &str, + token: &str, + proxy: &EventLoopProxy, +) -> Result<(), IoError> { + let mut parts = line.split_whitespace(); + let verb = parts.next().unwrap_or(""); + if parts.next() != Some(token) { + return Ok(()); + } + match verb { + "ATTACH" => { + let _ = proxy.send_event(Event::new(EventType::NebulaAttach, None)); + stream.write_all(b"OK\n") + }, + "PING" => stream.write_all(b"OK\n"), + _ => Ok(()), + } +} + +fn runtime_description() -> Value { + json!({ + "app_version": env!("VERSION"), + "protocol": PROTOCOL_NAME, + "protocol_version": PROTOCOL_VERSION, + "supported_versions": SUPPORTED_VERSIONS, + "schema": "docs/runtime-api-v1.schema.json", + "capabilities": [ + "runtime.describe", + "runtime.snapshot", + "events.subscribe", + "window.create", + "window.focus", + "tab.new", + "pane.split", + "pane.prompt", + "pane.wait" + ], + // Additive params cannot be detected from `capabilities`: an older + // build ignores an unknown `after_seq` and still races. Clients that + // need the guarantee should require the feature string. + "features": [ + "pane.wait.after_seq" + ] + }) +} + +fn subscribe_connection( + stream: &mut TcpStream, + request: ApiRequest, + hub: &RuntimeHub, +) -> Result<(), IoError> { + let params: SubscribeParams = match parse_params(&request.params) { + Ok(params) => params, + Err(error) => return write_response(stream, &ApiResponse::failure(request.id, error)), + }; + let (subscription_id, current, receiver) = hub.subscribe(); + write_response( + stream, + &ApiResponse::success( + request.id, + json!({ + "subscription_id": subscription_id, + "current_revision": current.as_ref().map_or(0, |snapshot| snapshot.revision) + }), + ), + )?; + stream.set_write_timeout(None)?; + if let Some(snapshot) = current + && params.since_revision.is_none_or(|revision| snapshot.revision > revision) + { + write_json_line(stream, &ApiEvent::snapshot(snapshot))?; + } + while let Ok(snapshot) = receiver.recv() { + write_json_line(stream, &ApiEvent::snapshot(snapshot))?; + } + Ok(()) +} + +fn wait_connection( + stream: &mut TcpStream, + request: ApiRequest, + hub: &RuntimeHub, +) -> Result<(), IoError> { + let params: WaitParams = match parse_params(&request.params) { + Ok(params) => params, + Err(error) => return write_response(stream, &ApiResponse::failure(request.id, error)), + }; + let timeout = Duration::from_millis(params.timeout_ms); + if timeout.is_zero() || timeout > MAX_WAIT { + return write_response( + stream, + &ApiResponse::failure( + request.id, + ApiError::invalid_params("timeout_ms must be between 1 and 86400000"), + ), + ); + } + + let (_, current, receiver) = hub.subscribe(); + // Remember what the pane last looked like so a timeout can report why it + // never matched rather than only that it did not. + let mut observed = None; + if let Some(snapshot) = current { + match snapshot.pane(params.window_id, params.pane_id) { + Ok(pane) if wait_matches(pane, params.state, params.after_seq) => { + return write_response( + stream, + &ApiResponse::success( + request.id, + serde_json::to_value(snapshot).map_err(IoError::other)?, + ), + ); + }, + Ok(pane) => observed = Some((pane.task_state, pane.state_change_seq)), + Err(_) => (), + } + } + + let deadline = Instant::now() + timeout; + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + break; + } + let snapshot = match receiver.recv_timeout(remaining) { + Ok(snapshot) => snapshot, + Err(RecvTimeoutError::Timeout | RecvTimeoutError::Disconnected) => break, + }; + match snapshot.pane(params.window_id, params.pane_id) { + Ok(pane) if wait_matches(pane, params.state, params.after_seq) => { + return write_response( + stream, + &ApiResponse::success( + request.id, + serde_json::to_value(snapshot).map_err(IoError::other)?, + ), + ); + }, + Ok(pane) => observed = Some((pane.task_state, pane.state_change_seq)), + Err(_) => (), + } + } + + let detail = match observed { + Some((state, seq)) => format!("last observed state {state:?} at state_change_seq {seq}"), + None => "the pane was never present in a published snapshot".to_owned(), + }; + write_response( + stream, + &ApiResponse::failure( + request.id, + ApiError::new( + "timeout", + format!( + "pane {} did not reach the requested state before timeout; {detail}", + params.pane_id + ), + ) + .details(json!({ + "pane_id": params.pane_id, + "after_seq": params.after_seq, + "observed_state_change_seq": observed.map(|(_, seq)| seq) + })), + ), + ) +} + +fn dispatch_connection( + stream: &mut TcpStream, + request: ApiRequest, + proxy: &EventLoopProxy, +) -> Result<(), IoError> { + let command = match RuntimeCommand::from_request(&request) { + Ok(command) => command, + Err(error) => return write_response(stream, &ApiResponse::failure(request.id, error)), + }; + let (dispatch, receiver) = RuntimeDispatch::new(command); + if proxy.send_event(Event::new(EventType::RuntimeControl(dispatch), None)).is_err() { + return write_response( + stream, + &ApiResponse::failure( + request.id, + ApiError::new("runtime_unavailable", "Nebula's event loop is not available"), + ), + ); + } + let response = match receiver.recv_timeout(COMMAND_TIMEOUT) { + Ok(Ok(result)) => ApiResponse::success(request.id, result), + Ok(Err(error)) => ApiResponse::failure(request.id, error), + Err(_) => ApiResponse::failure( + request.id, + ApiError::new("runtime_timeout", "runtime event thread did not answer in time"), + ), + }; + write_response(stream, &response) +} + +fn write_response(stream: &mut TcpStream, response: &ApiResponse) -> Result<(), IoError> { + write_json_line(stream, response) +} + +fn write_json_line(stream: &mut TcpStream, value: &T) -> Result<(), IoError> { + serde_json::to_writer(&mut *stream, value).map_err(IoError::other)?; + stream.write_all(b"\n")?; + stream.flush() +} + +fn client_stream( + endpoint: &Endpoint, + request: &ApiRequest, + timeout: Option, +) -> Result { + let mut stream = TcpStream::connect_timeout(&endpoint_addr(&endpoint), CONNECT_TIMEOUT)?; + stream.set_read_timeout(timeout)?; + stream.set_write_timeout(Some(IO_TIMEOUT))?; + serde_json::to_writer(&mut stream, request).map_err(IoError::other)?; + stream.write_all(b"\n")?; + stream.flush()?; + stream.shutdown(Shutdown::Write)?; + Ok(stream) +} + +fn request_once( + method: &str, + params: Value, + timeout: Duration, +) -> Result> { + let endpoint = + read_endpoint().ok_or_else(|| CliError("no resident Nebula runtime found".into()))?; + let request = ApiRequest::new(endpoint.token.clone(), method, params); + let stream = client_stream(&endpoint, &request, Some(timeout))?; + let mut line = String::new(); + BufReader::new(stream).read_line(&mut line)?; + if line.is_empty() { + return Err(CliError("runtime closed the connection without a response".into()).into()); + } + Ok(serde_json::from_str(&line)?) +} + +fn print_response(response: &ApiResponse, pretty: bool) -> Result<(), Box> { + if pretty { + println!("{}", serde_json::to_string_pretty(response)?); + } else { + println!("{}", serde_json::to_string(response)?); + } + if response.ok { + Ok(()) + } else { + let error = + response.error.as_ref().map_or("runtime request failed", |error| &error.message); + Err(CliError(error.to_owned()).into()) + } +} + +/// CLI adapter. It deliberately speaks the same serialized protocol as any +/// external client instead of calling Processor helpers in-process. +pub fn run_cli(options: ControlOptions) -> Result<(), Box> { + if options.timeout_ms == 0 || Duration::from_millis(options.timeout_ms) > MAX_WAIT { + return Err(CliError("--timeout-ms must be between 1 and 86400000".into()).into()); + } + let timeout = Duration::from_millis(options.timeout_ms); + match options.command { + CliCommand::Describe => { + let response = request_once("runtime.describe", json!({}), timeout)?; + print_response(&response, options.pretty) + }, + CliCommand::Snapshot => { + let response = request_once("runtime.snapshot", json!({}), timeout)?; + print_response(&response, options.pretty) + }, + CliCommand::Subscribe { since } => subscribe_cli(since, timeout), + CliCommand::NewWindow => { + let response = request_once("window.create", json!({}), timeout)?; + print_response(&response, options.pretty) + }, + CliCommand::Focus { window, pane } => { + let response = request_once( + "window.focus", + json!({ "window_id": window, "pane_id": pane }), + timeout, + )?; + print_response(&response, options.pretty) + }, + CliCommand::NewTab { window } => { + let response = request_once("tab.new", json!({ "window_id": window }), timeout)?; + print_response(&response, options.pretty) + }, + CliCommand::Split { window, direction } => { + let direction = match direction { + ControlSplitDirection::Right => RuntimeSplitDirection::LeftRight, + ControlSplitDirection::Down => RuntimeSplitDirection::TopBottom, + }; + let response = request_once( + "pane.split", + json!({ "window_id": window, "direction": direction }), + timeout, + )?; + print_response(&response, options.pretty) + }, + CliCommand::Prompt { window, pane, text, no_submit, wait } => { + let response = request_once( + "pane.prompt", + json!({ + "window_id": window, + "pane_id": pane, + "text": text, + "submit": !no_submit + }), + timeout, + )?; + if !response.ok || wait.is_none() { + return print_response(&response, options.pretty); + } + // The prompt response carries the snapshot taken immediately after + // submission. Using its counter as the baseline is what makes the + // follow-up wait mean "settled again", not "already settled". + let baseline = pane_state_change_seq(&response, window, pane); + wait_cli(window, pane, wait.expect("checked above"), baseline, timeout, options.pretty) + }, + CliCommand::Wait { window, pane, state, after_seq } => { + wait_cli(window, pane, state, after_seq, timeout, options.pretty) + }, + } +} + +/// Dig a pane's transition counter out of a command response's embedded +/// snapshot. Returns `None` when the shape is unexpected, which degrades the +/// follow-up wait to plain state matching rather than failing the command. +fn pane_state_change_seq( + response: &ApiResponse, + window_id: Option, + pane_id: u64, +) -> Option { + let snapshot = response.result.as_ref()?.get("snapshot")?; + let snapshot: RuntimeSnapshot = serde_json::from_value(snapshot.clone()).ok()?; + snapshot.pane(window_id, pane_id).ok().map(|pane| pane.state_change_seq) +} + +fn wait_cli( + window: Option, + pane: u64, + state: ControlWaitState, + after_seq: Option, + timeout: Duration, + pretty: bool, +) -> Result<(), Box> { + let state = match state { + ControlWaitState::Idle => "idle", + ControlWaitState::Running => "running", + ControlWaitState::WaitingInput => "waiting_input", + ControlWaitState::Attention => "attention", + ControlWaitState::Finished => "finished", + ControlWaitState::Failed => "failed", + ControlWaitState::Settled => "settled", + }; + let response = request_once( + "pane.wait", + json!({ + "window_id": window, + "pane_id": pane, + "state": state, + "timeout_ms": timeout.as_millis() as u64, + "after_seq": after_seq + }), + timeout.saturating_add(Duration::from_secs(1)), + )?; + print_response(&response, pretty) +} + +fn subscribe_cli(since: Option, timeout: Duration) -> Result<(), Box> { + let endpoint = + read_endpoint().ok_or_else(|| CliError("no resident Nebula runtime found".into()))?; + let request = ApiRequest::new( + endpoint.token.clone(), + "events.subscribe", + json!({ "since_revision": since }), + ); + let stream = client_stream(&endpoint, &request, Some(timeout))?; + let mut reader = BufReader::new(stream); + let mut line = String::new(); + if reader.read_line(&mut line)? == 0 { + return Err( + CliError("runtime closed the subscription without an acknowledgement".into()).into() + ); + } + print!("{line}"); + std::io::stdout().flush()?; + reader.get_mut().set_read_timeout(None)?; + loop { + line.clear(); + if reader.read_line(&mut line)? == 0 { + return Ok(()); + } + print!("{line}"); + std::io::stdout().flush()?; + } +} + +#[derive(Debug)] +struct CliError(String); + +impl fmt::Display for CliError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.0) + } +} + +impl Error for CliError {} + +#[cfg(test)] +mod tests { + use super::*; + + fn snapshot(state: RuntimeTaskState) -> RuntimeSnapshot { + RuntimeSnapshot::new( + 0, + vec![RuntimeWindow { + id: 7, + focused: true, + session_exempt: false, + active_tab: 0, + focused_pane_id: Some(3), + tabs: vec![RuntimeTab { + index: 0, + active: true, + label: "test".into(), + kind: "shell".into(), + bell: false, + focused_pane_id: Some(3), + layout: Some(RuntimeLayout::Pane { pane_id: 3 }), + panes: vec![RuntimePane { + id: 3, + active: true, + title: "shell".into(), + cwd: "D:/work".into(), + branch: "main".into(), + ssh_destination: None, + running_program: None, + task_state: state, + state_change_seq: 0, + }], + }], + }], + ) + } + + #[test] + fn hub_revisions_change_only_when_semantic_state_changes() { + let hub = RuntimeHub::new(); + let first = hub.publish(snapshot(RuntimeTaskState::Idle)); + let duplicate = hub.publish(snapshot(RuntimeTaskState::Idle)); + let changed = hub.publish(snapshot(RuntimeTaskState::Running)); + assert_eq!(first.revision, 1); + assert_eq!(duplicate.revision, 1); + assert_eq!(changed.revision, 2); + } + + #[test] + fn subscribers_receive_the_canonical_revision() { + let hub = RuntimeHub::new(); + hub.publish(snapshot(RuntimeTaskState::Idle)); + let (_, current, receiver) = hub.subscribe(); + assert_eq!(current.unwrap().revision, 1); + hub.publish(snapshot(RuntimeTaskState::Running)); + assert_eq!(receiver.recv_timeout(Duration::from_millis(50)).unwrap().revision, 2); + } + + #[test] + fn prompt_rejects_terminal_control_sequences() { + assert!(validate_prompt("please inspect the build").is_ok()); + assert!(validate_prompt("unsafe\u{1b}[2J").is_err()); + assert!(validate_prompt("two\nlines").is_err()); + } + + #[test] + fn settled_wait_excludes_only_running() { + assert!(!wait_state_matches(RuntimeTaskState::Running, RuntimeWaitState::Settled)); + assert!(wait_state_matches(RuntimeTaskState::WaitingInput, RuntimeWaitState::Settled)); + assert!(wait_state_matches(RuntimeTaskState::Failed, RuntimeWaitState::Settled)); + } + + #[test] + fn state_change_seq_advances_only_on_transitions() { + let hub = RuntimeHub::new(); + let seq = |snapshot: &RuntimeSnapshot| snapshot.pane(None, 3).unwrap().state_change_seq; + + let first = hub.publish(snapshot(RuntimeTaskState::Idle)); + assert_eq!(seq(&first), 1, "a newly seen pane starts at 1, never 0"); + // A duplicate publish is deduped, which only holds because the stamp + // carried the counter forward instead of bumping it. + assert_eq!(seq(&hub.publish(snapshot(RuntimeTaskState::Idle))), 1); + assert_eq!(seq(&hub.publish(snapshot(RuntimeTaskState::Running))), 2); + assert_eq!(seq(&hub.publish(snapshot(RuntimeTaskState::Idle))), 3); + } + + #[test] + fn wait_ignores_a_pane_that_never_left_the_target_state() { + let hub = RuntimeHub::new(); + let idle = hub.publish(snapshot(RuntimeTaskState::Idle)); + let pane = idle.pane(None, 3).unwrap(); + + // Without a baseline, an already-idle pane satisfies "wait for idle". + assert!(wait_matches(pane, RuntimeWaitState::Idle, None)); + // With the baseline captured at submit time, it must not: this is the + // race where a wait returned before the shell had started the command. + assert!(!wait_matches(pane, RuntimeWaitState::Idle, Some(pane.state_change_seq))); + + let running = hub.publish(snapshot(RuntimeTaskState::Running)); + let settled = hub.publish(snapshot(RuntimeTaskState::Idle)); + assert!(!wait_matches( + running.pane(None, 3).unwrap(), + RuntimeWaitState::Idle, + Some(pane.state_change_seq) + )); + assert!(wait_matches( + settled.pane(None, 3).unwrap(), + RuntimeWaitState::Idle, + Some(pane.state_change_seq) + )); + } + + #[test] + fn state_change_seq_does_not_leak_across_windows() { + // Pane ids are window-local, so pane 3 in window 8 must not inherit + // window 7's counter and appear to have already transitioned. + let hub = RuntimeHub::new(); + hub.publish(snapshot(RuntimeTaskState::Running)); + let mut relabelled = snapshot(RuntimeTaskState::Idle); + relabelled.windows[0].id = 8; + let published = hub.publish(relabelled); + assert_eq!(published.pane(Some(8), 3).unwrap().state_change_seq, 1); + } + + #[test] + fn protocol_schema_is_valid_json_and_tracks_v1() { + let schema: Value = + serde_json::from_str(include_str!("../../docs/runtime-api-v1.schema.json")).unwrap(); + assert_eq!(schema["properties"]["version"]["const"], PROTOCOL_VERSION); + } +} diff --git a/nebula_app/src/window_context.rs b/nebula_app/src/window_context.rs index bca1524..0549ca3 100644 --- a/nebula_app/src/window_context.rs +++ b/nebula_app/src/window_context.rs @@ -190,7 +190,7 @@ pub struct WindowContext { /// 上一帧的聚焦 pane。变化时给新聚焦终端补发 CursorBlinkingChange, /// blink 定时器按它的样式重新起表——否则从"不闪"的 pane 切到"该闪" /// 的 pane 后表根本没开,光标永远常亮。 - blink_focus_pane: Option, + blink_focus_pane: Option, prev_bell_cmd: Option, /// When the PTYs last learned their size. Drives the leading-edge check of /// the resize debounce: a lone resize (startup, maximize, sidebar toggle) @@ -680,7 +680,7 @@ impl WindowContext { false }, TabRequest::NewProfile(profile) => { - self.spawn_tab_profile_value(profile); + self.spawn_tab_profile_value(profile, TabPlacement::Created); false }, TabRequest::NewShell { name, shell } => { diff --git a/nebula_app/src/window_context/runtime.rs b/nebula_app/src/window_context/runtime.rs new file mode 100644 index 0000000..4085a64 --- /dev/null +++ b/nebula_app/src/window_context/runtime.rs @@ -0,0 +1,220 @@ +//! Read-only runtime projection and narrowly-scoped control actions. +//! +//! Keeping this adapter beside `WindowContext` lets the public API observe the +//! same tab/layout/pane authority as the GUI without exposing those mutable +//! implementation types to transport threads. + +use nebula_terminal::event::Notify; + +use crate::display::SplitDirection; +use crate::event::TabRequest; +use crate::runtime_api::{ + ApiError, RuntimeLayout, RuntimePane, RuntimeSplitDirection, RuntimeTab, RuntimeTaskState, + RuntimeWindow, +}; + +use super::{Layout, TabLaunch, WindowContext}; + +impl WindowContext { + pub(crate) fn runtime_snapshot(&self) -> RuntimeWindow { + let tabs = self + .tabs + .iter() + .enumerate() + .map(|(index, tab)| { + let special = tab.doc.is_some() || tab.image.is_some() || tab.settings; + let mut pane_ids = Vec::new(); + if !special { + tab.layout.leaves(&mut pane_ids); + } + let panes = pane_ids + .iter() + .filter_map(|id| self.pane(*id)) + .map(|pane| RuntimePane { + id: pane.id, + active: pane.id == tab.active_pane, + title: pane.title.clone(), + cwd: pane.nebula_state.cwd.clone(), + branch: pane.nebula_state.branch.clone(), + ssh_destination: pane.ssh_destination.clone(), + running_program: pane.nebula_state.running_program.clone(), + task_state: task_state(pane), + // Seeded to 0; the hub stamps the real transition + // counter at publish time, where the previous snapshot + // is available to compare against. + state_change_seq: 0, + }) + .collect(); + let label = tab.custom_name.clone().unwrap_or_else(|| { + self.pane(tab.active_pane) + .map(Self::chrome_tab_label) + .unwrap_or_else(|| tab_kind(&tab.launch).to_owned()) + }); + RuntimeTab { + index, + active: index == self.active_tab, + label, + kind: tab_kind(&tab.launch).to_owned(), + bell: tab.has_bell, + focused_pane_id: (!special).then_some(tab.active_pane), + layout: (!special).then(|| runtime_layout(&tab.layout)), + panes, + } + }) + .collect(); + + RuntimeWindow { + id: self.id().into(), + focused: self.display.window.has_focus(), + session_exempt: self.session_exempt, + active_tab: self.active_tab, + focused_pane_id: self + .tabs + .get(self.active_tab) + .filter(|tab| tab.doc.is_none() && tab.image.is_none() && !tab.settings) + .map(|tab| tab.active_pane), + tabs, + } + } + + pub(crate) fn runtime_contains_pane(&self, pane_id: u64) -> bool { + self.pane(pane_id).is_some() + } + + pub(crate) fn runtime_focus(&mut self, pane_id: Option) -> Result<(), ApiError> { + if let Some(pane_id) = pane_id { + let tab_index = self.tabs.iter().position(|tab| { + let mut ids = Vec::new(); + tab.layout.leaves(&mut ids); + ids.contains(&pane_id) + }); + let Some(tab_index) = tab_index else { + return Err(ApiError::new( + "target_not_found", + format!("pane {pane_id} does not belong to the target window"), + )); + }; + self.select_tab(tab_index); + self.tabs[tab_index].active_pane = pane_id; + } + self.display.window.focus_window(); + self.dirty = true; + self.display.window.request_redraw(); + Ok(()) + } + + pub(crate) fn runtime_new_tab(&mut self) -> Result { + let before = self.tabs.len(); + self.handle_tab_request(TabRequest::New); + if self.tabs.len() == before { + return Err(ApiError::new( + "action_failed", + "the default shell could not be started for a new tab", + )); + } + Ok(self.tabs[self.active_tab].active_pane) + } + + pub(crate) fn runtime_split( + &mut self, + direction: RuntimeSplitDirection, + ) -> Result { + let special = self + .tabs + .get(self.active_tab) + .is_none_or(|tab| tab.doc.is_some() || tab.image.is_some() || tab.settings); + if special { + return Err(ApiError::new( + "invalid_state", + "document, image, and settings tabs cannot be split", + )); + } + let before = self.panes.len(); + let direction = match direction { + RuntimeSplitDirection::LeftRight => SplitDirection::LeftRight, + RuntimeSplitDirection::TopBottom => SplitDirection::TopBottom, + }; + self.handle_tab_request(TabRequest::SplitToggle(direction)); + if self.panes.len() == before { + return Err(ApiError::new( + "action_failed", + "the default shell could not be started for a new pane", + )); + } + Ok(self.tabs[self.active_tab].active_pane) + } + + pub(crate) fn runtime_prompt( + &mut self, + pane_id: u64, + text: String, + submit: bool, + ) -> Result<(), ApiError> { + crate::runtime_api::validate_prompt(&text)?; + let Some(index) = self.pane_index(pane_id) else { + return Err(ApiError::new( + "target_not_found", + format!("pane {pane_id} does not belong to the target window"), + )); + }; + let pane = &mut self.panes[index]; + pane.notifier.notify(text.into_bytes()); + if submit { + pane.notifier.notify(vec![b'\r']); + } + // Direct API input has the same semantic effect as keyboard input: + // stale completion/attention badges must not survive a new turn. + pane.nebula_state.touched = true; + pane.nebula_state.awaiting_input = false; + pane.nebula_state.needs_attention = false; + pane.nebula_state.finished_unseen = false; + pane.nebula_state.failed_unseen = false; + self.dirty = true; + self.display.window.request_redraw(); + Ok(()) + } +} + +fn task_state(pane: &super::Pane) -> RuntimeTaskState { + let state = &pane.nebula_state; + if state.needs_attention { + RuntimeTaskState::Attention + } else if state.awaiting_input { + RuntimeTaskState::WaitingInput + } else if state.command_started.is_some() { + RuntimeTaskState::Running + } else if state.failed_unseen { + RuntimeTaskState::Failed + } else if state.finished_unseen { + RuntimeTaskState::Finished + } else { + RuntimeTaskState::Idle + } +} + +fn tab_kind(launch: &TabLaunch) -> &'static str { + match launch { + TabLaunch::Default => "shell", + TabLaunch::Profile(_) => "profile", + TabLaunch::Shell { .. } => "shell", + TabLaunch::Ssh(_) => "ssh", + TabLaunch::Document(_) => "document", + TabLaunch::Image(_) => "image", + TabLaunch::Settings => "settings", + } +} + +fn runtime_layout(layout: &Layout) -> RuntimeLayout { + match layout { + Layout::Leaf(pane_id) => RuntimeLayout::Pane { pane_id: *pane_id }, + Layout::Split { direction, ratio, first, second, .. } => RuntimeLayout::Split { + direction: match direction { + SplitDirection::LeftRight => RuntimeSplitDirection::LeftRight, + SplitDirection::TopBottom => RuntimeSplitDirection::TopBottom, + }, + ratio: *ratio, + first: Box::new(runtime_layout(first)), + second: Box::new(runtime_layout(second)), + }, + } +} diff --git a/nebula_terminal/src/grid/resize.rs b/nebula_terminal/src/grid/resize.rs index a4e1487..1d52738 100644 --- a/nebula_terminal/src/grid/resize.rs +++ b/nebula_terminal/src/grid/resize.rs @@ -1,6 +1,6 @@ //! Grid resize and reflow. -use std::cmp::{Ordering, max, min}; +use std::cmp::{max, min, Ordering}; use std::mem; use crate::index::{Boundary, Column, Line}; @@ -25,12 +25,48 @@ impl Grid { Ordering::Equal => (), } - match self.columns.cmp(&columns) { + // Anchor the cursor to the content *before* reflowing, and put it back + // afterwards, instead of trusting the running total that `grow_columns` / + // `shrink_columns` keep as they splice rows. + // + // Those two maintain the cursor by incremental bookkeeping + // (`cursor_line_delta`, `point.column -= columns`, and a couple of + // branches that `continue` before recording anything). A single step is + // right, but an interactive drag calls this once per column — around 80 + // times for a 120→40 column drag, and 80 more on the way back — and the + // error compounds until the cursor sits in the middle of old output. + // + // Windows Terminal avoids the problem by not keeping a separate tally at + // all: `TextBuffer::Reflow` rewrites the content cell by cell and reads + // the cursor back off wherever its cell landed + // (`newCursorPos = { AdjustToGlyphStart(oldCursorPos.x - oldX + newX), newY }`, + // textBuffer.cpp:2879). The cursor rides the content. This does the same + // thing without touching the splice logic: remember which logical line + // the cursor is on and how far into it, then re-derive the position from + // the reflowed buffer. + // + // Only when the rows actually get spliced, though. Widening with reflow + // disabled just makes every row wider without moving a single cell, and + // anchoring through that would *introduce* a jump: the offset was + // measured in old columns and each row now spans more of them. + let column_order = self.columns.cmp(&columns); + let splices_rows = match column_order { + Ordering::Less => reflow && self.reflow_on_grow, + Ordering::Greater => reflow, + Ordering::Equal => false, + }; + let anchor = if splices_rows { self.cursor_anchor() } else { None }; + + match column_order { Ordering::Less => self.grow_columns(reflow && self.reflow_on_grow, columns), Ordering::Greater => self.shrink_columns(reflow, columns), Ordering::Equal => (), } + if let Some(anchor) = anchor { + self.restore_cursor_anchor(anchor); + } + // Restore template cell. self.cursor.template = template; } @@ -181,7 +217,25 @@ impl Grid { // this will always be either `0` or `1`. let line_delta = self.cursor.point.line - target.line; - if line_delta != 0 && row.is_clear() { + if row.is_clear() { + // The row was merged away in full, so it must go, exactly + // like any other emptied continuation row. + // + // Upstream only drops it when `line_delta != 0`, which never + // holds for a cursor on the top visible row: `Point::sub` + // clamps at `Line(0)` under `Boundary::Cursor` (index.rs:103), + // so the delta comes out `0` and the blank row survives. It + // then stands exactly where the content used to be and pushes + // that content up a row into the scrollback — which reads as + // the whole viewport having scrolled by itself. + // + // Dropping it unconditionally is safe now that the cursor is + // re-derived from `CursorAnchor` after the reflow rather than + // from these running deltas. + if i < self.display_offset { + self.display_offset = self.display_offset.saturating_sub(1); + } + continue; } @@ -386,4 +440,148 @@ impl Grid { // Clamp the saved cursor to the grid. self.saved_cursor.point.column = min(self.saved_cursor.point.column, Column(columns - 1)); } + + /// Whether this row soft-wraps into the one below it. + /// + /// The flag lives on the row's *physical* last cell, which is where both + /// `shrink_columns` writes it and `grow_columns` reads it. + fn has_wrapline(&self, line: Line) -> bool { + if line < self.topmost_line() || line > self.bottommost_line() { + return false; + } + + let row = &self[line]; + let len = row.len(); + len > 0 && row[Column(len - 1)].flags().contains(Flags::WRAPLINE) + } + + /// Columns this row contributes to its logical line. + /// + /// A trailing [`Flags::LEADING_WIDE_CHAR_SPACER`] is padding inserted because + /// a wide char would not fit — the char itself lives on the continuation row, + /// so the spacer holds no content and must not shift the offset. + fn logical_width(&self, line: Line) -> usize { + let row = &self[line]; + let len = row.len(); + if len > 0 && row[Column(len - 1)].flags().contains(Flags::LEADING_WIDE_CHAR_SPACER) { + len - 1 + } else { + len + } + } + + /// Record where the cursor sits relative to the *content*, so it can be found + /// again after the rows underneath it have been split or merged. + /// + /// Returns `None` when the cursor is outside the viewport, which leaves the + /// existing bookkeeping in charge rather than guessing. + fn cursor_anchor(&self) -> Option { + let cursor = self.cursor.point; + if cursor.line < Line(0) || cursor.line > self.bottommost_line() { + return None; + } + + // Walk up to the first row of the cursor's logical line, accumulating the + // columns every row above the cursor contributes. `input_needs_wrap` + // means "one past the last column", so it counts as one more column. + let topmost = self.topmost_line(); + let mut start = cursor.line; + let mut offset = cursor.column.0 + usize::from(self.cursor.input_needs_wrap); + while start > topmost && self.has_wrapline(start - 1) { + start -= 1; + offset += self.logical_width(start); + } + + // Find the logical line's last row, then count the logical lines below + // it. Reflow splits and merges rows *within* a logical line and never + // moves a hard line break, so this count is what survives it — physical + // row indices do not. Counting from the bottom also makes it immune to + // scrollback truncation, which only ever drops the oldest rows. + let bottommost = self.bottommost_line(); + let mut end = cursor.line; + while end < bottommost && self.has_wrapline(end) { + end += 1; + } + + let mut logical_lines_below = 0; + let mut line = end + 1; + while line <= bottommost { + // Every logical line ends in exactly one row without a wrap flag. + if !self.has_wrapline(line) { + logical_lines_below += 1; + } + line += 1; + } + + Some(CursorAnchor { logical_lines_below, offset }) + } + + /// Put the cursor back on the cell it was anchored to, overriding whatever + /// the reflow's own bookkeeping arrived at. + fn restore_cursor_anchor(&mut self, anchor: CursorAnchor) { + let topmost = self.topmost_line(); + let bottommost = self.bottommost_line(); + + // Walk up from the bottom of the buffer until `logical_lines_below` + // logical lines have been passed; the next row up ends the anchored one. + let mut end = bottommost; + let mut seen = 0; + while seen < anchor.logical_lines_below { + if !self.has_wrapline(end) { + seen += 1; + } + + if end == topmost { + // The anchored line is no longer in the buffer; leave the + // cursor where the reflow put it rather than inventing a spot. + return; + } + end -= 1; + } + + // Then back up to that logical line's first row and walk down through it, + // spending the recorded offset one row at a time. + let mut line = end; + while line > topmost && self.has_wrapline(line - 1) { + line -= 1; + } + + let mut remaining = anchor.offset; + while line < end { + let width = self.logical_width(line); + if remaining < width { + break; + } + remaining -= width; + line += 1; + } + + let last_column = Column(self.columns - 1); + if remaining > last_column.0 { + // Offset landed one past the end: the pending-wrap state, which is + // how a full row keeps the cursor addressable. + self.cursor.point.column = last_column; + self.cursor.input_needs_wrap = true; + } else { + self.cursor.point.column = Column(remaining); + self.cursor.input_needs_wrap = false; + } + + // A logical line long enough to push its own start above the viewport + // leaves the cursor clamped to the top row, matching what the reflow + // paths already do in that case. + self.cursor.point.line = max(line, Line(0)); + } +} + +/// Where the cursor sits in the content, in terms that survive a reflow. +/// +/// Both fields are measured against the *logical* line — the run of rows joined +/// by [`Flags::WRAPLINE`] — because that is the unit reflow preserves. +struct CursorAnchor { + /// Logical lines between the cursor's own and the bottom of the buffer. + logical_lines_below: usize, + + /// Cursor's column offset from the start of its logical line. + offset: usize, } diff --git a/nebula_terminal/src/grid/tests.rs b/nebula_terminal/src/grid/tests.rs index 309e76e..fb6f43d 100644 --- a/nebula_terminal/src/grid/tests.rs +++ b/nebula_terminal/src/grid/tests.rs @@ -270,16 +270,16 @@ fn grow_reflow() { assert_eq!(grid.total_lines(), 2); // '3' rejoins the row it was wrapped off of, and the wrap mark is consumed. - assert_eq!(grid[Line(1)].len(), 3); - assert_eq!(grid[Line(1)][Column(0)], cell('1')); - assert_eq!(grid[Line(1)][Column(1)], cell('2')); - assert_eq!(grid[Line(1)][Column(2)], cell('3')); + assert_eq!(grid[Line(0)].len(), 3); + assert_eq!(grid[Line(0)][Column(0)], cell('1')); + assert_eq!(grid[Line(0)][Column(1)], cell('2')); + assert_eq!(grid[Line(0)][Column(2)], cell('3')); // The vacated row is blank, not a leftover copy. - assert_eq!(grid[Line(0)].len(), 3); - assert_eq!(grid[Line(0)][Column(0)], Cell::default()); - assert_eq!(grid[Line(0)][Column(1)], Cell::default()); - assert_eq!(grid[Line(0)][Column(2)], Cell::default()); + assert_eq!(grid[Line(1)].len(), 3); + assert_eq!(grid[Line(1)][Column(0)], Cell::default()); + assert_eq!(grid[Line(1)][Column(1)], Cell::default()); + assert_eq!(grid[Line(1)][Column(2)], Cell::default()); } /// The property that actually matters to the user: narrowing then widening back @@ -300,6 +300,99 @@ fn shrink_then_grow_restores_the_original_layout() { } } +/// Restoring the *text* is only half the round-trip: the cursor has to land back +/// where it started too, or the next keystroke overwrites the wrong cell. +/// +/// The cursor sits right after the text, which is where a shell prompt leaves +/// it. Alacritty tracks the cursor through reflow by incremental bookkeeping +/// (`cursor_line_delta` and friends in `grow_columns`) rather than deriving it +/// from where the content landed, and its grid tests never assert on the cursor +/// at all — so this is the property most likely to be wrong. +#[test] +fn shrink_then_grow_restores_the_cursor() { + let mut grid = Grid::::new(3, 6, 4); + for (i, c) in "123456".chars().enumerate() { + grid[Line(0)][Column(i)] = cell(c); + } + grid.cursor.point = Point::new(Line(1), Column(0)); + + grid.resize(true, 3, 2); + grid.resize(true, 3, 6); + + assert_eq!(grid.cursor.point, Point::new(Line(1), Column(0)), "cursor after round-trip"); +} + +/// Same round-trip with the cursor parked *inside* the text that gets wrapped +/// and re-merged, rather than on the empty line below it. +#[test] +fn shrink_then_grow_restores_a_cursor_inside_wrapped_text() { + let mut grid = Grid::::new(3, 6, 4); + for (i, c) in "123456".chars().enumerate() { + grid[Line(0)][Column(i)] = cell(c); + } + // On '5' — column 4 of the logical line, which a shrink to 2 columns pushes + // onto the third wrapped row. + grid.cursor.point = Point::new(Line(0), Column(4)); + + grid.resize(true, 3, 2); + grid.resize(true, 3, 6); + + assert_eq!(grid[Line(0)][Column(4)], cell('5'), "cursor's cell after round-trip"); + assert_eq!(grid.cursor.point, Point::new(Line(0), Column(4)), "cursor after round-trip"); +} + +/// The round-trips above resize in one jump. An interactive drag does not: with +/// cell-sized resize increments the window emits one resize *per column*, so a +/// 20→4 drag calls `resize` 16 times and 16 more on the way back. +/// +/// This is the case that actually broke. Each individual step looked right, but +/// the cursor used to be carried along by incremental bookkeeping inside +/// `grow_columns` / `shrink_columns`, and the per-step error compounded until the +/// cursor sat somewhere in the middle of old output. +#[test] +fn dragging_one_column_at_a_time_keeps_the_cursor_on_its_cell() { + let text = "0123456789abcdefghij"; + let mut grid = Grid::::new(3, 20, 8); + for (i, c) in text.chars().enumerate() { + grid[Line(0)][Column(i)] = cell(c); + } + grid.cursor.point = Point::new(Line(1), Column(0)); + + for columns in (4..20).rev() { + grid.resize(true, 3, columns); + } + for columns in 5..=20 { + grid.resize(true, 3, columns); + } + + for (i, c) in text.chars().enumerate() { + assert_eq!(grid[Line(0)][Column(i)], cell(c), "column {i} after the drag"); + } + assert_eq!(grid.cursor.point, Point::new(Line(1), Column(0)), "cursor after the drag"); +} + +/// The shape from the bug report: a shell prompt leaves the cursor just past the +/// text it printed, on the same row. Nothing is written below it, so the only +/// thing pinning the cursor is the content to its left. +#[test] +fn a_cursor_parked_after_the_text_survives_a_narrow_drag() { + let text = "0123456789"; + let mut grid = Grid::::new(3, 20, 8); + for (i, c) in text.chars().enumerate() { + grid[Line(0)][Column(i)] = cell(c); + } + grid.cursor.point = Point::new(Line(0), Column(text.len())); + + for columns in (4..20).rev() { + grid.resize(true, 3, columns); + } + for columns in 5..=20 { + grid.resize(true, 3, columns); + } + + assert_eq!(grid.cursor.point, Point::new(Line(0), Column(text.len())), "cursor after the drag"); +} + /// Opting out keeps shrink-time wrapping but never re-merges, so rows stay /// split — the legacy in-box ConPTY contract. #[test] @@ -335,14 +428,15 @@ fn grow_reflow_multiline() { assert_eq!(grid.total_lines(), 3); - // All three rows were one logical line: it collapses back into one row. - assert_eq!(grid[Line(2)].len(), 6); + // All three rows were one logical line: it collapses back into one row, and + // stays on the row the logical line started on. + assert_eq!(grid[Line(0)].len(), 6); for (i, c) in "123456".chars().enumerate() { - assert_eq!(grid[Line(2)][Column(i)], cell(c), "column {i}"); + assert_eq!(grid[Line(0)][Column(i)], cell(c), "column {i}"); } // The two rows it was spread across are left blank. - for r in (0..2).map(Line::from) { + for r in (1..3).map(Line::from) { assert_eq!(grid[r].len(), 6); for c in 0..6 { assert_eq!(grid[r][Column(c)], Cell::default()); diff --git a/nebula_terminal/src/osc_cwd.rs b/nebula_terminal/src/osc_cwd.rs index c761727..9c32dd5 100644 --- a/nebula_terminal/src/osc_cwd.rs +++ b/nebula_terminal/src/osc_cwd.rs @@ -111,7 +111,7 @@ impl CwdSniffer { if let Some(event) = self.parse() { events.push((i + 1, event)); } - self.to_ground(); + self.reset_to_ground(); } else { // The ESC belonged to the payload after all; keep it and // reprocess this byte in the normal OSC state. @@ -134,14 +134,14 @@ impl CwdSniffer { if let Some(event) = self.parse() { events.push((i + 1, event)); } - self.to_ground(); + self.reset_to_ground(); }, 0x1b => self.phase = Phase::OscEsc, _ => self.push(b), } } - fn to_ground(&mut self) { + fn reset_to_ground(&mut self) { self.phase = Phase::Ground; self.payload.clear(); self.interested = false; diff --git a/nebula_terminal/src/term/cell.rs b/nebula_terminal/src/term/cell.rs index 66dfa7d..fee8854 100644 --- a/nebula_terminal/src/term/cell.rs +++ b/nebula_terminal/src/term/cell.rs @@ -1,5 +1,5 @@ -use std::sync::Arc; use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::Arc; use bitflags::bitflags; #[cfg(feature = "serde")] diff --git a/nebula_terminal/src/term/mod.rs b/nebula_terminal/src/term/mod.rs index 53be016..e7db880 100644 --- a/nebula_terminal/src/term/mod.rs +++ b/nebula_terminal/src/term/mod.rs @@ -8,8 +8,8 @@ use std::{cmp, mem, ptr, str}; #[cfg(feature = "serde")] use serde::{Deserialize, Serialize}; -use base64::Engine; use base64::engine::general_purpose::STANDARD as Base64; +use base64::Engine; use bitflags::bitflags; use log::{debug, trace}; use unicode_width::UnicodeWidthChar; @@ -289,20 +289,15 @@ pub enum Osc52 { /// Whether the grid may re-merge soft-wrapped rows when it grows wider. /// -/// The sideloaded passthrough ConPTY (the default host) leaves reflow entirely -/// to us, so re-merging is what makes a shrink→grow round-trip lossless. The -/// legacy in-box host reflows its own buffer on resize, and two engines -/// rewriting one stream mangle it — hence the capability check rather than a -/// blanket `true`. -#[cfg(windows)] -fn reflow_on_grow_supported() -> bool { - crate::tty::windows::conpty_sideload_enabled() -} - -/// Unix ptys carry no reflow engine of their own; the grid is always the sole -/// owner. -#[cfg(not(windows))] +/// Nebula's grid is the terminal-side source of truth for cursor/content +/// coordinates on every backend. ConPTY also reflows its private buffer, but +/// Windows Terminal's `TextBuffer::Reflow` shows that this is intentionally +/// allowed: ConPTY asks the terminal for the current cursor position instead +/// of replacing the terminal's reflow state. Disabling grow reflow for one +/// ConPTY variant therefore leaves the grid permanently split after a narrow +/// resize and makes the next input use a stale logical row. fn reflow_on_grow_supported() -> bool { + // 关键:ConPTY 的私有缓冲会自行重排,但终端网格仍是 shell 查询到的光标权威,不能关闭回合并。 true } @@ -406,10 +401,9 @@ impl Term { let mut grid = Grid::new(num_lines, num_cols, history_size); let mut inactive_grid = Grid::new(num_lines, num_cols, 0); - // Only re-merge soft-wrapped rows on grow when we own the sole reflow - // engine. The sideloaded passthrough ConPTY leaves reflow entirely to us - // (the default); the legacy in-box host reflows its own buffer, and two - // engines rewriting one stream mangle it. + // Keep the grid's logical rows reversible on both in-box and side-loaded + // ConPTY. The host has a private buffer, while the terminal grid remains + // the cursor/content authority queried by the shell after a resize. let reflow_on_grow = reflow_on_grow_supported(); grid.set_reflow_on_grow(reflow_on_grow); inactive_grid.set_reflow_on_grow(reflow_on_grow); @@ -2346,7 +2340,11 @@ enum ModeState { impl From for ModeState { fn from(value: bool) -> Self { - if value { Self::Set } else { Self::Reset } + if value { + Self::Set + } else { + Self::Reset + } } } @@ -3006,6 +3004,56 @@ mod tests { assert_eq!(term.grid.cursor.point, Point::new(Line(4), Column(0))); } + #[test] + fn reflow_keeps_prompt_input_at_the_logical_cursor() { + let size = TermSize::new(6, 3); + let mut term = Term::new(Config::default(), &size, VoidListener); + for c in "promp".chars() { + term.input(c); + } + + for columns in (3..6).rev() { + term.resize(TermSize::new(columns, 3)); + } + for columns in 4..=6 { + term.resize(TermSize::new(columns, 3)); + } + + term.input('!'); + + assert_eq!(term.grid()[Line(0)][Column(5)].c, '!'); + assert_eq!(term.grid.cursor.point, Point::new(Line(0), Column(5))); + assert!(term.grid.cursor.input_needs_wrap); + } + + #[test] + fn reflow_preserves_pending_wrap_before_the_next_input() { + let size = TermSize::new(6, 3); + let mut term = Term::new(Config::default(), &size, VoidListener); + for c in "123456".chars() { + term.input(c); + } + assert_eq!(term.grid.cursor.point, Point::new(Line(0), Column(5))); + assert!(term.grid.cursor.input_needs_wrap); + + for columns in (3..6).rev() { + term.resize(TermSize::new(columns, 3)); + } + for columns in 4..=6 { + term.resize(TermSize::new(columns, 3)); + } + + // The cursor is visually on the last cell, but the next byte must wrap + // before writing instead of overwriting that cell. + assert_eq!(term.grid.cursor.point, Point::new(Line(0), Column(5))); + assert!(term.grid.cursor.input_needs_wrap); + term.input('X'); + + assert_eq!(term.grid[Line(1)][Column(0)].c, 'X'); + assert_eq!(term.grid.cursor.point, Point::new(Line(1), Column(1))); + assert!(!term.grid.cursor.input_needs_wrap); + } + #[test] fn damage_public_usage() { let size = TermSize::new(10, 10); diff --git a/nebula_terminal/src/term/search.rs b/nebula_terminal/src/term/search.rs index 302f67f..e0e9f70 100644 --- a/nebula_terminal/src/term/search.rs +++ b/nebula_terminal/src/term/search.rs @@ -4,16 +4,16 @@ use std::mem; use std::ops::RangeInclusive; use log::{debug, warn}; -pub use regex_automata::hybrid::BuildError; use regex_automata::hybrid::dfa::{Builder, Cache, Config, DFA}; +pub use regex_automata::hybrid::BuildError; use regex_automata::nfa::thompson::Config as ThompsonConfig; use regex_automata::util::syntax::Config as SyntaxConfig; use regex_automata::{Anchored, Input, MatchKind}; use crate::grid::{BidirectionalIterator, Dimensions, GridIterator, Indexed}; use crate::index::{Boundary, Column, Direction, Point, Side}; -use crate::term::Term; use crate::term::cell::{Cell, Flags}; +use crate::term::Term; /// Used to match equal brackets, when performing a bracket-pair selection. const BRACKET_PAIRS: [(char, char); 4] = [('(', ')'), ('[', ']'), ('{', '}'), ('<', '>')]; @@ -689,8 +689,8 @@ mod tests { use super::*; use crate::index::{Column, Line}; + use crate::term::test::{mock_term, TermSize}; use crate::term::Config; - use crate::term::test::{TermSize, mock_term}; #[test] fn regex_right() { diff --git a/nebula_terminal/src/tty/windows/blocking.rs b/nebula_terminal/src/tty/windows/blocking.rs index 328d216..21dac28 100644 --- a/nebula_terminal/src/tty/windows/blocking.rs +++ b/nebula_terminal/src/tty/windows/blocking.rs @@ -6,7 +6,7 @@ use std::sync::{Arc, Mutex}; use std::task::{Context, Poll, Wake, Waker}; use std::{io, thread}; -use piper::{Reader, Writer, pipe}; +use piper::{pipe, Reader, Writer}; use polling::os::iocp::{CompletionPacket, PollerIocpExt}; use polling::{Event, PollMode, Poller}; diff --git a/nebula_terminal/src/tty/windows/child.rs b/nebula_terminal/src/tty/windows/child.rs index 9d1a49a..e034b1c 100644 --- a/nebula_terminal/src/tty/windows/child.rs +++ b/nebula_terminal/src/tty/windows/child.rs @@ -5,14 +5,14 @@ use std::os::windows::process::ExitStatusExt; use std::process::ExitStatus; use std::ptr; use std::sync::atomic::{AtomicPtr, Ordering}; -use std::sync::{Arc, Mutex, mpsc}; +use std::sync::{mpsc, Arc, Mutex}; use polling::os::iocp::{CompletionPacket, PollerIocpExt}; use polling::{Event, Poller}; use windows_sys::Win32::Foundation::{BOOLEAN, FALSE, HANDLE}; use windows_sys::Win32::System::Threading::{ - GetExitCodeProcess, GetProcessId, INFINITE, RegisterWaitForSingleObject, UnregisterWait, + GetExitCodeProcess, GetProcessId, RegisterWaitForSingleObject, UnregisterWait, INFINITE, WT_EXECUTEINWAITTHREAD, WT_EXECUTEONLYONCE, }; diff --git a/nebula_terminal/src/tty/windows/conpty.rs b/nebula_terminal/src/tty/windows/conpty.rs index 765477b..2f33e37 100644 --- a/nebula_terminal/src/tty/windows/conpty.rs +++ b/nebula_terminal/src/tty/windows/conpty.rs @@ -7,25 +7,25 @@ use std::os::windows::io::IntoRawHandle; use std::path::{Path, PathBuf}; use std::{mem, ptr}; -use windows_sys::Win32::Foundation::{HANDLE, S_OK}; -use windows_sys::Win32::System::Console::{ - COORD, ClosePseudoConsole, CreatePseudoConsole, HPCON, ResizePseudoConsole, -}; -use windows_sys::Win32::System::LibraryLoader::{GetProcAddress, LoadLibraryW}; use windows_sys::core::{HRESULT, PWSTR}; use windows_sys::s; +use windows_sys::Win32::Foundation::{HANDLE, S_OK}; +use windows_sys::Win32::System::Console::{ + ClosePseudoConsole, CreatePseudoConsole, ResizePseudoConsole, COORD, HPCON, +}; +use windows_sys::Win32::System::LibraryLoader::{GetProcAddress, LoadLibraryW}; use windows_sys::Win32::System::Threading::{ - CREATE_UNICODE_ENVIRONMENT, CreateProcessW, EXTENDED_STARTUPINFO_PRESENT, - InitializeProcThreadAttributeList, PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE, PROCESS_INFORMATION, - STARTF_USESTDHANDLES, STARTUPINFOEXW, STARTUPINFOW, UpdateProcThreadAttribute, + CreateProcessW, InitializeProcThreadAttributeList, UpdateProcThreadAttribute, + CREATE_UNICODE_ENVIRONMENT, EXTENDED_STARTUPINFO_PRESENT, PROCESS_INFORMATION, + PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE, STARTF_USESTDHANDLES, STARTUPINFOEXW, STARTUPINFOW, }; use crate::event::{OnResize, WindowSize}; -use crate::tty::Options; use crate::tty::windows::blocking::{UnblockedReader, UnblockedWriter}; use crate::tty::windows::child::ChildExitWatcher; -use crate::tty::windows::{Pty, cmdline, win32_string}; +use crate::tty::windows::{cmdline, win32_string, Pty}; +use crate::tty::Options; const PIPE_CAPACITY: usize = crate::event_loop::READ_BUFFER_SIZE; @@ -201,8 +201,13 @@ pub fn new(config: &Options, window_size: WindowSize) -> Result { ); crate::pty_trace("CreatePseudoConsole win32-input rejected; legacy retry"); result = unsafe { - (api.create)(window_size.into(), conin_handle, conout_handle, 0, &mut pty_handle - as *mut _) + (api.create)( + window_size.into(), + conin_handle, + conout_handle, + 0, + &mut pty_handle as *mut _, + ) }; } crate::pty_trace("CreatePseudoConsole done"); diff --git a/nebula_terminal/src/tty/windows/mod.rs b/nebula_terminal/src/tty/windows/mod.rs index ff915bb..107e76c 100644 --- a/nebula_terminal/src/tty/windows/mod.rs +++ b/nebula_terminal/src/tty/windows/mod.rs @@ -3,8 +3,8 @@ use std::io::{self, Result}; use std::iter::once; use std::os::windows::ffi::OsStrExt; use std::path::PathBuf; -use std::sync::Arc; use std::sync::mpsc::TryRecvError; +use std::sync::Arc; use windows_sys::Win32::System::Threading::TerminateProcess; @@ -269,9 +269,7 @@ fn nebula_find_bash() -> Option { } } - for root in - ["LOCALAPPDATA", "USERPROFILE"].into_iter().filter_map(|name| std::env::var_os(name)) - { + for root in ["LOCALAPPDATA", "USERPROFILE"].into_iter().filter_map(std::env::var_os) { let root = PathBuf::from(root); for candidate in [ root.join("Programs").join("Git").join("bin").join("bash.exe"), @@ -1006,9 +1004,9 @@ mod test { use std::process::{Command, Stdio}; use crate::tty::windows::{ - NEBULA_BASH_RC, NEBULA_PROMPT_PS1, NebulaRuntimeSettings, NebulaShellExecutor, cmdline, - explicit_bash_integration_args, nebula_default_shell, nebula_find_bash, - powershell_integration_args, push_escaped_arg, + cmdline, explicit_bash_integration_args, nebula_default_shell, nebula_find_bash, + powershell_integration_args, push_escaped_arg, NebulaRuntimeSettings, NebulaShellExecutor, + NEBULA_BASH_RC, NEBULA_PROMPT_PS1, }; use crate::tty::{Options, Shell}; @@ -1072,9 +1070,8 @@ mod test { /// guard already; this covers the path that actually regressed. #[test] fn default_powershell_loads_the_user_profile_and_ends_with_the_integration() { - let shell = nebula_default_shell(NebulaRuntimeSettings { - shell: NebulaShellExecutor::PowerShell, - }); + let shell = + nebula_default_shell(NebulaRuntimeSettings { shell: NebulaShellExecutor::PowerShell }); let args = shell.args(); assert_eq!(shell.program(), "powershell");