diff --git a/.github/workflows/linux-lua.yml b/.github/workflows/linux-lua.yml new file mode 100644 index 0000000..ad515b2 --- /dev/null +++ b/.github/workflows/linux-lua.yml @@ -0,0 +1,42 @@ +name: Linux Lua configuration + +on: + push: + paths: + - "**/*.rs" + - "**/Cargo.toml" + - "Cargo.lock" + - ".github/workflows/linux-lua.yml" + pull_request: + paths: + - "**/*.rs" + - "**/Cargo.toml" + - "Cargo.lock" + - ".github/workflows/linux-lua.yml" + +permissions: + contents: read + +jobs: + ubuntu-24-04: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - name: Install native dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + build-essential cmake pkg-config \ + libfontconfig1-dev libfreetype6-dev \ + libegl1-mesa-dev libgl1-mesa-dev \ + libwayland-dev libxkbcommon-dev \ + libx11-xcb-dev libxcb1-dev libxcb-render0-dev \ + libxcb-shape0-dev libxcb-xfixes0-dev + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: "1.85.0" + - uses: Swatinem/rust-cache@v2 + - name: Test Lua configuration on Wayland and X11 build + run: cargo test -p nebula config:: + - name: Check Linux release build + run: cargo check --workspace --release diff --git a/INSTALL.md b/INSTALL.md index a97096c..018aed1 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -22,7 +22,7 @@ Keep the extracted directory structure intact: | `runtime/nebula-hook.exe` | AI turn-notification bridge (Claude Code / Codex) | | `runtime/conpty.dll` + `runtime/OpenConsole.exe` | modern ConPTY host (correct resize, fast tab spawn) | | `fonts/MapleMonoNormal-NF-CN-Regular.ttf` | Nerd Font for powerline/icons — install once (SIL OFL 1.1) | -| `docs/CHANGELOG.md` + `docs/INSTALL.md` | release changes and installation details | +| `docs/CHANGELOG.md` + `docs/INSTALL.md` + `docs/lua-configuration.md` | release changes, installation, and Lua configuration | | `licenses/` | Nebula and third-party license notices | ## Build from source @@ -53,5 +53,11 @@ have already been built and verified. - Claude Code / Codex turn notifications are wired on first boot (`nebula setup-ai --remove` to undo; `nebula notify-test` to verify the toast pipeline). -- Configuration lives at `%APPDATA%\nebula\nebula.toml` (created on demand); - visual settings are in the in-app settings panel. +- New configuration uses `%APPDATA%\nebula\nebula.lua`. Run + `nebula config init --language system` to create an annotated template and + `nebula config check` to validate it. Existing `nebula.toml` remains + supported when no Lua configuration is present. +- Linux uses `$XDG_CONFIG_HOME/nebula/nebula.lua` (normally + `~/.config/nebula/nebula.lua`) and supports both Wayland and X11. The initial + binary baseline is x86_64 glibc on Ubuntu 24.04, Debian 12, and Fedora 42. +- Visual settings remain available in the in-app settings panel. diff --git a/README.md b/README.md index 0bbc851..890cb9b 100644 --- a/README.md +++ b/README.md @@ -188,6 +188,17 @@ under `runtime/`, while fonts, documentation, and notices live under `fonts/`, cargo build --release # artifacts land in target/release/ ``` +### Lua Configuration + +Nebula ships a vendored Lua 5.4 runtime with `require 'nebula'` and +`nebula.config_builder()`. Generate an annotated template with +`nebula config init --language system|zh-CN|en-US`, then validate it without +opening the GUI using `nebula config check`. Module `require` paths participate +in live reload; invalid edits keep the last-known-good configuration active. +Existing TOML files remain supported. See the +[Lua configuration guide](docs/lua-configuration.md) for APIs, discovery order, +Windows/Linux paths, arrays, modules, and reload behavior. + ### 🧩 Tech Stack | Layer | Tech | @@ -340,6 +351,16 @@ Nebula 是一款 Windows 上的终端模拟器,以 Rust 编写,构建在 GPU cargo build --release # 产物在 target/release/ ``` +### Lua 配置 + +Nebula 内置 Lua 5.4,使用 `require 'nebula'` 与 +`nebula.config_builder()`。运行 +`nebula config init --language system|zh-CN|en-US` 可生成带完整注释的模板, +`nebula config check` 可在不启动 GUI 的情况下检查语法与字段。通过 `require` +载入的模块会参与热重载;配置错误时继续使用上一份有效配置。已有 TOML 仍兼容。 +路径优先级、Windows/Linux 位置、数组、模块与重载说明见 +[Lua 配置指南](docs/lua-configuration.md)。 + ### 🧩 技术栈 | 层级 | 技术 | diff --git a/THIRD-PARTY-NOTICES b/THIRD-PARTY-NOTICES index d4cb610..5501e3a 100644 --- a/THIRD-PARTY-NOTICES +++ b/THIRD-PARTY-NOTICES @@ -5,6 +5,10 @@ Portions of this software incorporate code originally distributed under the Apache License 2.0 and the MIT License. The required license texts and copyright notices are reproduced below, as their terms require. +The embedded Lua 5.4 runtime is distributed under the Lua MIT license. +The Rust binding is mlua 0.11, also distributed under the MIT license. +Their component-specific license files are included in release packages. + --------------------------------------------------------------------- Apache License diff --git a/docs/lua-configuration.md b/docs/lua-configuration.md new file mode 100644 index 0000000..4774dd6 --- /dev/null +++ b/docs/lua-configuration.md @@ -0,0 +1,169 @@ +# Nebula Lua Configuration + +Nebula uses vendored Lua 5.4 for programmable configuration. The API follows +the productive shape of WezTerm's configuration experience, but it is a +Nebula API rather than a compatibility layer: use `require 'nebula'`, and do +not expect an existing `.wezterm.lua` to run unchanged. + +Lua configuration is executable local code. Only use configuration files and +modules that you trust. Nebula does not download or execute remote +configuration during discovery. + +## Quick Start + +Create a localized, annotated configuration: + +```text +nebula config init --language system +nebula config init --language zh-CN +nebula config init --language en-US +``` + +Nebula refuses to overwrite an existing file. `--force` first creates a +timestamped backup and only then atomically replaces the target. + +Validate a configuration without opening a window: + +```text +nebula config check +nebula config check --config-file D:/configs/nebula.lua +``` + +## Basic Configuration + +```lua +local nebula = require 'nebula' +local config = nebula.config_builder() + +config.window = { + opacity = 0.96, + decorations = 'Full', +} + +config.font = { + normal = { family = 'Maple Mono NF CN', style = 'Regular' }, + size = 13.0, +} + +config.scrolling = { + history = 10000, +} + +config.env = { + EDITOR = 'nvim', +} + +return config +``` + +Returning a plain table is also supported, but `config_builder()` is +recommended because it validates top-level assignments as they are made. +Unknown fields and invalid values are errors by default. + +## Arrays And Modules + +Lua's `{}` does not distinguish an empty array from an empty object. Nebula +treats an ordinary empty table as an object; use `nebula.array()` for an empty +array: + +```lua +config.profiles = nebula.array() + +config.profiles = nebula.array { + { + name = 'Production SSH', + command = 'ssh', + args = { 'user@server' }, + }, +} +``` + +Non-empty tables with consecutive integer keys are arrays automatically. +Sparse arrays and tables mixing integer and string keys are rejected. + +Split large configurations into sibling modules: + +```lua +-- theme.lua +return { + primary = { + foreground = '#d8dee9', + background = '#16181d', + }, +} +``` + +```lua +-- nebula.lua +local nebula = require 'nebula' +local config = nebula.config_builder() +config.colors = require 'theme' +return config +``` + +Files successfully loaded through `require` are added to the live-reload watch +list. Extra local files can be registered with +`nebula.add_to_config_reload_watch_list(path)`. + +## Platform Values + +The module exposes stable runtime paths and platform data: + +```lua +local nebula = require 'nebula' + +if nebula.platform.os == 'linux' then + -- display_server is 'wayland', 'x11', or 'unknown'. + nebula.log_info('Linux display: ' .. nebula.platform.display_server) +end + +-- nebula.config_file, nebula.config_dir, nebula.home_dir +-- nebula.executable_dir, nebula.version, nebula.target_triple +``` + +## Discovery Order + +An explicit `--config-file` wins, followed by `NEBULA_CONFIG_FILE`. Explicit +paths must end in `.lua`, `.toml`, `.yml`, or `.yaml`. + +Without an explicit path, Nebula searches all Lua locations before considering +legacy TOML/YAML. An existing but invalid Lua file is authoritative and +reports an error; Nebula does not silently fall back to an older TOML file. + +Windows Lua locations: + +1. `nebula.lua` beside `nebula.exe` for portable installations. +2. `%APPDATA%/nebula/nebula.lua`. +3. `%USERPROFILE%/.nebula.lua`. + +Linux Lua locations: + +1. `$XDG_CONFIG_HOME/nebula/nebula.lua`. +2. `$XDG_CONFIG_HOME/nebula.lua`. +3. `$HOME/.config/nebula/nebula.lua`. +4. `$HOME/.nebula.lua`. +5. `/etc/nebula/nebula.lua`. + +The same location order is then checked for `.toml`, `.yml`, and `.yaml`. +Existing TOML configurations remain supported; YAML remains a deprecated +transition format. + +## Transactional Reload + +Configuration parsing and Lua execution run on one serial worker. Rapid file +saves are coalesced, and only the latest successful generation is published. +If Lua syntax, module loading, conversion, or validation fails, Nebula keeps +the last-known-good `UiConfig` and Lua VM alive. Fixing and saving the file +causes the next valid generation to replace them together. + +The initial Linux support baseline is x86_64 glibc on Ubuntu 24.04, Debian 12, +and Fedora 42, with both Wayland and X11. Arch Linux is treated as rolling +compatibility coverage rather than a fixed dependency baseline. + +## 中文说明 + +`nebula config init --language zh-CN` 会生成活动代码与英文模板完全一致、 +但说明全部为简体中文的 UTF-8 配置。切换 Nebula 界面语言不会改写已经存在的 +Lua 文件。普通空表 `{}` 表示对象;空数组必须写成 `nebula.array()`。保存配置 +后若出现语法或字段错误,Nebula 会保留上一份可用配置,修正并再次保存即可恢复 +自动重载。 diff --git a/licenses/LICENSE-LUA b/licenses/LICENSE-LUA new file mode 100644 index 0000000..c9ef531 --- /dev/null +++ b/licenses/LICENSE-LUA @@ -0,0 +1,21 @@ +Lua license + +Copyright (c) 1994-2017 Lua.org, PUC-Rio. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/licenses/LICENSE-MLUA b/licenses/LICENSE-MLUA new file mode 100644 index 0000000..66f2fa3 --- /dev/null +++ b/licenses/LICENSE-MLUA @@ -0,0 +1,25 @@ +Both mlua and rlua are distributed under the MIT license, which is reproduced +below: + +MIT License + +Copyright (c) 2019-2021 A. Orlenko +Copyright (c) 2017 rlua + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/nebula_app/Cargo.toml b/nebula_app/Cargo.toml index dc44243..674bb8c 100644 --- a/nebula_app/Cargo.toml +++ b/nebula_app/Cargo.toml @@ -108,6 +108,7 @@ winreg = "0.55" tauri-winrt-notification = "0.7.3" windows-sys = { version = "0.59", features = [ "Win32_UI_WindowsAndMessaging", + "Win32_Globalization", "Win32_UI_Controls_Dialogs", # System double-click time for the terminal's multi-click detection. "Win32_UI_Input_KeyboardAndMouse", diff --git a/nebula_app/src/cli.rs b/nebula_app/src/cli.rs index 9aa92dd..b205d48 100644 --- a/nebula_app/src/cli.rs +++ b/nebula_app/src/cli.rs @@ -5,7 +5,7 @@ use std::path::PathBuf; use std::rc::Rc; use nebula_config::SerdeReplace; -use clap::{ArgAction, Args, Parser, Subcommand, ValueHint}; +use clap::{ArgAction, Args, Parser, Subcommand, ValueEnum, ValueHint}; use log::{LevelFilter, error}; use serde::{Deserialize, Serialize}; use toml::Value; @@ -236,6 +236,8 @@ pub enum Subcommands { #[cfg(unix)] Msg(MessageOptions), Migrate(MigrateOptions), + /// Validate or create the Nebula configuration. + Config(ConfigOptions), /// Test system notification (toast) delivery. #[cfg(windows)] NotifyTest, @@ -250,6 +252,62 @@ pub enum Subcommands { Ssh(SshOptions), } +#[derive(Args, Debug)] +pub struct ConfigOptions { + #[clap(subcommand)] + pub command: ConfigCommand, +} + +#[derive(Subcommand, Debug)] +pub enum ConfigCommand { + /// Validate a Lua, TOML, or YAML configuration without opening the GUI. + Check(ConfigCheckOptions), + /// Create an annotated Lua configuration template. + Init(ConfigInitOptions), +} + +#[derive(Args, Debug)] +pub struct ConfigCheckOptions { + /// Configuration file to validate; otherwise use normal discovery. + #[clap(long, value_hint = ValueHint::FilePath)] + pub config_file: Option, +} + +#[derive(Args, Debug)] +pub struct ConfigInitOptions { + /// Lua configuration path; otherwise use the platform user config directory. + #[clap(long, value_hint = ValueHint::FilePath)] + pub config_file: Option, + + /// Comment language for the generated template. + #[clap(long, value_enum, default_value = "system")] + pub language: ConfigLanguage, + + /// Back up and replace an existing configuration. + #[clap(long)] + pub force: bool, +} + +#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConfigLanguage { + #[value(name = "system")] + System, + #[value(name = "zh-CN")] + ZhCn, + #[value(name = "en-US")] + EnUs, +} + +impl ConfigLanguage { + pub fn as_str(self) -> &'static str { + match self { + Self::System => "system", + Self::ZhCn => "zh-CN", + Self::EnUs => "en-US", + } + } +} + /// Options for the `setup-ai` subcommand. #[cfg(windows)] #[derive(Args, Debug)] @@ -519,6 +577,29 @@ mod tests { assert!(value.is_err()); } + #[test] + fn parses_config_check_and_init_subcommands() { + let check = + Options::try_parse_from(["nebula", "config", "check", "--config-file", "sample.lua"]) + .unwrap(); + assert!(matches!( + check.subcommands, + Some(Subcommands::Config(ConfigOptions { command: ConfigCommand::Check(_) })) + )); + + let init = + Options::try_parse_from(["nebula", "config", "init", "--language", "zh-CN"]).unwrap(); + assert!(matches!( + init.subcommands, + Some(Subcommands::Config(ConfigOptions { + command: ConfigCommand::Init(ConfigInitOptions { + language: ConfigLanguage::ZhCn, + .. + }) + })) + )); + } + #[test] fn float_option_as_value() { let value: Value = toml::from_str("float=3.4").unwrap(); diff --git a/nebula_app/src/config/bindings.rs b/nebula_app/src/config/bindings.rs index 4955a3a..cadc15b 100644 --- a/nebula_app/src/config/bindings.rs +++ b/nebula_app/src/config/bindings.rs @@ -5,7 +5,7 @@ use std::fmt::{self, Debug, Display}; use bitflags::bitflags; use serde::de::{self, Error as SerdeError, MapAccess, Unexpected, Visitor}; use serde::{Deserialize, Deserializer}; -use std::rc::Rc; +use std::sync::Arc; use toml::Value as SerdeValue; use winit::event::MouseButton; use winit::keyboard::{ @@ -97,7 +97,7 @@ pub enum Action { /// Regex keyboard hints. #[config(skip)] - Hint(Rc), + Hint(Arc), /// Move vi mode cursor. #[config(skip)] diff --git a/nebula_app/src/config/lua/mod.rs b/nebula_app/src/config/lua/mod.rs index 8cdb92c..a1f6c69 100644 --- a/nebula_app/src/config/lua/mod.rs +++ b/nebula_app/src/config/lua/mod.rs @@ -1,6 +1,6 @@ use std::fmt::{self, Display, Formatter}; use std::fs; -use std::path::{Path, PathBuf}; +use std::path::Path; use mlua::Value as LuaValue; use nebula_config::{ConfigDiagnostic, UnknownFieldPolicy, capture_diagnostics}; @@ -15,8 +15,7 @@ use convert::{LuaValueError, lua_value_to_toml}; use runtime::{LuaRuntime, ReloadSignal, builder_strict}; pub struct LuaGeneration { - pub lua: mlua::Lua, - pub watched_paths: Vec, + pub(crate) _lua: mlua::Lua, } pub struct LoadedLuaConfig { @@ -90,7 +89,11 @@ pub fn load_lua_file(path: &Path, reload: ReloadSignal) -> Result bool { self.0.swap(false, Ordering::AcqRel) } diff --git a/nebula_app/src/config/mod.rs b/nebula_app/src/config/mod.rs index 04be70b..f7160dc 100644 --- a/nebula_app/src/config/mod.rs +++ b/nebula_app/src/config/mod.rs @@ -1,9 +1,10 @@ use std::fmt::{self, Display, Formatter}; +use std::ops::Deref; use std::path::{Path, PathBuf}; use std::result::Result as StdResult; use std::{env, fs, io}; -use log::{debug, error, info, warn}; +use log::{error, info, warn}; use serde::Deserialize; use serde_yaml::Error as YamlError; use toml::de::Error as TomlError; @@ -18,9 +19,12 @@ pub mod font; pub mod general; pub mod lua; pub mod monitor; +pub mod reload; pub mod scrolling; pub mod selection; pub mod serde_utils; +pub mod source; +pub mod template; pub mod terminal; pub mod ui_config; pub mod window; @@ -60,6 +64,9 @@ pub enum Error { /// Invalid yaml. Yaml(YamlError), + + /// Lua configuration failed to load or validate. + Lua(lua::LuaConfigError), } impl std::error::Error for Error { @@ -70,6 +77,7 @@ impl std::error::Error for Error { Error::Toml(err) => err.source(), Error::TomlSe(err) => err.source(), Error::Yaml(err) => err.source(), + Error::Lua(err) => Some(err), } } } @@ -84,6 +92,7 @@ impl Display for Error { Error::Toml(err) => write!(f, "Config error: {err}"), Error::TomlSe(err) => write!(f, "Yaml conversion error: {err}"), Error::Yaml(err) => write!(f, "Config error: {err}"), + Error::Lua(err) => write!(f, "Lua config error: {err}"), } } } @@ -118,45 +127,53 @@ impl From for Error { } } +impl From for Error { + fn from(val: lua::LuaConfigError) -> Self { + Error::Lua(val) + } +} + +pub struct LoadedConfig { + pub config: UiConfig, + pub source: Option, + pub lua_generation: Option, +} + +impl Deref for LoadedConfig { + type Target = UiConfig; + + fn deref(&self) -> &Self::Target { + &self.config + } +} + /// Load the configuration file. -pub fn load(options: &mut Options) -> UiConfig { - let config_path = options - .config_file - .clone() - .or_else(|| installed_config("toml")) - .or_else(|| installed_config("yml")); +pub fn load(options: &mut Options) -> LoadedConfig { + let config_source = match source::discover(options.config_file.clone()) { + Ok(source) => source, + Err(err) => { + error!(target: LOG_TARGET_CONFIG, "Unable to discover configuration: {err}"); + None + }, + }; // Load the config using the following fallback behavior: // - Config path + CLI overrides // - CLI overrides // - Default - let mut config = config_path - .as_ref() - .and_then(|config_path| load_from(config_path).ok()) - .unwrap_or_else(|| { + let mut loaded = + config_source.as_ref().and_then(|source| load_source(source).ok()).unwrap_or_else(|| { let mut config = UiConfig::default(); - match config_path { - Some(config_path) => config.config_paths.push(config_path), + match config_source.as_ref() { + Some(source) => config.config_paths.push(source.primary_path.clone()), None => info!(target: LOG_TARGET_CONFIG, "No config file found; using default"), } - config + LoadedConfig { config, source: config_source.clone(), lua_generation: None } }); - after_loading(&mut config, options); + after_loading(&mut loaded.config, options); - config -} - -/// Attempt to reload the configuration file. -pub fn reload(config_path: &Path, options: &mut Options) -> Result { - debug!("Reloading configuration file: {config_path:?}"); - - // Load config, propagating errors. - let mut config = load_from(config_path)?; - - after_loading(&mut config, options); - - Ok(config) + loaded } /// Modifications after the `UiConfig` object is created. @@ -166,15 +183,39 @@ fn after_loading(config: &mut UiConfig, options: &mut Options) { } /// Load configuration file and log errors. -fn load_from(path: &Path) -> Result { - match read_config(path) { +pub fn load_source(source: &source::ConfigSource) -> Result { + let result: Result = match source.format { + source::ConfigFormat::Lua => { + lua::load_lua_file(&source.primary_path, lua::runtime::ReloadSignal::default()) + .map(|loaded| { + for diagnostic in &loaded.diagnostics { + warn!(target: LOG_TARGET_CONFIG, "{}", diagnostic.message); + } + LoadedConfig { + config: loaded.config, + source: Some(source.clone()), + lua_generation: Some(loaded.generation), + } + }) + .map_err(Error::Lua) + }, + source::ConfigFormat::Toml | source::ConfigFormat::Yaml => { + read_config(&source.primary_path).map(|config| LoadedConfig { + config, + source: Some(source.clone()), + lua_generation: None, + }) + }, + }; + + match result { Ok(config) => Ok(config), Err(Error::Io(io)) if io.kind() == io::ErrorKind::NotFound => { - error!(target: LOG_TARGET_CONFIG, "Unable to load config {path:?}: File not found"); + error!(target: LOG_TARGET_CONFIG, "Unable to load config {:?}: File not found", source.primary_path); Err(Error::Io(io)) }, Err(err) => { - error!(target: LOG_TARGET_CONFIG, "Unable to load config {path:?}: {err}"); + error!(target: LOG_TARGET_CONFIG, "Unable to load config {:?}: {err}", source.primary_path); Err(err) }, } diff --git a/nebula_app/src/config/monitor.rs b/nebula_app/src/config/monitor.rs index 7350958..17467f3 100644 --- a/nebula_app/src/config/monitor.rs +++ b/nebula_app/src/config/monitor.rs @@ -172,7 +172,7 @@ impl ConfigMonitor { /// This checks the supplied list of files against the monitored files to determine if a /// restart is necessary. pub fn needs_restart(&self, files: &[PathBuf]) -> bool { - Self::hash_paths(files).is_none_or(|hash| Some(hash) == self.watched_hash) + Self::hash_paths(files).is_none_or(|hash| Some(hash) != self.watched_hash) } /// Generate the hash for a list of paths. @@ -196,3 +196,23 @@ impl ConfigMonitor { Some(hasher.finish()) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn monitor_restarts_only_when_watched_paths_change() { + let original = vec![PathBuf::from("nebula.lua"), PathBuf::from("theme.lua")]; + let same_reordered = vec![PathBuf::from("theme.lua"), PathBuf::from("nebula.lua")]; + let hash = ConfigMonitor::hash_paths(&original); + let monitor = ConfigMonitor { + thread: std::thread::spawn(|| {}), + shutdown_tx: mpsc::channel().0, + watched_hash: hash, + }; + + assert!(!monitor.needs_restart(&same_reordered)); + assert!(monitor.needs_restart(&[PathBuf::from("nebula.lua")])); + } +} diff --git a/nebula_app/src/config/reload.rs b/nebula_app/src/config/reload.rs new file mode 100644 index 0000000..79c462d --- /dev/null +++ b/nebula_app/src/config/reload.rs @@ -0,0 +1,157 @@ +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::mpsc::{self, Receiver, Sender, TryRecvError}; +use std::thread::{self, JoinHandle}; + +use super::source::ConfigSource; +use super::{LoadedConfig, Result, load_source}; + +enum WorkerMessage { + Load(ReloadRequest), + Shutdown, +} + +#[derive(Debug, Clone)] +pub struct ReloadRequest { + pub request_id: u64, + pub source: ConfigSource, +} + +pub struct ReloadResult { + pub request_id: u64, + pub loaded: Result, +} + +pub struct ReloadWorker { + request_tx: Sender, + result_rx: Receiver, + latest_requested: Arc, + thread: Option>, +} + +impl ReloadWorker { + pub fn new(notify_ready: impl Fn() + Send + 'static) -> Self { + Self::with_loader(load_source, notify_ready) + } + + fn with_loader( + mut loader: impl FnMut(&ConfigSource) -> Result + Send + 'static, + notify_ready: impl Fn() + Send + 'static, + ) -> Self { + let (request_tx, request_rx) = mpsc::channel(); + let (result_tx, result_rx) = mpsc::channel(); + let latest_requested = Arc::new(AtomicU64::new(0)); + let worker_latest = latest_requested.clone(); + let thread = thread::Builder::new() + .name("config reload".to_owned()) + .spawn(move || { + while let Ok(message) = request_rx.recv() { + let WorkerMessage::Load(mut request) = message else { + break; + }; + + // 保存风暴期间只执行队列中最新一次请求,避免旧代次反复覆盖 UI。 + loop { + match request_rx.try_recv() { + Ok(WorkerMessage::Load(newer)) => request = newer, + Ok(WorkerMessage::Shutdown) => return, + Err(TryRecvError::Empty) => break, + Err(TryRecvError::Disconnected) => return, + } + } + + let loaded = loader(&request.source); + if request.request_id != worker_latest.load(Ordering::Acquire) { + continue; + } + if result_tx + .send(ReloadResult { request_id: request.request_id, loaded }) + .is_err() + { + return; + } + notify_ready(); + } + }) + .expect("failed to spawn config reload worker"); + + Self { request_tx, result_rx, latest_requested, thread: Some(thread) } + } + + pub fn request(&self, source: ConfigSource) -> u64 { + let request_id = self.latest_requested.fetch_add(1, Ordering::AcqRel) + 1; + let _ = self.request_tx.send(WorkerMessage::Load(ReloadRequest { request_id, source })); + request_id + } + + pub fn take_latest(&self) -> Option { + let latest = self.latest_requested.load(Ordering::Acquire); + let mut selected = None; + while let Ok(result) = self.result_rx.try_recv() { + if result.request_id == latest { + selected = Some(result); + } + } + selected + } +} + +impl Drop for ReloadWorker { + fn drop(&mut self) { + let _ = self.request_tx.send(WorkerMessage::Shutdown); + if let Some(thread) = self.thread.take() { + let _ = thread.join(); + } + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + use std::time::Duration; + + use serde::Deserialize; + + use super::*; + use crate::config::UiConfig; + use crate::config::source::ConfigFormat; + + fn loaded(history: usize) -> LoadedConfig { + let value: toml::Value = + toml::from_str(&format!("[scrolling]\nhistory={history}")).unwrap(); + LoadedConfig { + config: UiConfig::deserialize(value).unwrap(), + source: None, + lua_generation: None, + } + } + + fn source() -> ConfigSource { + ConfigSource { + primary_path: PathBuf::from("nebula.lua"), + format: ConfigFormat::Lua, + explicit: true, + } + } + + #[test] + fn worker_discards_obsolete_generations() { + let (ready_tx, ready_rx) = mpsc::channel(); + let worker = ReloadWorker::with_loader( + |_| { + std::thread::sleep(Duration::from_millis(20)); + Ok(loaded(777)) + }, + move || { + let _ = ready_tx.send(()); + }, + ); + worker.request(source()); + worker.request(source()); + let latest = worker.request(source()); + ready_rx.recv_timeout(Duration::from_secs(2)).unwrap(); + let result = worker.take_latest().unwrap(); + assert_eq!(result.request_id, latest); + assert_eq!(result.loaded.unwrap().config.scrolling.history(), 777); + } +} diff --git a/nebula_app/src/config/source.rs b/nebula_app/src/config/source.rs new file mode 100644 index 0000000..5614cf6 --- /dev/null +++ b/nebula_app/src/config/source.rs @@ -0,0 +1,187 @@ +use std::env; +use std::path::{Path, PathBuf}; + +pub const CONFIG_FILE_ENV: &str = "NEBULA_CONFIG_FILE"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConfigFormat { + Lua, + Toml, + Yaml, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConfigSource { + pub primary_path: PathBuf, + pub format: ConfigFormat, + pub explicit: bool, +} + +#[derive(Debug, Clone)] +pub struct DiscoveryRoots { + pub config_home: Option, + pub home_dir: Option, + pub executable_dir: Option, + pub system_dir: Option, + pub windows_portable: bool, +} + +impl DiscoveryRoots { + pub fn from_environment() -> Self { + let home_dir = home::home_dir(); + + #[cfg(windows)] + let config_home = dirs::config_dir(); + #[cfg(not(windows))] + let config_home = env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .filter(|path| path.is_absolute()) + .or_else(|| home_dir.as_ref().map(|home| home.join(".config"))); + + Self { + config_home, + home_dir, + executable_dir: env::current_exe() + .ok() + .and_then(|path| path.parent().map(Path::to_owned)), + #[cfg(windows)] + system_dir: None, + #[cfg(not(windows))] + system_dir: Some(PathBuf::from("/etc/nebula")), + windows_portable: cfg!(windows), + } + } + + fn candidates(&self, extension: &str) -> Vec { + let file_name = format!("nebula.{extension}"); + let mut paths = Vec::new(); + + if self.windows_portable { + if let Some(executable_dir) = &self.executable_dir { + paths.push(executable_dir.join(&file_name)); + } + } + + if let Some(config_home) = &self.config_home { + paths.push(config_home.join("nebula").join(&file_name)); + #[cfg(not(windows))] + paths.push(config_home.join(&file_name)); + } + + if let Some(home) = &self.home_dir { + #[cfg(not(windows))] + paths.push(home.join(".config").join("nebula").join(&file_name)); + paths.push(home.join(format!(".{file_name}"))); + } + + if let Some(system_dir) = &self.system_dir { + paths.push(system_dir.join(&file_name)); + } + + paths.dedup(); + paths + } +} + +pub fn discover(explicit_path: Option) -> Result, String> { + let environment_path = env::var_os(CONFIG_FILE_ENV).filter(|value| !value.is_empty()); + discover_with( + &DiscoveryRoots::from_environment(), + explicit_path, + environment_path.map(PathBuf::from), + ) +} + +pub fn discover_with( + roots: &DiscoveryRoots, + explicit_path: Option, + environment_path: Option, +) -> Result, String> { + if let Some(path) = explicit_path.or(environment_path) { + return source_for_path(path, true).map(Some); + } + + // 先按格式、再按位置查找,确保任何 Lua 配置都优先于旧格式。 + for extension in ["lua", "toml", "yml", "yaml"] { + for path in roots.candidates(extension) { + if path.is_file() { + return source_for_path(path, false).map(Some); + } + } + } + + Ok(None) +} + +pub fn source_for_path(path: PathBuf, explicit: bool) -> Result { + let extension = + path.extension().and_then(|extension| extension.to_str()).map(str::to_ascii_lowercase); + let format = match extension.as_deref() { + Some("lua") => ConfigFormat::Lua, + Some("toml") => ConfigFormat::Toml, + Some("yml" | "yaml") => ConfigFormat::Yaml, + _ => { + return Err(format!( + "unsupported configuration extension for {} (expected .lua, .toml, .yml, or .yaml)", + path.display() + )); + }, + }; + Ok(ConfigSource { primary_path: path, format, explicit }) +} + +pub fn default_lua_path() -> Result { + let roots = DiscoveryRoots::from_environment(); + roots + .config_home + .map(|root| root.join("nebula").join("nebula.lua")) + .or_else(|| roots.home_dir.map(|home| home.join(".nebula.lua"))) + .ok_or_else(|| "unable to determine the user configuration directory".to_owned()) +} + +#[cfg(test)] +mod tests { + use std::fs; + + use super::*; + + fn roots(base: &Path) -> DiscoveryRoots { + DiscoveryRoots { + config_home: Some(base.join("config")), + home_dir: Some(base.join("home")), + executable_dir: Some(base.join("bin")), + system_dir: Some(base.join("etc")), + windows_portable: true, + } + } + + #[test] + fn explicit_and_environment_paths_have_priority() { + let temp = tempfile::tempdir().unwrap(); + let roots = roots(temp.path()); + let explicit = temp.path().join("explicit.lua"); + let environment = temp.path().join("environment.toml"); + let source = discover_with(&roots, Some(explicit.clone()), Some(environment)).unwrap(); + assert_eq!(source.unwrap().primary_path, explicit); + } + + #[test] + fn lua_precedes_legacy_config_even_when_lua_is_broken() { + let temp = tempfile::tempdir().unwrap(); + let roots = roots(temp.path()); + fs::create_dir_all(temp.path().join("bin")).unwrap(); + fs::create_dir_all(temp.path().join("config/nebula")).unwrap(); + fs::write(temp.path().join("bin/nebula.toml"), "[scrolling]\nhistory=123").unwrap(); + fs::write(temp.path().join("config/nebula/nebula.lua"), "invalid lua").unwrap(); + + let source = discover_with(&roots, None, None).unwrap().unwrap(); + assert_eq!(source.format, ConfigFormat::Lua); + assert!(source.primary_path.ends_with("config/nebula/nebula.lua")); + } + + #[test] + fn rejects_unknown_explicit_extension() { + let error = source_for_path(PathBuf::from("nebula.json"), true).unwrap_err(); + assert!(error.contains("unsupported configuration extension")); + } +} diff --git a/nebula_app/src/config/template.rs b/nebula_app/src/config/template.rs new file mode 100644 index 0000000..8644eb7 --- /dev/null +++ b/nebula_app/src/config/template.rs @@ -0,0 +1,247 @@ +use std::env; +use std::fmt::{self, Display, Formatter}; +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +pub const CHINESE_TEMPLATE: &str = include_str!("templates/nebula.zh-CN.lua"); +pub const ENGLISH_TEMPLATE: &str = include_str!("templates/nebula.en-US.lua"); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TemplateLanguage { + ZhCn, + EnUs, +} + +impl TemplateLanguage { + fn contents(self) -> &'static str { + match self { + Self::ZhCn => CHINESE_TEMPLATE, + Self::EnUs => ENGLISH_TEMPLATE, + } + } +} + +#[derive(Debug)] +pub enum TemplateError { + AlreadyExists(PathBuf), + InvalidLanguage(String), + Io(std::io::Error), +} + +impl Display for TemplateError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + match self { + Self::AlreadyExists(path) => write!( + formatter, + "configuration already exists at {}; use --force to back it up and replace it", + path.display() + ), + Self::InvalidLanguage(language) => write!( + formatter, + "unsupported language {language:?}; expected system, zh-CN, or en-US" + ), + Self::Io(error) => Display::fmt(error, formatter), + } + } +} + +impl std::error::Error for TemplateError {} + +impl From for TemplateError { + fn from(error: std::io::Error) -> Self { + Self::Io(error) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TemplateWrite { + pub path: PathBuf, + pub backup: Option, + pub created: bool, +} + +pub fn resolve_template_language( + explicit: Option<&str>, + saved: Option<&str>, + locale: Option<&str>, +) -> Result { + let requested = explicit.or(saved).unwrap_or("system"); + match requested { + "zh-CN" => Ok(TemplateLanguage::ZhCn), + "en-US" => Ok(TemplateLanguage::EnUs), + "system" => Ok(if locale.is_some_and(is_chinese_locale) { + TemplateLanguage::ZhCn + } else { + TemplateLanguage::EnUs + }), + other => Err(TemplateError::InvalidLanguage(other.to_owned())), + } +} + +pub fn system_locale() -> Option { + system_locale_impl().or_else(environment_locale) +} + +#[cfg(windows)] +fn system_locale_impl() -> Option { + use windows_sys::Win32::Globalization::GetUserDefaultLocaleName; + + let mut buffer = [0_u16; 85]; + let length = unsafe { GetUserDefaultLocaleName(buffer.as_mut_ptr(), buffer.len() as i32) }; + if length <= 1 { + return None; + } + String::from_utf16(&buffer[..length as usize - 1]).ok() +} + +#[cfg(not(windows))] +fn system_locale_impl() -> Option { + None +} + +fn environment_locale() -> Option { + ["LC_ALL", "LC_MESSAGES", "LANG"] + .into_iter() + .find_map(|name| env::var(name).ok().filter(|value| !value.is_empty())) +} + +fn is_chinese_locale(locale: &str) -> bool { + locale + .split(['-', '_', '.', '@']) + .next() + .is_some_and(|language| language.eq_ignore_ascii_case("zh")) +} + +pub fn write_template( + path: &Path, + language: TemplateLanguage, + force: bool, +) -> Result { + let existed = path.exists(); + if existed && !force { + return Err(TemplateError::AlreadyExists(path.to_owned())); + } + + let parent = + path.parent().filter(|parent| !parent.as_os_str().is_empty()).unwrap_or(Path::new(".")); + fs::create_dir_all(parent)?; + + // 必须先完成备份;备份失败时绝不能触碰用户当前可用的配置。 + let backup = if existed { Some(create_backup(path)?) } else { None }; + let mut temporary = tempfile::NamedTempFile::new_in(parent)?; + temporary.write_all(language.contents().as_bytes())?; + temporary.as_file_mut().sync_all()?; + temporary.persist(path).map_err(|error| TemplateError::Io(error.error))?; + + Ok(TemplateWrite { path: path.to_owned(), backup, created: !existed }) +} + +pub fn ensure_user_lua_config( + path: &Path, + language: TemplateLanguage, +) -> Result { + if path.exists() { + return Ok(TemplateWrite { path: path.to_owned(), backup: None, created: false }); + } + write_template(path, language, false) +} + +fn create_backup(path: &Path) -> Result { + let timestamp = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_millis(); + for sequence in 0..1000_u16 { + let suffix = if sequence == 0 { + format!("bak-{timestamp}") + } else { + format!("bak-{timestamp}-{sequence}") + }; + let backup = path.with_extension(format!("lua.{suffix}")); + let mut destination = + match fs::OpenOptions::new().write(true).create_new(true).open(&backup) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(error) => return Err(error), + }; + let mut source = fs::File::open(path)?; + std::io::copy(&mut source, &mut destination)?; + destination.sync_all()?; + return Ok(backup); + } + Err(std::io::Error::new( + std::io::ErrorKind::AlreadyExists, + "unable to allocate a unique configuration backup name", + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::lua::runtime::ReloadSignal; + + fn active_lines(template: &str) -> Vec<&str> { + template + .lines() + .map(str::trim) + .filter(|line| !line.is_empty() && !line.starts_with("--")) + .collect() + } + + #[test] + fn localized_templates_have_equivalent_valid_lua() { + assert_eq!(active_lines(CHINESE_TEMPLATE), active_lines(ENGLISH_TEMPLATE)); + let temp = tempfile::tempdir().unwrap(); + for (name, contents) in [("zh.lua", CHINESE_TEMPLATE), ("en.lua", ENGLISH_TEMPLATE)] { + let path = temp.path().join(name); + fs::write(&path, contents).unwrap(); + crate::config::lua::load_lua_file(&path, ReloadSignal::default()).unwrap(); + } + } + + #[test] + fn language_resolution_supports_all_public_values() { + assert_eq!( + resolve_template_language(Some("zh-CN"), None, None).unwrap(), + TemplateLanguage::ZhCn + ); + assert_eq!( + resolve_template_language(Some("en-US"), None, Some("zh_CN")).unwrap(), + TemplateLanguage::EnUs + ); + assert_eq!( + resolve_template_language(Some("system"), None, Some("zh-Hans-CN")).unwrap(), + TemplateLanguage::ZhCn + ); + } + + #[test] + fn existing_file_requires_force_and_force_creates_backup() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("nebula.lua"); + fs::write(&path, "return { existing = true }").unwrap(); + assert!(matches!( + write_template(&path, TemplateLanguage::ZhCn, false), + Err(TemplateError::AlreadyExists(_)) + )); + assert_eq!(fs::read_to_string(&path).unwrap(), "return { existing = true }"); + + let result = write_template(&path, TemplateLanguage::EnUs, true).unwrap(); + assert_eq!( + fs::read_to_string(result.backup.unwrap()).unwrap(), + "return { existing = true }" + ); + assert_eq!(fs::read_to_string(path).unwrap(), ENGLISH_TEMPLATE); + } + + #[test] + fn ensure_never_rewrites_an_existing_file() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("nebula.lua"); + let first = ensure_user_lua_config(&path, TemplateLanguage::EnUs).unwrap(); + assert!(first.created); + let original = fs::read_to_string(&path).unwrap(); + let second = ensure_user_lua_config(&path, TemplateLanguage::ZhCn).unwrap(); + assert!(!second.created); + assert_eq!(fs::read_to_string(path).unwrap(), original); + } +} diff --git a/nebula_app/src/config/templates/nebula.en-US.lua b/nebula_app/src/config/templates/nebula.en-US.lua new file mode 100644 index 0000000..e396ec7 --- /dev/null +++ b/nebula_app/src/config/templates/nebula.en-US.lua @@ -0,0 +1,67 @@ +local nebula = require 'nebula' +local config = nebula.config_builder() + +-- Nebula Lua configuration. Run `nebula config check` after editing to validate syntax and fields. +-- Field names and enum values use stable English identifiers; comments explain safe edits. + +-- Number of scrollback lines to retain. Larger values consume more memory. +config.scrolling = { + history = 10000, +} + +-- Font example. Remove the leading `--` from each line below to enable it. +-- config.font = { +-- normal = { family = 'Maple Mono NF CN', style = 'Regular' }, +-- size = 13.0, +-- builtin_box_drawing = true, +-- } + +-- Window example. opacity ranges from 0.0 to 1.0. +-- config.window = { +-- opacity = 0.96, +-- decorations = 'Full', +-- dynamic_title = true, +-- padding = { x = 8, y = 8 }, +-- } + +-- Cursor example. +-- config.cursor = { +-- style = { shape = 'Beam', blinking = 'On' }, +-- unfocused_hollow = true, +-- } + +-- Terminal foreground, background, and the ANSI palette can be overridden independently. +-- config.colors = { +-- primary = { foreground = '#d8dee9', background = '#16181d' }, +-- } + +-- The default shell can be a program string or a program with arguments. +-- config.terminal = { +-- shell = { program = 'pwsh.exe', args = { '-NoLogo' } }, +-- } + +-- Linux example: select a shell for the current platform while sharing this file with Windows. +-- if nebula.platform.os == 'linux' then +-- config.terminal = { shell = '/bin/bash' } +-- end + +-- Environment variables injected into every new terminal process. +-- config.env = { +-- EDITOR = 'nvim', +-- } + +-- Quick-launch profiles. An empty list must use nebula.array(), not a plain empty table {}. +-- config.profiles = nebula.array { +-- { name = 'Production SSH', command = 'ssh', args = { 'user@server' } }, +-- { name = 'Project', command = 'pwsh.exe', args = { '-NoLogo' }, cwd = 'D:/src/project' }, +-- } + +-- Large configurations can use sibling modules; for example, theme.lua can return a colors table: +-- local theme = require 'theme' +-- config.colors = theme +-- Required Lua files are automatically added to the live-reload watch list. + +-- Unknown fields are strict by default. This can assist migration but should not stay disabled: +-- config:set_strict_mode(false) + +return config diff --git a/nebula_app/src/config/templates/nebula.zh-CN.lua b/nebula_app/src/config/templates/nebula.zh-CN.lua new file mode 100644 index 0000000..1d26955 --- /dev/null +++ b/nebula_app/src/config/templates/nebula.zh-CN.lua @@ -0,0 +1,67 @@ +local nebula = require 'nebula' +local config = nebula.config_builder() + +-- Nebula Lua 配置文件。修改后可运行 `nebula config check` 检查语法和字段。 +-- 字段名和枚举值使用稳定的英文标识;注释使用简体中文,方便直接修改。 + +-- 保留的滚动历史行数。较大的值会占用更多内存。 +config.scrolling = { + history = 10000, +} + +-- 字体示例。取消下面各行开头的 `--` 即可启用。 +-- config.font = { +-- normal = { family = 'Maple Mono NF CN', style = 'Regular' }, +-- size = 13.0, +-- builtin_box_drawing = true, +-- } + +-- 窗口示例。opacity 范围为 0.0 到 1.0。 +-- config.window = { +-- opacity = 0.96, +-- decorations = 'Full', +-- dynamic_title = true, +-- padding = { x = 8, y = 8 }, +-- } + +-- 光标示例。 +-- config.cursor = { +-- style = { shape = 'Beam', blinking = 'On' }, +-- unfocused_hollow = true, +-- } + +-- 终端前景色、背景色与 ANSI 调色板可以分别覆盖。 +-- config.colors = { +-- primary = { foreground = '#d8dee9', background = '#16181d' }, +-- } + +-- 默认 Shell 可以是程序字符串,也可以带参数。 +-- config.terminal = { +-- shell = { program = 'pwsh.exe', args = { '-NoLogo' } }, +-- } + +-- Linux 示例:根据运行平台选择 Shell。此段可以和 Windows 共用同一份配置。 +-- if nebula.platform.os == 'linux' then +-- config.terminal = { shell = '/bin/bash' } +-- end + +-- 注入到所有新终端进程的环境变量。 +-- config.env = { +-- EDITOR = 'nvim', +-- } + +-- 快速启动配置。空列表必须写成 nebula.array(),不能写普通空表 {}。 +-- config.profiles = nebula.array { +-- { name = 'Production SSH', command = 'ssh', args = { 'user@server' } }, +-- { name = 'Project', command = 'pwsh.exe', args = { '-NoLogo' }, cwd = 'D:/src/project' }, +-- } + +-- 大型配置可拆分到同目录模块,例如 theme.lua 返回一个 colors 表: +-- local theme = require 'theme' +-- config.colors = theme +-- 被 require 的 Lua 文件会自动加入热重载监视列表。 + +-- 配置默认严格检查未知字段。迁移旧配置时可临时关闭,但不建议长期使用: +-- config:set_strict_mode(false) + +return config diff --git a/nebula_app/src/config/ui_config.rs b/nebula_app/src/config/ui_config.rs index d44221b..093c3de 100644 --- a/nebula_app/src/config/ui_config.rs +++ b/nebula_app/src/config/ui_config.rs @@ -1,10 +1,9 @@ -use std::cell::{OnceCell, RefCell}; use std::collections::HashMap; use std::error::Error; use std::fmt::{self, Formatter}; use std::mem; use std::path::PathBuf; -use std::rc::Rc; +use std::sync::{Arc, Mutex, OnceLock}; use log::{error, warn}; use serde::de::{Error as SerdeError, MapAccess, Visitor}; @@ -259,14 +258,14 @@ pub struct Hints { alphabet: HintsAlphabet, /// All configured terminal hints. - pub enabled: Vec>, + pub enabled: Vec>, } impl Default for Hints { fn default() -> Self { // Add URL hint by default when no other hint is present. let pattern = LazyRegexVariant::Pattern(String::from(URL_REGEX)); - let regex = LazyRegex(Rc::new(RefCell::new(pattern))); + let regex = LazyRegex(Arc::new(Mutex::new(pattern))); let content = HintContent::new(Some(regex), true); #[cfg(not(any(target_os = "macos", windows)))] @@ -280,7 +279,7 @@ impl Default for Hints { }); Self { - enabled: vec![Rc::new(Hint { + enabled: vec![Arc::new(Hint { content, action, persist: false, @@ -479,12 +478,12 @@ pub struct HintBinding { /// Cache for on-demand [`HintBinding`] to [`KeyBinding`] conversion. #[serde(skip)] - cache: OnceCell, + cache: OnceLock, } impl HintBinding { /// Get the key binding for a hint. - pub fn key_binding(&self, hint: &Rc) -> &KeyBinding { + pub fn key_binding(&self, hint: &Arc) -> &KeyBinding { self.cache.get_or_init(|| KeyBinding { trigger: self.key.clone(), mods: self.mods.0, @@ -517,8 +516,8 @@ pub struct HintMouse { } /// Lazy regex with interior mutability. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct LazyRegex(Rc>); +#[derive(Clone, Debug)] +pub struct LazyRegex(Arc>); impl LazyRegex { /// Execute a function with the compiled regex DFAs as parameter. @@ -526,7 +525,7 @@ impl LazyRegex { where F: FnMut(&mut RegexSearch) -> T, { - self.0.borrow_mut().compiled().map(f) + self.0.lock().unwrap_or_else(|lock| lock.into_inner()).compiled().map(f) } } @@ -536,7 +535,7 @@ impl<'de> Deserialize<'de> for LazyRegex { D: Deserializer<'de>, { let regex = LazyRegexVariant::Pattern(String::deserialize(deserializer)?); - Ok(Self(Rc::new(RefCell::new(regex)))) + Ok(Self(Arc::new(Mutex::new(regex)))) } } @@ -545,7 +544,7 @@ impl Serialize for LazyRegex { where S: Serializer, { - let variant = self.0.borrow(); + let variant = self.0.lock().unwrap_or_else(|lock| lock.into_inner()); let regex = match &*variant { LazyRegexVariant::Compiled(regex, _) => regex, LazyRegexVariant::Uncompilable(regex) => regex, @@ -555,6 +554,19 @@ impl Serialize for LazyRegex { } } +impl PartialEq for LazyRegex { + fn eq(&self, other: &Self) -> bool { + if Arc::ptr_eq(&self.0, &other.0) { + return true; + } + let value = self.0.lock().unwrap_or_else(|lock| lock.into_inner()); + let other = other.0.lock().unwrap_or_else(|lock| lock.into_inner()); + *value == *other + } +} + +impl Eq for LazyRegex {} + /// Regex which is compiled on demand, to avoid expensive computations at startup. #[derive(Clone, Debug)] pub enum LazyRegexVariant { diff --git a/nebula_app/src/config_cli.rs b/nebula_app/src/config_cli.rs new file mode 100644 index 0000000..a357bc0 --- /dev/null +++ b/nebula_app/src/config_cli.rs @@ -0,0 +1,117 @@ +use std::path::PathBuf; + +use crate::cli::{ConfigCommand, ConfigInitOptions, ConfigOptions}; +use crate::config; +use crate::config::source::{self, ConfigFormat}; +use crate::config::template; + +pub fn run(options: ConfigOptions) -> i32 { + match options.command { + ConfigCommand::Check(options) => run_check(options.config_file), + ConfigCommand::Init(options) => run_init(options), + } +} + +fn run_check(explicit_path: Option) -> i32 { + let source = match source::discover(explicit_path) { + Ok(Some(source)) => source, + Ok(None) => { + eprintln!("No Nebula configuration file was found."); + return 1; + }, + Err(error) => { + eprintln!("Configuration discovery failed: {error}"); + return 1; + }, + }; + + match config::load_source(&source) { + Ok(loaded) => { + println!("Configuration is valid: {}", source.primary_path.display()); + for path in &loaded.config.config_paths { + if path != &source.primary_path { + println!(" module: {}", path.display()); + } + } + 0 + }, + Err(error) => { + eprintln!("{}: {error}", source.primary_path.display()); + 1 + }, + } +} + +fn run_init(options: ConfigInitOptions) -> i32 { + let path = match options.config_file.map(Ok).unwrap_or_else(source::default_lua_path) { + Ok(path) => path, + Err(error) => { + eprintln!("Unable to choose a configuration path: {error}"); + return 1; + }, + }; + if !matches!(source::source_for_path(path.clone(), true), Ok(source) if source.format == ConfigFormat::Lua) + { + eprintln!("Lua configuration path must end in .lua: {}", path.display()); + return 1; + } + + let requested_language = options.language.as_str(); + let language = match template::resolve_template_language( + Some(requested_language), + None, + template::system_locale().as_deref(), + ) { + Ok(language) => language, + Err(error) => { + eprintln!("Unable to select template language: {error}"); + return 1; + }, + }; + + match template::write_template(&path, language, options.force) { + Ok(written) => { + println!("Wrote Lua configuration: {}", written.path.display()); + if let Some(backup) = written.backup { + println!("Backed up previous configuration: {}", backup.display()); + } + 0 + }, + Err(error) => { + eprintln!("Unable to write Lua configuration: {error}"); + 1 + }, + } +} + +#[cfg(test)] +mod tests { + use std::fs; + + use super::*; + use crate::cli::{ConfigInitOptions, ConfigLanguage}; + + #[test] + fn check_accepts_valid_lua_and_rejects_invalid_lua() { + let temp = tempfile::tempdir().unwrap(); + let valid = temp.path().join("valid.lua"); + let invalid = temp.path().join("invalid.lua"); + fs::write(&valid, "return { scrolling = { history = 321 } }").unwrap(); + fs::write(&invalid, "return { windwo = {} }").unwrap(); + assert_eq!(run_check(Some(valid)), 0); + assert_eq!(run_check(Some(invalid)), 1); + } + + #[test] + fn init_writes_requested_localized_template() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("nebula.lua"); + let options = ConfigInitOptions { + config_file: Some(path.clone()), + language: ConfigLanguage::ZhCn, + force: false, + }; + assert_eq!(run_init(options), 0); + assert_eq!(fs::read_to_string(path).unwrap(), template::CHINESE_TEMPLATE); + } +} diff --git a/nebula_app/src/display/hint.rs b/nebula_app/src/display/hint.rs index d7c0560..f8b1b21 100644 --- a/nebula_app/src/display/hint.rs +++ b/nebula_app/src/display/hint.rs @@ -2,7 +2,7 @@ use std::borrow::Cow; use std::cmp::{self, Reverse}; use std::collections::HashSet; use std::iter; -use std::rc::Rc; +use std::sync::Arc; use ahash::RandomState; use winit::keyboard::ModifiersState; @@ -25,7 +25,7 @@ const HINT_SPLIT_PERCENTAGE: f32 = 0.5; /// Keyboard regex hint state. pub struct HintState { /// Hint currently in use. - hint: Option>, + hint: Option>, /// Alphabet for hint labels. alphabet: String, @@ -58,7 +58,7 @@ impl HintState { } /// Start the hint selection process. - pub fn start(&mut self, hint: Rc) { + pub fn start(&mut self, hint: Arc) { self.hint = Some(hint); } @@ -201,7 +201,7 @@ pub struct HintMatch { hyperlink: Option, /// Hint which triggered this match. - hint: Rc, + hint: Arc, } impl HintMatch { diff --git a/nebula_app/src/display/mod.rs b/nebula_app/src/display/mod.rs index 4e65310..b26eba7 100644 --- a/nebula_app/src/display/mod.rs +++ b/nebula_app/src/display/mod.rs @@ -1390,6 +1390,8 @@ pub struct Display { /// window is following the operating system. nebula_window_theme_override: Option, pub nebula_settings_open: bool, + /// Paths from the last successful app configuration generation. + nebula_config_paths: Vec, /// Settings content scroll offset in scaled px (0 = top of the section). nebula_settings_scroll: f32, /// Command palette (Ctrl+Shift+P): fuzzy launcher model + UI state. @@ -1811,6 +1813,7 @@ impl Display { nebula_system_theme, nebula_window_theme_override, nebula_settings_open: false, + nebula_config_paths: config.config_paths.clone(), nebula_settings_scroll: 0.0, nebula_palette: command_palette::CommandPalette::new(), nebula_detected_shells: None, @@ -3002,7 +3005,7 @@ impl Display { self.nebula_font_picker_open = false; } #[cfg(not(windows))] - self.open_settings_file(); + self.open_user_config_file(); } /// WT-style default-shell picker (command palette mode). Kept for compatibility. @@ -3124,13 +3127,41 @@ impl Display { } #[cfg(not(windows))] { - self.open_settings_file(); + self.open_user_config_file(); } } - pub fn open_settings_file(&mut self) { + pub fn open_user_config_file(&mut self) { self.persist_nebula_settings(); - let path = nebula_data_dir().join("nebula_settings.txt"); + let active_lua = self.nebula_config_paths.first().filter(|path| { + path.extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| extension.eq_ignore_ascii_case("lua")) + }); + let path = active_lua.cloned().or_else(|| crate::config::source::default_lua_path().ok()); + let Some(path) = path else { + log::error!( + target: crate::logging::LOG_TARGET_CONFIG, + "Unable to determine Lua config path" + ); + return; + }; + if !path.exists() { + let language = crate::config::template::resolve_template_language( + None, + None, + crate::config::template::system_locale().as_deref(), + ) + .unwrap_or(crate::config::template::TemplateLanguage::EnUs); + if let Err(error) = crate::config::template::ensure_user_lua_config(&path, language) { + log::error!( + target: crate::logging::LOG_TARGET_CONFIG, + "Unable to create Lua config {:?}: {error}", + path + ); + return; + } + } #[cfg(windows)] let _ = std::process::Command::new("notepad.exe").arg(&path).spawn(); #[cfg(target_os = "macos")] @@ -5301,6 +5332,7 @@ impl Display { /// Update to a new configuration. pub fn update_config(&mut self, config: &UiConfig) { + self.nebula_config_paths.clone_from(&config.config_paths); self.damage_tracker.debug = config.debug.highlight_damage; self.visual_bell.update_config(&config.bell); // Refresh the base scheme, then re-apply the active theme's restyle. diff --git a/nebula_app/src/event.rs b/nebula_app/src/event.rs index ae0c0b1..597434f 100644 --- a/nebula_app/src/event.rs +++ b/nebula_app/src/event.rs @@ -50,6 +50,7 @@ use nebula_terminal::vte::ansi::NamedColor; use crate::cli::{IpcConfig, ParsedOptions}; use crate::cli::{Options as CliOptions, WindowOptions}; use crate::clipboard::Clipboard; +use crate::config::reload::ReloadWorker; use crate::config::ui_config::{HintAction, HintInternalAction}; use crate::config::{self, UiConfig}; #[cfg(not(windows))] @@ -101,6 +102,10 @@ pub struct Processor { global_ipc_options: ParsedOptions, cli_options: CliOptions, config: Rc, + // Lua 回调与模块状态属于当前成功代次,失败重载不能提前释放它。 + lua_generation: Option, + config_source: Option, + config_reload_worker: ReloadWorker, /// The quick (Quake) terminal window, once created. quick_terminal: Option, /// Whether the quick terminal is currently shown (target state). @@ -121,11 +126,16 @@ pub struct Processor { impl Processor { /// Create a new event processor. pub fn new( - config: UiConfig, + loaded_config: config::LoadedConfig, cli_options: CliOptions, event_loop: &EventLoop, ) -> Processor { let proxy = event_loop.create_proxy(); + let reload_proxy = proxy.clone(); + let config_reload_worker = ReloadWorker::new(move || { + let event = Event::new(EventType::ConfigReloadReady, None); + let _ = reload_proxy.send_event(event); + }); let scheduler = Scheduler::new(proxy.clone()); let initial_window_options = Some(cli_options.window_options.clone()); @@ -141,11 +151,13 @@ impl Processor { // The monitor watches the config file for changes and reloads it. Pending // config changes are processed in the main loop. let mut config_monitor = None; - if config.live_config_reload() { + if loaded_config.live_config_reload() { config_monitor = - ConfigMonitor::new(config.config_paths.clone(), event_loop.create_proxy()); + ConfigMonitor::new(loaded_config.config_paths.clone(), event_loop.create_proxy()); } + let config::LoadedConfig { config, source: config_source, lua_generation } = loaded_config; + // Register the global quick-terminal toggle hotkey (Ctrl+`). let (global_hotkey, quick_hotkey_id) = Self::init_quick_hotkey(); @@ -157,6 +169,9 @@ impl Processor { scheduler, gl_config: None, config: Rc::new(config), + lua_generation, + config_source, + config_reload_worker, clipboard, windows: Default::default(), #[cfg(unix)] @@ -587,9 +602,22 @@ impl ApplicationHandler for Processor { } } - // Load config and update each terminal. - if let Ok(config) = config::reload(&path, &mut self.cli_options) { - self.config = Rc::new(config); + match config::source::source_for_path(path, true) { + Ok(source) => { + self.config_reload_worker.request(source); + }, + Err(error) => error!("Unable to reload configuration: {error}"), + } + }, + (EventType::ConfigReloadReady, _) => { + // 失败结果只产生诊断;当前配置和 Lua 代次保持不变。 + if let Some(result) = self.config_reload_worker.take_latest() + && let Ok(mut loaded) = result.loaded + { + self.cli_options.override_config(&mut loaded.config); + self.lua_generation = loaded.lua_generation; + self.config_source = loaded.source; + self.config = Rc::new(loaded.config); // Restart config monitor if imports changed. if let Some(monitor) = self.config_monitor.take() { @@ -914,6 +942,7 @@ impl From for WinitEvent { pub enum EventType { Terminal(TerminalEvent), ConfigReload(PathBuf), + ConfigReloadReady, Message(Message), Scroll(Scroll), CreateWindow(WindowOptions), @@ -2730,6 +2759,7 @@ impl input::Processor> { EventType::IpcConfig(_) | EventType::IpcGetConfig(..) | EventType::Shutdown => (), EventType::Message(_) | EventType::ConfigReload(_) + | EventType::ConfigReloadReady | EventType::CreateWindow(_) | EventType::Frame => (), }, diff --git a/nebula_app/src/input/keyboard.rs b/nebula_app/src/input/keyboard.rs index 3392f78..c25725e 100644 --- a/nebula_app/src/input/keyboard.rs +++ b/nebula_app/src/input/keyboard.rs @@ -723,7 +723,7 @@ impl> Processor { .ctx .nebula_tab(TabRequest::SplitToggle(crate::display::SplitDirection::TopBottom)), OpenSettings => self.ctx.display().toggle_settings(), - OpenSettingsFile => self.ctx.display().open_settings_file(), + OpenSettingsFile => self.ctx.display().open_user_config_file(), ToggleGhost => self.ctx.display().toggle_ghost(), CycleAccept => self.ctx.display().cycle_accept(), PickBackgroundImage => self.ctx.display().pick_background_image(), diff --git a/nebula_app/src/input/mod.rs b/nebula_app/src/input/mod.rs index c3cdfc2..b855eba 100644 --- a/nebula_app/src/input/mod.rs +++ b/nebula_app/src/input/mod.rs @@ -1706,7 +1706,7 @@ impl> Processor { return; }, crate::display::SettingsHit::OpenConfigFile => { - self.ctx.display().open_settings_file(); + self.ctx.display().open_user_config_file(); self.ctx.mark_dirty(); return; }, diff --git a/nebula_app/src/main.rs b/nebula_app/src/main.rs index 615f0ec..edee8bc 100644 --- a/nebula_app/src/main.rs +++ b/nebula_app/src/main.rs @@ -31,6 +31,7 @@ mod ai_hook; mod cli; mod clipboard; mod config; +mod config_cli; mod daemon; mod display; mod event; @@ -117,6 +118,7 @@ fn main() -> Result<(), Box> { #[cfg(unix)] Some(Subcommands::Msg(options)) => msg(options)?, Some(Subcommands::Migrate(options)) => migrate::migrate(options), + Some(Subcommands::Config(options)) => std::process::exit(config_cli::run(options)), #[cfg(windows)] Some(Subcommands::NotifyTest) => std::process::exit(crate::notify::notify_test()), #[cfg(windows)] diff --git a/scripts/package-release.ps1 b/scripts/package-release.ps1 index 8e2ac67..fea6479 100644 --- a/scripts/package-release.ps1 +++ b/scripts/package-release.ps1 @@ -35,7 +35,10 @@ $manifest = [ordered]@{ 'fonts/MapleMonoNormal-NF-CN-Regular.ttf' = Join-Path $repo 'assets\fonts\MapleMonoNormal-NF-CN-Regular.ttf' 'docs/CHANGELOG.md' = Join-Path $repo 'CHANGELOG.md' 'docs/INSTALL.md' = Join-Path $repo 'INSTALL.md' + 'docs/lua-configuration.md' = Join-Path $repo 'docs\lua-configuration.md' 'licenses/LICENSE' = Join-Path $repo 'LICENSE' + 'licenses/LICENSE-LUA' = Join-Path $repo 'licenses\LICENSE-LUA' + 'licenses/LICENSE-MLUA' = Join-Path $repo 'licenses\LICENSE-MLUA' 'licenses/THIRD-PARTY-NOTICES' = Join-Path $repo 'THIRD-PARTY-NOTICES' } diff --git a/scripts/tests/package-release.tests.ps1 b/scripts/tests/package-release.tests.ps1 index 079fe6d..c926ad7 100644 --- a/scripts/tests/package-release.tests.ps1 +++ b/scripts/tests/package-release.tests.ps1 @@ -57,8 +57,11 @@ try { 'README.md' 'docs/CHANGELOG.md' 'docs/INSTALL.md' + 'docs/lua-configuration.md' 'fonts/MapleMonoNormal-NF-CN-Regular.ttf' 'licenses/LICENSE' + 'licenses/LICENSE-LUA' + 'licenses/LICENSE-MLUA' 'licenses/THIRD-PARTY-NOTICES' 'nebula.exe' 'runtime/OpenConsole.exe'