From 1f796a0f9525157eb2c890f36f647faf330913bc Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Thu, 5 Nov 2026 17:42:50 -0800 Subject: [PATCH 1/6] =?UTF-8?q?=E6=96=B0=E5=A2=9E=20lithe-core-rules=20?= =?UTF-8?q?=E5=90=8C=E6=AD=A5=E8=A7=84=E5=88=99=E6=A1=A5=E5=92=8C=20wasm?= =?UTF-8?q?=20=E6=9E=84=E5=BB=BA=E6=8E=A5=E5=85=A5=20(#1662)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增纯 Rust crate rust/lithe-core-rules:只依赖 serde,不碰 I/O 和时钟,能编译到 wasm32;lithe-core 依赖它,并通过现有 JSON 分发暴露 keymap.modifierGestureStep,envelope 与错误码和其他 Core 命令一致 - wasm ABI 不用 wasm-bindgen:导出 alloc/dealloc/execute/result_len,输入输出为 Core envelope JSON;release 约 141 KB - Windows 新增 src/core/core-rules.ts:启动时异步 instantiate(WebView2 不允许同步编译大模块),之后 executeCoreRuleSync 同步调用;加载失败显示本地化错误,不回退 TS 算法 - 新增 scripts/windows/prepare/prepare-core-rules-wasm.mjs,接入 dev/build、ci-windows 前端 job 和两个 Windows 发布工作流;产物不提交 - 注释检查、rustfmt、日志检查和 Rust 单测纳入新 crate - 工作树资源清单登记不可复用的 core-rules-wasm,补测试和 docs/ci-builds.md - 新增 coreRules.* 中英词条 Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/ci-windows.yml | 10 ++ .github/workflows/release-preview-windows.yml | 2 +- .github/workflows/release-windows.yml | 2 +- .gitignore | 3 + docs/ci-builds.md | 11 ++ rust/Cargo.lock | 9 ++ rust/Cargo.toml | 2 +- rust/lithe-core-rules/Cargo.toml | 14 ++ rust/lithe-core-rules/src/dispatch.rs | 55 +++++++ rust/lithe-core-rules/src/error.rs | 43 +++++ rust/lithe-core-rules/src/keymap/mod.rs | 8 + .../src/keymap/modifier_gesture.rs | 153 ++++++++++++++++++ rust/lithe-core-rules/src/lib.rs | 23 +++ rust/lithe-core-rules/src/wasm.rs | 65 ++++++++ .../tests/modifier_gesture_fixture.rs | 78 +++++++++ rust/lithe-core/Cargo.toml | 1 + rust/lithe-core/src/protocol/command.rs | 4 + rust/lithe-core/src/protocol/error.rs | 15 ++ rust/lithe-core/src/runtime/dispatcher.rs | 6 + rust/lithe-core/src/tests/protocol.rs | 51 ++++++ scripts/ci/common/classify-ci-changes.sh | 2 +- scripts/logging/verify-log-usage.mjs | 4 +- .../resources/reuse-worktree-resources.mjs | 2 + .../test-reuse-worktree-resources.mjs | 12 ++ scripts/resources/worktree-resources.json | 10 ++ scripts/rust-core/test-rust-core-comments.mjs | 33 ++++ .../rust-core/verify-rust-core-comments.sh | 19 ++- scripts/rust-core/verify-rust-core-crates.sh | 1 + scripts/rust-core/verify-rust-core-static.sh | 2 +- .../prepare/prepare-core-rules-wasm.mjs | 66 ++++++++ shared/i18n/locales/en-US.json | 2 + shared/i18n/locales/zh-CN.json | 2 + shared/i18n/messages.json | 17 ++ windows/tauri/package.json | 4 +- windows/tauri/src-tauri/Cargo.lock | 9 ++ windows/tauri/src/core/core-rules.test.ts | 39 +++++ windows/tauri/src/core/core-rules.ts | 142 ++++++++++++++++ windows/tauri/src/i18n/shared.generated.ts | 4 + windows/tauri/src/main.tsx | 23 ++- windows/tauri/src/test-utils/core-rules.ts | 44 +++++ 40 files changed, 973 insertions(+), 19 deletions(-) create mode 100644 rust/lithe-core-rules/Cargo.toml create mode 100644 rust/lithe-core-rules/src/dispatch.rs create mode 100644 rust/lithe-core-rules/src/error.rs create mode 100644 rust/lithe-core-rules/src/keymap/mod.rs create mode 100644 rust/lithe-core-rules/src/keymap/modifier_gesture.rs create mode 100644 rust/lithe-core-rules/src/lib.rs create mode 100644 rust/lithe-core-rules/src/wasm.rs create mode 100644 rust/lithe-core-rules/tests/modifier_gesture_fixture.rs create mode 100755 scripts/windows/prepare/prepare-core-rules-wasm.mjs create mode 100644 windows/tauri/src/core/core-rules.test.ts create mode 100644 windows/tauri/src/core/core-rules.ts create mode 100644 windows/tauri/src/test-utils/core-rules.ts diff --git a/.github/workflows/ci-windows.yml b/.github/workflows/ci-windows.yml index 854950951a..600928e830 100644 --- a/.github/workflows/ci-windows.yml +++ b/.github/workflows/ci-windows.yml @@ -161,6 +161,11 @@ jobs: - name: Check out source uses: actions/checkout@v7 + - name: Set up Rust for the core rules wasm module + uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-unknown-unknown + - name: Set up Bun id: bun uses: oven-sh/setup-bun@v2 @@ -226,6 +231,11 @@ jobs: shell: pwsh run: ./scripts/windows/build/install-windows-frontend-dependencies.ps1 + # The frontend loads rust/lithe-core-rules as wasm; typecheck, bun tests and the build all need it. + - name: Build core rules wasm module + shell: pwsh + run: node scripts/windows/prepare/prepare-core-rules-wasm.mjs + - name: Check Windows frontend types working-directory: windows/tauri shell: pwsh diff --git a/.github/workflows/release-preview-windows.yml b/.github/workflows/release-preview-windows.yml index f27ad7f4cb..cdc6b09dd7 100644 --- a/.github/workflows/release-preview-windows.yml +++ b/.github/workflows/release-preview-windows.yml @@ -40,7 +40,7 @@ jobs: - name: Set up Rust uses: dtolnay/rust-toolchain@stable with: - targets: x86_64-pc-windows-msvc + targets: x86_64-pc-windows-msvc,wasm32-unknown-unknown - name: Set up Bun id: bun diff --git a/.github/workflows/release-windows.yml b/.github/workflows/release-windows.yml index a91910479a..b8810c0381 100644 --- a/.github/workflows/release-windows.yml +++ b/.github/workflows/release-windows.yml @@ -67,7 +67,7 @@ jobs: - name: Set up Rust uses: dtolnay/rust-toolchain@stable with: - targets: x86_64-pc-windows-msvc + targets: x86_64-pc-windows-msvc,wasm32-unknown-unknown - name: Set up Bun id: bun diff --git a/.gitignore b/.gitignore index 28c158475c..a37605c828 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,6 @@ reasonix.toml # Built MCP helper, packaged read-only by Tauri. /windows/tauri/src-tauri/helpers/*.exe + +# Built from rust/lithe-core-rules by scripts/windows/prepare/prepare-core-rules-wasm.mjs. +/windows/tauri/public/core-rules/ diff --git a/docs/ci-builds.md b/docs/ci-builds.md index b4a750c8e3..38f50d01f7 100644 --- a/docs/ci-builds.md +++ b/docs/ci-builds.md @@ -428,6 +428,17 @@ SwiftPM 的 `.build/`、资源 bundle 和 `.lithe-swift-toolchain` 标记登记 由本工作树 SwiftPM 清理管理;这些操作仅作用于构建目录,不修改已安装应用、签名 发行基线或 Sparkle delta。 +Windows 前端用到的纯规则(`rust/lithe-core-rules`,例如双击 Shift 手势)会被编译成 +`windows/tauri/public/core-rules/lithe_core_rules.wasm`,由 +`scripts/windows/prepare/prepare-core-rules-wasm.mjs` 生成(需要 `rustup target add wasm32-unknown-unknown`, +约几秒、不下载任何东西)。`dev`、`build`(因此 `desktop:build` 的 beforeBuildCommand 也包含它)、 +`ci-windows.yml` 的前端 job,以及 `release-windows.yml`、`release-preview-windows.yml` 的 Rust 目标都已接入; +bun 测试辅助 `src/test-utils/core-rules.ts` 在产物缺失或旧于 Rust 源码时会调用同一个脚本重建。 +该产物登记为 `core-rules-wasm`,**不可跨工作树复用**:文件里没有版本、源码修订或工具链 +identity stamp,无法证明与目标工作树的 Rust 源码一致,所有校验和复制阶段都拒绝, +每个工作树自行重建(`.gitignore` 忽略,不提交)。它是只读的打包输入,应用运行时不写入, +不影响代码签名和 Sparkle delta。macOS 不使用 wasm,通过 lithe-core 的 FFI 调用同一份规则源码。 + `scripts/windows/perf/run-windows-perf.mjs` 的生产构建、本地基线和 CPU profile 写入 `.artifacts/windows-perf/`,登记为 `windows-perf-state`,所有复用阶段均拒绝复制。这些内容 跟随所在工作树的源码和机器,没有 identity stamp;工具在本工作树重新构建,不写 app bundle、 diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 6f51fc97ee..edff320f2a 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -1488,6 +1488,7 @@ dependencies = [ "chrono", "comrak", "lithe-agent-host", + "lithe-core-rules", "lithe-git-host", "lithe-ide-host", "lithe-remote", @@ -1512,6 +1513,14 @@ dependencies = [ "zip", ] +[[package]] +name = "lithe-core-rules" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "lithe-git-host" version = "0.1.0" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 3e23cb1cd4..536f3c987b 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["lithe-core", "lithe-git-host", "lithe-agent-host", "lithe-ide-host", "lithe-remote"] +members = ["lithe-core", "lithe-core-rules", "lithe-git-host", "lithe-agent-host", "lithe-ide-host", "lithe-remote"] resolver = "2" [profile.release] diff --git a/rust/lithe-core-rules/Cargo.toml b/rust/lithe-core-rules/Cargo.toml new file mode 100644 index 0000000000..1388a9f2da --- /dev/null +++ b/rust/lithe-core-rules/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "lithe-core-rules" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" +description = "Pure synchronous rules shared by Lithe Core and the Windows frontend (compiled to wasm)" + +[lib] +# `rlib` is linked into lithe-core; `cdylib` produces the wasm module on wasm32 targets. +crate-type = ["rlib", "cdylib"] + +[dependencies] +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" diff --git a/rust/lithe-core-rules/src/dispatch.rs b/rust/lithe-core-rules/src/dispatch.rs new file mode 100644 index 0000000000..29b1deb574 --- /dev/null +++ b/rust/lithe-core-rules/src/dispatch.rs @@ -0,0 +1,55 @@ +//! Command routing and the JSON envelope shared with `lithe-core`. + +use crate::{keymap, RuleError, RuleErrorCode}; +use serde::Deserialize; +use serde_json::{json, Value}; + +/// Every command name this crate owns. `lithe-core` routes exactly these names here. +pub const COMMANDS: &[&str] = &[keymap::MODIFIER_GESTURE_STEP]; + +/// Reports whether `command` is owned by this crate. +pub fn is_rule_command(command: &str) -> bool { + COMMANDS.contains(&command) +} + +/// Runs one rule on an already decoded payload. +pub fn execute(command: &str, payload: Value) -> Result { + match command { + keymap::MODIFIER_GESTURE_STEP => keymap::modifier_gesture_step_json(payload), + _ => Err(RuleError::new( + RuleErrorCode::NotSupported, + "Unknown rule command", + )), + } +} + +#[derive(Deserialize)] +struct Request { + id: Option, + command: String, + #[serde(default)] + payload: Value, +} + +/// Executes one Core-style JSON request and returns the JSON response envelope. +/// +/// Unknown envelope fields (`operationId`, `origin`, ...) are ignored so hosts can send the +/// same request object they send to `lithe-core`. +pub fn execute_json(request: &str) -> String { + let parsed: Request = match serde_json::from_str(request) { + Ok(parsed) => parsed, + Err(error) => { + let error = RuleError::new(RuleErrorCode::InvalidRequest, "Invalid JSON request") + .with_details(error.to_string()); + return failure(None, &error); + } + }; + match execute(&parsed.command, parsed.payload) { + Ok(data) => json!({"id": parsed.id, "ok": true, "data": data}).to_string(), + Err(error) => failure(parsed.id, &error), + } +} + +fn failure(id: Option, error: &RuleError) -> String { + json!({"id": id, "ok": false, "error": error}).to_string() +} diff --git a/rust/lithe-core-rules/src/error.rs b/rust/lithe-core-rules/src/error.rs new file mode 100644 index 0000000000..74e5a60cc9 --- /dev/null +++ b/rust/lithe-core-rules/src/error.rs @@ -0,0 +1,43 @@ +//! Failure type whose JSON shape matches the Core error envelope. + +use serde::Serialize; + +/// Stable failure categories. Names serialize exactly like the matching `lithe-core` codes. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum RuleErrorCode { + /// The request envelope or payload does not match the command contract. + InvalidRequest, + /// The command name is not a rule owned by this crate. + NotSupported, +} + +/// A rule failure with a stable category and a message that is safe to display. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RuleError { + /// Machine-readable category. + pub code: RuleErrorCode, + /// Short summary of the failure. + pub message: String, + /// Optional diagnostic context; never contains secrets. + #[serde(skip_serializing_if = "Option::is_none")] + pub details: Option, +} + +impl RuleError { + /// Creates an error without diagnostic details. + pub fn new(code: RuleErrorCode, message: impl Into) -> Self { + Self { + code, + message: message.into(), + details: None, + } + } + + /// Attaches diagnostic context while keeping the category. + pub fn with_details(mut self, details: impl Into) -> Self { + self.details = Some(details.into()); + self + } +} diff --git a/rust/lithe-core-rules/src/keymap/mod.rs b/rust/lithe-core-rules/src/keymap/mod.rs new file mode 100644 index 0000000000..bb668b5c28 --- /dev/null +++ b/rust/lithe-core-rules/src/keymap/mod.rs @@ -0,0 +1,8 @@ +//! Keymap rules that must run synchronously on native key events. + +mod modifier_gesture; + +pub use modifier_gesture::{ + modifier_gesture_step, modifier_gesture_step_json, GestureEvent, GestureState, GestureStep, + MODIFIER_GESTURE_STEP, THRESHOLD_SECONDS, +}; diff --git a/rust/lithe-core-rules/src/keymap/modifier_gesture.rs b/rust/lithe-core-rules/src/keymap/modifier_gesture.rs new file mode 100644 index 0000000000..ebb50656d0 --- /dev/null +++ b/rust/lithe-core-rules/src/keymap/modifier_gesture.rs @@ -0,0 +1,153 @@ +//! Double-Shift gesture recognizer as a pure state transition. +//! +//! The host owns the [`GestureState`] value and the clock. For each native event it sends the +//! current state, the event and a timestamp, and stores the state that comes back. Nothing here +//! keeps hidden state, so the same sequence always gives the same result on every host. + +use crate::{RuleError, RuleErrorCode}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// Command name of the gesture step in the Core command namespace. +pub const MODIFIER_GESTURE_STEP: &str = "keymap.modifierGestureStep"; + +/// Largest gap, in seconds, between two standalone Shift releases that still counts as a double tap. +pub const THRESHOLD_SECONDS: f64 = 0.35; + +/// Recognizer memory carried between events. The default value is the idle state. +#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields, default)] +pub struct GestureState { + /// Whether Shift is currently held. + pub shift_was_down: bool, + /// Whether the current Shift press has not been combined with any other key or modifier. + pub current_press_is_standalone: bool, + /// Release time of the previous standalone tap, if one is still pending. + pub last_standalone_tap: Option, +} + +/// One native input event, reduced to what the gesture needs. +#[derive(Debug, Clone, Copy, PartialEq, Deserialize)] +#[serde( + tag = "kind", + rename_all = "camelCase", + rename_all_fields = "camelCase", + deny_unknown_fields +)] +pub enum GestureEvent { + /// A non-Shift key was pressed; it breaks any pending tap. + KeyDown, + /// The host lost track of the keyboard (focus change, suspension); forget everything. + Reset, + /// The modifier flags changed. + Flags { + /// Whether Shift is down after the change. + shift: bool, + /// Whether Command, Control, Option or Function (or Ctrl/Alt/Meta) is down. + other_modifiers: bool, + }, +} + +/// Result of one transition. +#[derive(Debug, Clone, Copy, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GestureStep { + /// State to hand back with the next event. + pub state: GestureState, + /// True exactly when this event completed a double Shift tap. + pub triggered: bool, +} + +/// Applies one event to `state`. `timestamp` is in seconds on any monotonic clock the host keeps. +pub fn modifier_gesture_step( + state: GestureState, + event: GestureEvent, + timestamp: f64, +) -> GestureStep { + let mut next = state; + let triggered = match event { + GestureEvent::KeyDown => { + next.current_press_is_standalone = false; + next.last_standalone_tap = None; + false + } + GestureEvent::Reset => { + next = GestureState::default(); + false + } + GestureEvent::Flags { + shift, + other_modifiers, + } => flags_changed(&mut next, shift, other_modifiers, timestamp), + }; + GestureStep { + state: next, + triggered, + } +} + +fn flags_changed( + state: &mut GestureState, + shift: bool, + other_modifiers: bool, + timestamp: f64, +) -> bool { + if shift && !state.shift_was_down { + state.current_press_is_standalone = !other_modifiers; + state.shift_was_down = true; + return false; + } + if shift && state.shift_was_down { + if other_modifiers { + state.current_press_is_standalone = false; + state.last_standalone_tap = None; + } + return false; + } + if !shift && state.shift_was_down { + state.shift_was_down = false; + let was_standalone = state.current_press_is_standalone; + state.current_press_is_standalone = false; + if !was_standalone || other_modifiers { + state.last_standalone_tap = None; + return false; + } + if let Some(previous) = state.last_standalone_tap { + let gap = timestamp - previous; + if gap >= 0.0 && gap < THRESHOLD_SECONDS { + state.last_standalone_tap = None; + return true; + } + } + state.last_standalone_tap = Some(timestamp); + return false; + } + if other_modifiers { + state.last_standalone_tap = None; + } + false +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct StepRequest { + #[serde(default)] + state: GestureState, + event: GestureEvent, + #[serde(default)] + timestamp: f64, +} + +/// JSON adapter for `keymap.modifierGestureStep`: `{state?, event, timestamp}` in, +/// `{state, triggered}` out. +pub fn modifier_gesture_step_json(payload: Value) -> Result { + let request: StepRequest = serde_json::from_value(payload).map_err(|error| { + RuleError::new( + RuleErrorCode::InvalidRequest, + "Invalid modifier gesture request", + ) + .with_details(error.to_string()) + })?; + let step = modifier_gesture_step(request.state, request.event, request.timestamp); + Ok(serde_json::to_value(step).expect("gesture step should encode")) +} diff --git a/rust/lithe-core-rules/src/lib.rs b/rust/lithe-core-rules/src/lib.rs new file mode 100644 index 0000000000..8e25075b30 --- /dev/null +++ b/rust/lithe-core-rules/src/lib.rs @@ -0,0 +1,23 @@ +//! Pure, synchronous product rules shared by every host. +//! +//! This crate holds rules that are small, deterministic and sit on a synchronous hot path +//! (for example a gesture recognizer that runs on every native key event). It performs no +//! file-system, process, thread or clock access: time is always supplied by the caller. +//! +//! The same source is consumed twice: +//! - `lithe-core` links it as a normal dependency and exposes each rule through its JSON +//! command dispatch, so macOS and the Windows backend reach it over the usual Core channel. +//! - On `wasm32` it is built as a `cdylib` whose `extern "C"` exports (see the private `wasm` module) let the +//! Windows frontend call the very same rules synchronously, without a Tauri IPC round trip. +//! +//! Requests and responses reuse the Core envelope: `{id, command, payload}` in and +//! `{id, ok, data | error}` out, with the same stable error codes. + +mod dispatch; +mod error; +pub mod keymap; +#[cfg(target_arch = "wasm32")] +mod wasm; + +pub use dispatch::{execute, execute_json, is_rule_command, COMMANDS}; +pub use error::{RuleError, RuleErrorCode}; diff --git a/rust/lithe-core-rules/src/wasm.rs b/rust/lithe-core-rules/src/wasm.rs new file mode 100644 index 0000000000..aa3f0c41df --- /dev/null +++ b/rust/lithe-core-rules/src/wasm.rs @@ -0,0 +1,65 @@ +//! Minimal C ABI exported by the wasm build, without `wasm-bindgen`. +//! +//! The host copies a UTF-8 JSON request into memory obtained from `lithe_rules_alloc`, calls +//! `lithe_rules_execute`, reads the response bytes from the returned pointer (its length comes +//! from `lithe_rules_result_len`), and frees both buffers with `lithe_rules_dealloc`. +//! Requests and responses use the Core envelope, see [`crate::execute_json`]. +//! +//! wasm32-unknown-unknown modules are single threaded, so the last-result length lives in a plain +//! atomic rather than behind a lock. + +use std::sync::atomic::{AtomicUsize, Ordering}; + +static LAST_RESULT_LEN: AtomicUsize = AtomicUsize::new(0); + +/// Allocates `len` writable bytes for the host and returns their address. +#[no_mangle] +pub extern "C" fn lithe_rules_alloc(len: usize) -> *mut u8 { + Box::into_raw(vec![0u8; len.max(1)].into_boxed_slice()).cast::() +} + +/// Releases a buffer previously handed out by `lithe_rules_alloc` or `lithe_rules_execute`. +/// +/// # Safety +/// +/// `pointer` and `len` must be exactly the address and requested length of a live buffer from +/// this module, and the buffer must not be used afterwards. +#[no_mangle] +pub unsafe extern "C" fn lithe_rules_dealloc(pointer: *mut u8, len: usize) { + if !pointer.is_null() { + drop(Box::from_raw(std::ptr::slice_from_raw_parts_mut( + pointer, + len.max(1), + ))); + } +} + +/// Runs one JSON request and returns the address of the JSON response. +/// +/// The response length is available from `lithe_rules_result_len` until the next call. +/// +/// # Safety +/// +/// `pointer` must address `len` initialized bytes inside this module's memory. +#[no_mangle] +pub unsafe extern "C" fn lithe_rules_execute(pointer: *const u8, len: usize) -> *mut u8 { + let request = std::slice::from_raw_parts(pointer, len); + let response = match std::str::from_utf8(request) { + Ok(text) => crate::execute_json(text), + Err(_) => r#"{"id":null,"ok":false,"error":{"code":"invalid_request","message":"Request is not valid UTF-8"}}"#.to_string(), + }; + let bytes = response.into_bytes(); + let response_len = bytes.len(); + // Copy into a buffer sized exactly like the host-visible length, so `lithe_rules_dealloc` + // can release it with that length. + let output = lithe_rules_alloc(response_len); + std::ptr::copy_nonoverlapping(bytes.as_ptr(), output, response_len); + LAST_RESULT_LEN.store(response_len, Ordering::Relaxed); + output +} + +/// Length in bytes of the response returned by the most recent `lithe_rules_execute` call. +#[no_mangle] +pub extern "C" fn lithe_rules_result_len() -> usize { + LAST_RESULT_LEN.load(Ordering::Relaxed) +} diff --git a/rust/lithe-core-rules/tests/modifier_gesture_fixture.rs b/rust/lithe-core-rules/tests/modifier_gesture_fixture.rs new file mode 100644 index 0000000000..2a6abadd2a --- /dev/null +++ b/rust/lithe-core-rules/tests/modifier_gesture_fixture.rs @@ -0,0 +1,78 @@ +//! Drives the shared double-Shift fixture through the JSON envelope, exactly like the hosts do. + +use lithe_core_rules::{execute_json, keymap::THRESHOLD_SECONDS}; +use serde_json::{json, Value}; + +const FIXTURE: &str = include_str!("../../../shared/fixtures/keymap/modifier-gesture-v1.json"); + +fn step(state: &Value, event: Value, timestamp: f64) -> Value { + let request = json!({ + "id": "fixture", + "command": "keymap.modifierGestureStep", + "payload": {"state": state, "event": event, "timestamp": timestamp}, + }); + let response: Value = serde_json::from_str(&execute_json(&request.to_string())).unwrap(); + assert_eq!(response["ok"], true, "{response}"); + assert_eq!(response["id"], "fixture"); + response["data"].clone() +} + +#[test] +fn threshold_matches_the_fixture() { + let fixture: Value = serde_json::from_str(FIXTURE).unwrap(); + assert_eq!( + fixture["thresholdSeconds"].as_f64(), + Some(THRESHOLD_SECONDS) + ); +} + +#[test] +fn every_fixture_sequence_matches_step_by_step() { + let fixture: Value = serde_json::from_str(FIXTURE).unwrap(); + for sequence in fixture["sequences"].as_array().unwrap() { + let name = sequence["name"].as_str().unwrap(); + // The host owns the state; start from the idle default exactly as a host does. + let mut state = json!({}); + for (index, entry) in sequence["steps"].as_array().unwrap().iter().enumerate() { + let (event, timestamp) = match entry["event"].as_str().unwrap() { + "keyDown" => (json!({"kind": "keyDown"}), 0.0), + "reset" => (json!({"kind": "reset"}), 0.0), + _ => ( + json!({ + "kind": "flags", + "shift": entry["shift"], + "otherModifiers": entry["otherModifiers"], + }), + entry["timestamp"].as_f64().unwrap(), + ), + }; + let data = step(&state, event, timestamp); + if let Some(expected) = entry.get("expected") { + assert_eq!(&data["triggered"], expected, "{name} step {index}"); + } + state = data["state"].clone(); + } + } +} + +#[test] +fn malformed_requests_use_core_error_codes() { + let unknown: Value = serde_json::from_str(&execute_json( + r#"{"id":"a","command":"keymap.nope","payload":{}}"#, + )) + .unwrap(); + assert_eq!(unknown["ok"], false); + assert_eq!(unknown["error"]["code"], "not_supported"); + + let bad_payload: Value = serde_json::from_str(&execute_json( + r#"{"id":"b","command":"keymap.modifierGestureStep","payload":{"event":{"kind":"flags"}}}"#, + )) + .unwrap(); + assert_eq!(bad_payload["ok"], false); + assert_eq!(bad_payload["error"]["code"], "invalid_request"); + assert_eq!(bad_payload["id"], "b"); + + let not_json: Value = serde_json::from_str(&execute_json("{")).unwrap(); + assert_eq!(not_json["error"]["code"], "invalid_request"); + assert!(not_json["id"].is_null()); +} diff --git a/rust/lithe-core/Cargo.toml b/rust/lithe-core/Cargo.toml index 42f20586db..61d8770374 100644 --- a/rust/lithe-core/Cargo.toml +++ b/rust/lithe-core/Cargo.toml @@ -9,6 +9,7 @@ description = "Cross-platform application core for Lithe" crate-type = ["rlib", "staticlib", "cdylib"] [dependencies] +lithe-core-rules = { path = "../lithe-core-rules" } lithe-git-host = { path = "../lithe-git-host" } lithe-agent-host = { path = "../lithe-agent-host" } lithe-ide-host = { path = "../lithe-ide-host" } diff --git a/rust/lithe-core/src/protocol/command.rs b/rust/lithe-core/src/protocol/command.rs index c1e8b0d2b3..6d62c7a097 100644 --- a/rust/lithe-core/src/protocol/command.rs +++ b/rust/lithe-core/src/protocol/command.rs @@ -406,6 +406,9 @@ pub enum CoreCommand { KeymapEvaluateContext, /// Finds conflicts among the final layered shortcut bindings (`keymap.analyzeConflicts`). KeymapAnalyzeConflicts, + /// Advances the double-Shift gesture recognizer by one event (`keymap.modifierGestureStep`). + /// The rule itself lives in `lithe-core-rules` so the Windows frontend can run it synchronously. + KeymapModifierGestureStep, /// Resolves terminal link candidates into file or external targets (`terminal.parseLinkTarget`). TerminalParseLinkTarget, /// Creates one transport-neutral Debug Adapter Protocol session (`debug.createSession`). @@ -1030,6 +1033,7 @@ impl CoreCommand { "keymap.parseContext" => Some(Self::KeymapParseContext), "keymap.evaluateContext" => Some(Self::KeymapEvaluateContext), "keymap.analyzeConflicts" => Some(Self::KeymapAnalyzeConflicts), + "keymap.modifierGestureStep" => Some(Self::KeymapModifierGestureStep), "terminal.parseLinkTarget" => Some(Self::TerminalParseLinkTarget), "debug.createSession" => Some(Self::DebugCreateSession), "debug.launch" => Some(Self::DebugLaunch), diff --git a/rust/lithe-core/src/protocol/error.rs b/rust/lithe-core/src/protocol/error.rs index ea8ab9212d..a6eea351a4 100644 --- a/rust/lithe-core/src/protocol/error.rs +++ b/rust/lithe-core/src/protocol/error.rs @@ -92,3 +92,18 @@ pub fn invalid_relative_path(value: &str) -> bool { || normalized.contains('\0') || normalized.split('/').any(|component| component == "..") } + +impl From for CoreError { + /// Rule errors already use Core's category names, so the mapping is one to one. + fn from(error: lithe_core_rules::RuleError) -> Self { + let code = match error.code { + lithe_core_rules::RuleErrorCode::InvalidRequest => ErrorCode::InvalidRequest, + lithe_core_rules::RuleErrorCode::NotSupported => ErrorCode::NotSupported, + }; + let converted = Self::new(code, error.message); + match error.details { + Some(details) => converted.with_details(details), + None => converted, + } + } +} diff --git a/rust/lithe-core/src/runtime/dispatcher.rs b/rust/lithe-core/src/runtime/dispatcher.rs index 9f7deeca48..7646ba47d6 100644 --- a/rust/lithe-core/src/runtime/dispatcher.rs +++ b/rust/lithe-core/src/runtime/dispatcher.rs @@ -1536,6 +1536,12 @@ fn execute_parsed(mut parsed: CoreRequest) -> CoreResponse { Err(error) => CoreResponse::failure(id, error), } } + CoreCommand::KeymapModifierGestureStep => { + match lithe_core_rules::execute("keymap.modifierGestureStep", parsed.payload) { + Ok(data) => CoreResponse::success(id, data), + Err(error) => CoreResponse::failure(id, error.into()), + } + } CoreCommand::TerminalParseLinkTarget => { let result = serde_json::from_value::(parsed.payload) diff --git a/rust/lithe-core/src/tests/protocol.rs b/rust/lithe-core/src/tests/protocol.rs index 3f2b966065..553f46fdbf 100644 --- a/rust/lithe-core/src/tests/protocol.rs +++ b/rust/lithe-core/src/tests/protocol.rs @@ -400,6 +400,57 @@ fn keymap_conflict_fixtures_cross_the_json_boundary() { assert_eq!(rejected["ok"], false); } +#[test] +fn modifier_gesture_fixture_runs_through_core_dispatch() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../shared/fixtures/keymap/modifier-gesture-v1.json" + )) + .unwrap(); + for sequence in fixture["sequences"].as_array().unwrap() { + let mut state = serde_json::json!({}); + for (index, entry) in sequence["steps"].as_array().unwrap().iter().enumerate() { + let event = match entry["event"].as_str().unwrap() { + "keyDown" => serde_json::json!({"kind": "keyDown"}), + "reset" => serde_json::json!({"kind": "reset"}), + _ => serde_json::json!({ + "kind": "flags", "shift": entry["shift"], "otherModifiers": entry["otherModifiers"], + }), + }; + let result = call_fixture_command( + "keymap.modifierGestureStep", + serde_json::json!({ + "state": state, "event": event, "timestamp": entry["timestamp"].as_f64().unwrap_or(0.0), + }), + ); + assert_eq!(result["ok"], true, "{}: {result}", sequence["name"]); + if let Some(expected) = entry.get("expected") { + assert_eq!( + &result["data"]["triggered"], expected, + "{} step {index}", + sequence["name"] + ); + } + state = result["data"]["state"].clone(); + } + } + let rejected = call_fixture_command( + "keymap.modifierGestureStep", + serde_json::json!({"event": {"kind": "bogus"}}), + ); + assert_eq!(rejected["ok"], false); + assert_eq!(rejected["error"]["code"], "invalid_request"); +} + +#[test] +fn every_rules_crate_command_is_routed_by_core() { + for command in lithe_core_rules::COMMANDS { + assert!( + crate::protocol::CoreCommand::parse(command).is_some(), + "{command} is owned by lithe-core-rules but not registered in CoreCommand" + ); + } +} + #[test] fn terminal_link_fixtures_cross_the_json_boundary() { let fixture: Value = serde_json::from_str(include_str!( diff --git a/scripts/ci/common/classify-ci-changes.sh b/scripts/ci/common/classify-ci-changes.sh index 74f6be01a7..92ad1dddfc 100755 --- a/scripts/ci/common/classify-ci-changes.sh +++ b/scripts/ci/common/classify-ci-changes.sh @@ -367,7 +367,7 @@ classify_path() { rust_core=true macos_release=true ;; - scripts/windows/build/build-windows.ps1|scripts/windows/verify/verify-windows-boundaries.ps1|scripts/windows/verify/verify-windows-boundaries.sh|scripts/windows/prepare/prepare-jdtls.ps1|scripts/windows/prepare/prepare-jdk.ps1|scripts/windows/package/package-windows.ps1|scripts/windows/build/install-windows-frontend-dependencies.ps1|scripts/windows/build/invoke-windows-bun-install.ps1|scripts/windows/build/windows-frontend-install.test.ts|scripts/windows/build/invoke-windows-tauri-build.ps1|scripts/release/windows/create-windows-updater-manifest.ps1|scripts/release/windows/test-windows-updater-manifest.ps1) + scripts/windows/build/build-windows.ps1|scripts/windows/verify/verify-windows-boundaries.ps1|scripts/windows/verify/verify-windows-boundaries.sh|scripts/windows/prepare/prepare-jdtls.ps1|scripts/windows/prepare/prepare-jdk.ps1|scripts/windows/prepare/prepare-core-rules-wasm.mjs|scripts/windows/package/package-windows.ps1|scripts/windows/build/install-windows-frontend-dependencies.ps1|scripts/windows/build/invoke-windows-bun-install.ps1|scripts/windows/build/windows-frontend-install.test.ts|scripts/windows/build/invoke-windows-tauri-build.ps1|scripts/release/windows/create-windows-updater-manifest.ps1|scripts/release/windows/test-windows-updater-manifest.ps1) windows=true ;; scripts/windows/perf/*) diff --git a/scripts/logging/verify-log-usage.mjs b/scripts/logging/verify-log-usage.mjs index d45e976e2a..7c3fe594e9 100644 --- a/scripts/logging/verify-log-usage.mjs +++ b/scripts/logging/verify-log-usage.mjs @@ -163,7 +163,7 @@ async function files(directory, extension) { return results.flat().sort(); } export async function scanRepository(root = ROOT) { - const rustCrates = ['lithe-core', 'lithe-agent-host', 'lithe-git-host', 'lithe-ide-host', 'lithe-remote']; + const rustCrates = ['lithe-core', 'lithe-core-rules', 'lithe-agent-host', 'lithe-git-host', 'lithe-ide-host', 'lithe-remote']; const groups = await Promise.all([files(path.join(root, 'macos/Sources'), '.swift'), ...rustCrates.map(crate => files(path.join(root, `rust/${crate}/src`), '.rs'))]); // Remote server and standalone binaries have stderr owned by their process host; migrate separately. return (await Promise.all(groups.flat().filter(file => !/rust\/lithe-(?:remote\/src\/(?:server|bin)|ide-host\/src\/bin)\//.test(path.relative(root, file).split(path.sep).join('/'))).map(async file => scanSource(path.relative(root, file).split(path.sep).join('/'), await readFile(file, 'utf8'))))).flat(); @@ -176,7 +176,7 @@ function validateAllowlist(allowlist) { const keys = new Set(); for (const entry of allowlist.entries) { const key = `${entry.file}:${entry.rule}`; - if (typeof entry.file !== 'string' || !/^(macos\/Sources\/.*\.swift|rust\/lithe-(?:core|agent-host|git-host|ide-host|remote)\/src\/.*\.rs)$/.test(entry.file) || entry.file.split('/').some(part => part === '..') || !ALLOWLIST_RULES.has(entry.rule) || !Number.isSafeInteger(entry.count) || entry.count <= 0 || keys.has(key) || !entry.reason?.trim()) throw new Error(`allowlist: invalid or duplicate entry ${key}`); + if (typeof entry.file !== 'string' || !/^(macos\/Sources\/.*\.swift|rust\/lithe-(?:core|core-rules|agent-host|git-host|ide-host|remote)\/src\/.*\.rs)$/.test(entry.file) || entry.file.split('/').some(part => part === '..') || !ALLOWLIST_RULES.has(entry.rule) || !Number.isSafeInteger(entry.count) || entry.count <= 0 || keys.has(key) || !entry.reason?.trim()) throw new Error(`allowlist: invalid or duplicate entry ${key}`); keys.add(key); } } diff --git a/scripts/resources/reuse-worktree-resources.mjs b/scripts/resources/reuse-worktree-resources.mjs index 8ebc14a7da..ccb6050116 100644 --- a/scripts/resources/reuse-worktree-resources.mjs +++ b/scripts/resources/reuse-worktree-resources.mjs @@ -94,6 +94,8 @@ async function readRegistry() { // document tokens and editor action leases are memory-only, never reusable. // Shared logging catalogs and generated API sources also use the refusal route: // validate with generate-log-events.mjs in the target checkout; never copy. + // The core rules wasm module (rust/lithe-core-rules) has no identity stamp either: + // each worktree rebuilds it with prepare-core-rules-wasm.mjs; it never gains a validator. // Shared i18n source and generated tables take this same refusal route: // rebuild from the target checkout, never copy another worktree's translations. // AutoBuild publication checkpoints and baseline downloads are invocation-owned, diff --git a/scripts/resources/test-reuse-worktree-resources.mjs b/scripts/resources/test-reuse-worktree-resources.mjs index 785c796f2b..330622f5f5 100644 --- a/scripts/resources/test-reuse-worktree-resources.mjs +++ b/scripts/resources/test-reuse-worktree-resources.mjs @@ -172,6 +172,18 @@ try { assert.notEqual(refused.status, 0); assert.match(diagnostics(refused), /shared-logging.*cannot be reused/); }); + await test("the core rules wasm module is rebuilt per worktree, never reused", { timeout: 15000 }, async () => { + const registry = JSON.parse(await fs.readFile(path.join(scriptDirectory, "worktree-resources.json"), "utf8")); + const resource = registry.excludedResources.find(item => item.id === "core-rules-wasm"); + assert.equal(resource.reusable, false); + assert.ok(resource.locations.includes("windows/tauri/public/core-rules/lithe_core_rules.wasm")); + const listed = run(process.execPath, [reuseScript, "--list"]); + assertSucceeded(listed); + assert.ok(!listed.stdout.includes(resource.id)); + const refused = reuse(["--resource", resource.id]); + assert.notEqual(refused.status, 0); + assert.match(diagnostics(refused), /core-rules-wasm.*cannot be reused/); + }); await test("shared translations are regenerated locally rather than reused", { timeout: 15000 }, async () => { const registry = JSON.parse(await fs.readFile(path.join(scriptDirectory, "worktree-resources.json"), "utf8")); const resource = registry.excludedResources.find(item => item.id === "shared-i18n"); diff --git a/scripts/resources/worktree-resources.json b/scripts/resources/worktree-resources.json index 21189cf2a2..3ed53a7121 100644 --- a/scripts/resources/worktree-resources.json +++ b/scripts/resources/worktree-resources.json @@ -569,6 +569,16 @@ ], "identity": "Git-owned event catalog and checked-in generated source bytes from the current revision. No sealed version/platform/architecture/toolchain reuse identity stamp.", "reason": "Generate and check with scripts/logging/generate-log-events.mjs in each target worktree before compilation. Every validation and copy stage rejects cross-worktree reuse. No download or cache; compiled metadata and Swift event values perform no runtime I/O and never change code signatures or Sparkle delta baselines." + }, + { + "id": "core-rules-wasm", + "reusable": false, + "locations": [ + "windows/tauri/public/core-rules/lithe_core_rules.wasm", + "rust/target/wasm32-unknown-unknown/" + ], + "identity": "Generated by scripts/windows/prepare/prepare-core-rules-wasm.mjs from rust/lithe-core-rules at this checkout's revision, with the local rustc, Cargo.lock and wasm32-unknown-unknown target. The file carries no version, source-revision or toolchain identity stamp, so a copy cannot be proven to match the target worktree's Rust sources.", + "reason": "Never copy or share this artifact at any worktree reuse stage. Each worktree rebuilds it (a few seconds, no downloads) through the Windows dev, build and desktop:build scripts, CI and the bun test helper, which also rebuilds when the Rust sources are newer. It is a read-only packaged frontend input: nothing writes it at runtime, so installed bundles, code signatures and Sparkle delta baselines are unaffected." } ] } diff --git a/scripts/rust-core/test-rust-core-comments.mjs b/scripts/rust-core/test-rust-core-comments.mjs index 03e3c92a93..1124bfd23a 100644 --- a/scripts/rust-core/test-rust-core-comments.mjs +++ b/scripts/rust-core/test-rust-core-comments.mjs @@ -24,6 +24,8 @@ for (const locale of ["C", "en_US.UTF-8"]) { mkdirSync(bin); mkdirSync(path.join(root, "scripts/rust-core"), { recursive: true }); mkdirSync(path.join(root, "rust/lithe-core/src"), { recursive: true }); + mkdirSync(path.join(root, "rust/lithe-core-rules/src"), { recursive: true }); + writeFileSync(path.join(root, "rust/lithe-core-rules/src/lib.rs"), "//! Rules fixture.\n"); copyFileSync(path.join(scripts, "verify-rust-core-comments.sh"), path.join(root, "scripts/rust-core/verify-rust-core-comments.sh")); writeFileSync(path.join(root, "rust/lithe-core/src/lib.rs"), `//! Fixture module.\n${source}\n`); // Exercise the no-ripgrep path; system Ruby also needs uname while loading rbconfig. @@ -49,3 +51,34 @@ for (const locale of ["C", "en_US.UTF-8"]) { }); } } + +// lithe-core-rules ships inside Core (and as wasm), so it must not escape the English-comment rule. +test("comments in lithe-core-rules are checked too", { timeout: 15_000 }, () => { + const root = mkdtempSync(path.join(os.tmpdir(), "lithe-rules-")); + try { + const bin = path.join(root, "bin"); + mkdirSync(bin); + mkdirSync(path.join(root, "scripts/rust-core"), { recursive: true }); + mkdirSync(path.join(root, "rust/lithe-core/src"), { recursive: true }); + mkdirSync(path.join(root, "rust/lithe-core-rules/src"), { recursive: true }); + copyFileSync(path.join(scripts, "verify-rust-core-comments.sh"), path.join(root, "scripts/rust-core/verify-rust-core-comments.sh")); + writeFileSync(path.join(root, "rust/lithe-core/src/lib.rs"), "//! Fixture module.\n"); + writeFileSync(path.join(root, "rust/lithe-core-rules/src/lib.rs"), "//! Rules fixture.\nconst X: u8 = 1; // 中文说明\n"); + for (const tool of ["dirname", "find", "awk", "ruby", "sed", "sort", "uname"]) { + const located = spawnSync("/bin/sh", ["-c", `command -v ${tool}`], { encoding: "utf8", timeout: 2_000 }); + assert.equal(located.status, 0, `Required checker tool: ${tool}`); + symlinkSync(located.stdout.trim(), path.join(bin, tool)); + } + const cargo = path.join(bin, "cargo"); + writeFileSync(cargo, "#!/bin/sh\nexit 0\n"); + chmodSync(cargo, 0o755); + const result = spawnSync("/bin/bash", [path.join(root, "scripts/rust-core/verify-rust-core-comments.sh")], { + env: { ...process.env, PATH: bin }, encoding: "utf8", timeout: 10_000, + }); + assert.ifError(result.error); + assert.equal(result.status, 1, result.stdout + result.stderr); + assert.match(result.stderr, /lithe-core-rules\/src\/lib\.rs/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/scripts/rust-core/verify-rust-core-comments.sh b/scripts/rust-core/verify-rust-core-comments.sh index 8e46195e34..7b73709775 100755 --- a/scripts/rust-core/verify-rust-core-comments.sh +++ b/scripts/rust-core/verify-rust-core-comments.sh @@ -3,17 +3,18 @@ set -euo pipefail SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" ROOT_DIR="$(cd -- "$SCRIPT_DIR/../.." && pwd)" -SOURCE_DIR="$ROOT_DIR/rust/lithe-core/src" +# lithe-core-rules is part of Core: it holds the rules the Windows frontend runs as wasm. +SOURCE_DIRS=("$ROOT_DIR/rust/lithe-core/src" "$ROOT_DIR/rust/lithe-core-rules/src") cd "$ROOT_DIR" rust_files=() production_files=() while IFS= read -r -d '' file; do rust_files+=("$file") -done < <(find "$SOURCE_DIR" -type f -name '*.rs' -print0) +done < <(find "${SOURCE_DIRS[@]}" -type f -name '*.rs' -print0) while IFS= read -r -d '' file; do production_files+=("$file") -done < <(find "$SOURCE_DIR" -type f -name '*.rs' \ +done < <(find "${SOURCE_DIRS[@]}" -type f -name '*.rs' \ ! -path '*/tests/*' \ ! -name 'tests.rs' \ -print0) @@ -147,10 +148,12 @@ fi # Let Rust understand visibility instead of treating pub items in private # modules as exported APIs. This covers public functions, types, variants, and # fields without requiring documentation for every internal helper. -cargo rustdoc --quiet \ - --manifest-path rust/lithe-core/Cargo.toml \ - --lib -- \ - -D missing_docs \ - -D rustdoc::broken_intra_doc_links +for manifest in rust/lithe-core/Cargo.toml rust/lithe-core-rules/Cargo.toml; do + cargo rustdoc --quiet \ + --manifest-path "$manifest" \ + --lib -- \ + -D missing_docs \ + -D rustdoc::broken_intra_doc_links +done printf '%s\n' "Rust Core comment verification passed" diff --git a/scripts/rust-core/verify-rust-core-crates.sh b/scripts/rust-core/verify-rust-core-crates.sh index 8412876e9f..cc6cc42502 100755 --- a/scripts/rust-core/verify-rust-core-crates.sh +++ b/scripts/rust-core/verify-rust-core-crates.sh @@ -32,4 +32,5 @@ node .agents/skills/write-stable-tests/scripts/run-rust-tests-with-timing.mjs \ --report .artifacts/test-stability/ide-host-rust.json cargo build --locked --manifest-path rust/Cargo.toml -p lithe-ide-host --features mcp --bin lithe-mcp node --test scripts/ide-mcp/test-ide-mcp.mjs +cargo test --manifest-path rust/Cargo.toml -p lithe-core-rules cargo test --manifest-path rust/Cargo.toml -p lithe-core diff --git a/scripts/rust-core/verify-rust-core-static.sh b/scripts/rust-core/verify-rust-core-static.sh index 53af06b201..e2632d35a0 100755 --- a/scripts/rust-core/verify-rust-core-static.sh +++ b/scripts/rust-core/verify-rust-core-static.sh @@ -13,4 +13,4 @@ node --test --test-reporter=spec --test-reporter-destination=stdout \ scripts/rust-core/test-rust-core-comments.mjs scripts/rust-core/verify-rust-core-comments.sh scripts/rust-core/verify-rust-core-layout.sh -cargo fmt --manifest-path rust/Cargo.toml -p lithe-core -- --check +cargo fmt --manifest-path rust/Cargo.toml -p lithe-core -p lithe-core-rules -- --check diff --git a/scripts/windows/prepare/prepare-core-rules-wasm.mjs b/scripts/windows/prepare/prepare-core-rules-wasm.mjs new file mode 100755 index 0000000000..074ca5321b --- /dev/null +++ b/scripts/windows/prepare/prepare-core-rules-wasm.mjs @@ -0,0 +1,66 @@ +#!/usr/bin/env node +// Build-time only: compiles rust/lithe-core-rules to wasm32 and stages it for the Windows frontend. +// The module is a read-only packaged input served from the app's frontend assets; nothing at +// runtime writes it, so installed bundles, code signatures and Sparkle delta baselines are unchanged. +// Usage: node scripts/windows/prepare/prepare-core-rules-wasm.mjs [--check] +// --check verify that the staged module exists and is non-empty without building. +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { copyFileSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."); +const WASM_TARGET = "wasm32-unknown-unknown"; +const BUILT_NAME = "lithe_core_rules.wasm"; +// Served at /core-rules/lithe_core_rules.wasm, next to the other wasm assets under public/. +export const STAGED_WASM = path.join(ROOT, "windows/tauri/public/core-rules", BUILT_NAME); + +function run(command, args, options = {}) { + return spawnSync(command, args, { encoding: "utf8", timeout: 1_200_000, ...options }); +} + +// Prefer rustup's cargo so a Homebrew or system cargo that lacks the wasm target cannot shadow it. +function resolveCargo() { + if (process.env.CARGO) return process.env.CARGO; + const which = run("rustup", ["which", "cargo"], { timeout: 30_000 }); + return which.status === 0 && which.stdout.trim() ? which.stdout.trim() : "cargo"; +} + +function ensureWasmTarget(cargo) { + const rustc = path.join(path.dirname(cargo), process.platform === "win32" ? "rustc.exe" : "rustc"); + const sysroot = run(existsSync(rustc) ? rustc : "rustc", ["--print", "sysroot"], { timeout: 30_000 }); + const root = sysroot.status === 0 ? sysroot.stdout.trim() : ""; + if (!root || !existsSync(path.join(root, "lib/rustlib", WASM_TARGET))) { + throw new Error(`The ${WASM_TARGET} Rust target is missing. Run: rustup target add ${WASM_TARGET}`); + } +} + +function verifyStaged() { + if (!existsSync(STAGED_WASM) || statSync(STAGED_WASM).size === 0) { + throw new Error(`Missing ${path.relative(ROOT, STAGED_WASM)}. Run: node scripts/windows/prepare/prepare-core-rules-wasm.mjs`); + } +} + +function build() { + const cargo = resolveCargo(); + ensureWasmTarget(cargo); + const cargoEnv = { ...process.env, PATH: `${path.dirname(cargo)}${path.delimiter}${process.env.PATH ?? ""}` }; + const manifest = path.join(ROOT, "rust/Cargo.toml"); + const result = run(cargo, ["build", "--locked", "--release", "--manifest-path", manifest, "--target", WASM_TARGET, "-p", "lithe-core-rules"], { stdio: "inherit", env: cargoEnv }); + if (result.error || result.status !== 0) throw result.error || new Error("Could not build lithe-core-rules for wasm32"); + const targetRoot = process.env.CARGO_TARGET_DIR ? path.resolve(process.env.CARGO_TARGET_DIR) : path.join(ROOT, "rust/target"); + const built = path.join(targetRoot, WASM_TARGET, "release", BUILT_NAME); + if (!existsSync(built)) throw new Error(`Cargo did not produce ${built}`); + const bytes = readFileSync(built); + mkdirSync(path.dirname(STAGED_WASM), { recursive: true }); + // Keep the file untouched when identical so dev servers and file watchers stay quiet. + if (!existsSync(STAGED_WASM) || !readFileSync(STAGED_WASM).equals(bytes)) copyFileSync(built, STAGED_WASM); + const digest = createHash("sha256").update(bytes).digest("hex").slice(0, 16); + console.log(`lithe-core-rules wasm: ${bytes.length} bytes, sha256 ${digest}, staged at ${path.relative(ROOT, STAGED_WASM)}`); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + if (process.argv.includes("--check")) verifyStaged(); + else build(); +} diff --git a/shared/i18n/locales/en-US.json b/shared/i18n/locales/en-US.json index 52e2d023b2..bd696a3b56 100644 --- a/shared/i18n/locales/en-US.json +++ b/shared/i18n/locales/en-US.json @@ -957,6 +957,8 @@ "commandPalette.searchThemes": "Search themes...", "commandPalette.title": "Command palette", "commandPalette.uploadTheme": "Upload theme", + "coreRules.initFailed": "Lithe could not load its built-in rules module, so it cannot start. Restart the app; if this keeps happening, reinstall Lithe. Details: {message}", + "coreRules.notReady": "The built-in rules module is not loaded yet.", "csv.auto": "Auto", "csv.changeDelimiter": "Change delimiter", "csv.comma": "Comma", diff --git a/shared/i18n/locales/zh-CN.json b/shared/i18n/locales/zh-CN.json index d5038fcc95..f1fcaebc98 100644 --- a/shared/i18n/locales/zh-CN.json +++ b/shared/i18n/locales/zh-CN.json @@ -957,6 +957,8 @@ "commandPalette.searchThemes": "搜索主题...", "commandPalette.title": "命令面板", "commandPalette.uploadTheme": "上传主题", + "coreRules.initFailed": "Lithe 无法加载内置规则模块,因此无法启动。请重启应用;如果问题持续出现,请重新安装 Lithe。详情:{message}", + "coreRules.notReady": "内置规则模块尚未加载。", "csv.auto": "自动", "csv.changeDelimiter": "更改分隔符", "csv.comma": "逗号", diff --git a/shared/i18n/messages.json b/shared/i18n/messages.json index ab48915e6f..78baad74c4 100644 --- a/shared/i18n/messages.json +++ b/shared/i18n/messages.json @@ -7723,6 +7723,23 @@ "windows" ] }, + { + "id": "coreRules.initFailed", + "description": "Windows UI: startup failure when the synchronous Core rules wasm module cannot be loaded", + "platforms": [ + "windows" + ], + "parameters": { + "message": "string" + } + }, + { + "id": "coreRules.notReady", + "description": "Windows UI: a synchronous Core rule was called before startup loaded the wasm module", + "platforms": [ + "windows" + ] + }, { "id": "csv.auto", "description": "Windows UI: csv.auto", diff --git a/windows/tauri/package.json b/windows/tauri/package.json index 0cd29ee28e..c82ab0e156 100644 --- a/windows/tauri/package.json +++ b/windows/tauri/package.json @@ -4,8 +4,8 @@ "private": true, "type": "module", "scripts": { - "dev": "node ../../scripts/i18n/generate-i18n.mjs --check && bun ./node_modules/vite-plus/bin/vp dev", - "build": "node ../../scripts/i18n/generate-i18n.mjs --check && bunx vp build", + "dev": "node ../../scripts/windows/prepare/prepare-core-rules-wasm.mjs && node ../../scripts/i18n/generate-i18n.mjs --check && bun ./node_modules/vite-plus/bin/vp dev", + "build": "node ../../scripts/windows/prepare/prepare-core-rules-wasm.mjs && node ../../scripts/i18n/generate-i18n.mjs --check && bunx vp build", "preview": "bunx vp preview", "tauri": "tauri", "desktop:dev": "tauri dev --config src-tauri/tauri.windows.conf.json", diff --git a/windows/tauri/src-tauri/Cargo.lock b/windows/tauri/src-tauri/Cargo.lock index 2936328508..d70367fac1 100644 --- a/windows/tauri/src-tauri/Cargo.lock +++ b/windows/tauri/src-tauri/Cargo.lock @@ -2894,6 +2894,7 @@ dependencies = [ "chrono", "comrak", "lithe-agent-host", + "lithe-core-rules", "lithe-git-host", "lithe-ide-host", "lithe-remote", @@ -2917,6 +2918,14 @@ dependencies = [ "zip 2.4.2", ] +[[package]] +name = "lithe-core-rules" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "lithe-git-host" version = "0.1.0" diff --git a/windows/tauri/src/core/core-rules.test.ts b/windows/tauri/src/core/core-rules.test.ts new file mode 100644 index 0000000000..8bcd235efb --- /dev/null +++ b/windows/tauri/src/core/core-rules.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from "bun:test"; +import { loadCoreRulesForTests } from "@/test-utils/core-rules"; + +// Each test loads its own copy of the loader (distinct query string), so one file's +// initialization cannot leak into another and the order of tests never matters. +let copies = 0; +function freshLoader(): Promise { + copies += 1; + return import(`./core-rules.ts?copy=${copies}`); +} + +describe("core rules loader", () => { + test("synchronous calls fail loudly instead of falling back to TypeScript", async () => { + const loader = await freshLoader(); + expect(loader.isCoreRulesReady()).toBe(false); + expect(() => loader.executeCoreRuleSync("keymap.modifierGestureStep", { event: { kind: "keyDown" } })).toThrow( + loader.CoreRuleError, + ); + }); + + test("a module without the expected exports is rejected and can be retried", async () => { + const loader = await freshLoader(); + // Smallest valid wasm module: magic number and version only. + const empty = new Uint8Array([0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00]); + await expect(loader.initializeCoreRules(empty)).rejects.toThrow("expected C ABI"); + expect(loader.isCoreRulesReady()).toBe(false); + }); + + test("the real module initializes once and then answers synchronously", async () => { + await loadCoreRulesForTests(); + const { isCoreRulesReady, executeCoreRuleSync } = await import("./core-rules"); + expect(isCoreRulesReady()).toBe(true); + const step = executeCoreRuleSync<{ triggered: boolean }>("keymap.modifierGestureStep", { + event: { kind: "keyDown" }, + timestamp: 0, + }); + expect(step.triggered).toBe(false); + }); +}); diff --git a/windows/tauri/src/core/core-rules.ts b/windows/tauri/src/core/core-rules.ts new file mode 100644 index 0000000000..22b4585fad --- /dev/null +++ b/windows/tauri/src/core/core-rules.ts @@ -0,0 +1,142 @@ +/** + * Synchronous bridge to the pure rules in rust/lithe-core-rules, compiled to wasm. + * + * The same Rust source also backs the `lithe-core` JSON commands that macOS and the Tauri backend + * use. The wasm copy exists only because a few rules run on synchronous hot paths (key events, + * store `set()` calls) where an asynchronous Tauri `invoke` cannot be awaited. + * + * Chromium (WebView2) refuses to compile wasm modules larger than 4 KB synchronously on the main + * thread, so the module is instantiated once, asynchronously, at startup. UI that depends on a + * rule must not render before `initializeCoreRules()` resolves. After that every call is + * synchronous. There is deliberately no TypeScript fallback: if loading fails, startup fails. + * + * Note: .agents/notes/implemented/architecture/2026-11-05-core-rules-wasm-sync-bridge.md + */ +import { createTranslator, DEFAULT_DISPLAY_LANGUAGE } from "@/i18n/locale"; + +/** Where the build (scripts/windows/prepare/prepare-core-rules-wasm.mjs) stages the module. */ +export const CORE_RULES_WASM_URL = "/core-rules/lithe_core_rules.wasm"; + +interface RulesExports { + memory: WebAssembly.Memory; + lithe_rules_alloc(length: number): number; + lithe_rules_dealloc(pointer: number, length: number): void; + lithe_rules_execute(pointer: number, length: number): number; + lithe_rules_result_len(): number; +} + +/** Failure reported by a rule, using the same `code` strings as Core errors. */ +export class CoreRuleError extends Error { + readonly code: string; + readonly details?: string; + + constructor(code: string, message: string, details?: string) { + super(message); + this.name = "CoreRuleError"; + this.code = code; + this.details = details; + } +} + +type RuleResponse = + | { id: string | null; ok: true; data: TData } + | { id: string | null; ok: false; error: { code: string; message: string; details?: string } }; + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); +let exportsRef: RulesExports | null = null; +let loading: Promise | null = null; + +function requiredExports(instance: WebAssembly.Instance): RulesExports { + const candidate = instance.exports as Partial; + if ( + !(candidate.memory instanceof WebAssembly.Memory) || + typeof candidate.lithe_rules_alloc !== "function" || + typeof candidate.lithe_rules_dealloc !== "function" || + typeof candidate.lithe_rules_execute !== "function" || + typeof candidate.lithe_rules_result_len !== "function" + ) { + throw new Error("The rules module does not export the expected C ABI"); + } + return candidate as RulesExports; +} + +async function readModuleBytes( + source: BufferSource | Promise | undefined, +): Promise { + if (source) return source; + const response = await fetch(CORE_RULES_WASM_URL); + if (!response.ok) { + throw new Error(`Could not fetch ${CORE_RULES_WASM_URL}: HTTP ${response.status}`); + } + return response.arrayBuffer(); +} + +/** + * Loads and instantiates the rules module. Call once before rendering UI that needs a rule. + * Repeated calls share one load; a failed load can be retried by calling again. + * + * `source` lets tests supply the module bytes from disk instead of fetching them. + */ +export function initializeCoreRules(source?: BufferSource | Promise): Promise { + if (exportsRef) return Promise.resolve(); + loading ??= (async () => { + const bytes = await readModuleBytes(source); + const { instance } = (await WebAssembly.instantiate( + bytes, + {}, + )) as unknown as WebAssembly.WebAssemblyInstantiatedSource; + exportsRef = requiredExports(instance); + })().catch((error: unknown) => { + loading = null; + throw error; + }); + return loading; +} + +/** Whether `initializeCoreRules()` has completed successfully. */ +export function isCoreRulesReady(): boolean { + return exportsRef !== null; +} + +/** User-facing text for a failed startup load; uses the shared i18n catalog. */ +export function coreRulesInitFailureMessage(error: unknown): string { + const detail = error instanceof Error ? error.message : String(error); + return createTranslator(DEFAULT_DISPLAY_LANGUAGE)("coreRules.initFailed", { message: detail }); +} + +/** + * Runs one rule synchronously and returns its `data`, or throws {@link CoreRuleError}. + * `command` and `payload` follow the matching `lithe-core` command contract. + */ +export function executeCoreRuleSync( + command: string, + payload: TPayload, +): TData { + const wasm = exportsRef; + if (!wasm) { + throw new CoreRuleError( + "not_supported", + createTranslator(DEFAULT_DISPLAY_LANGUAGE)("coreRules.notReady"), + ); + } + const request = encoder.encode(JSON.stringify({ id: "core-rules", command, payload })); + const requestPointer = wasm.lithe_rules_alloc(request.length); + let responsePointer = 0; + let responseLength = 0; + try { + // Growing memory detaches earlier views, so take a fresh view for each access. + new Uint8Array(wasm.memory.buffer, requestPointer, request.length).set(request); + responsePointer = wasm.lithe_rules_execute(requestPointer, request.length); + responseLength = wasm.lithe_rules_result_len(); + const text = decoder.decode(new Uint8Array(wasm.memory.buffer, responsePointer, responseLength)); + const response = JSON.parse(text) as RuleResponse; + if (!response.ok) { + throw new CoreRuleError(response.error.code, response.error.message, response.error.details); + } + return response.data; + } finally { + wasm.lithe_rules_dealloc(requestPointer, request.length); + if (responsePointer) wasm.lithe_rules_dealloc(responsePointer, responseLength); + } +} diff --git a/windows/tauri/src/i18n/shared.generated.ts b/windows/tauri/src/i18n/shared.generated.ts index e4e6e115ce..ca812d6227 100644 --- a/windows/tauri/src/i18n/shared.generated.ts +++ b/windows/tauri/src/i18n/shared.generated.ts @@ -971,6 +971,8 @@ export const sharedCatalogs = { "commandPalette.searchThemes": "Search themes...", "commandPalette.title": "Command palette", "commandPalette.uploadTheme": "Upload theme", + "coreRules.initFailed": "Lithe could not load its built-in rules module, so it cannot start. Restart the app; if this keeps happening, reinstall Lithe. Details: {message}", + "coreRules.notReady": "The built-in rules module is not loaded yet.", "csv.auto": "Auto", "csv.changeDelimiter": "Change delimiter", "csv.comma": "Comma", @@ -6066,6 +6068,8 @@ export const sharedCatalogs = { "commandPalette.searchThemes": "搜索主题...", "commandPalette.title": "命令面板", "commandPalette.uploadTheme": "上传主题", + "coreRules.initFailed": "Lithe 无法加载内置规则模块,因此无法启动。请重启应用;如果问题持续出现,请重新安装 Lithe。详情:{message}", + "coreRules.notReady": "内置规则模块尚未加载。", "csv.auto": "自动", "csv.changeDelimiter": "更改分隔符", "csv.comma": "逗号", diff --git a/windows/tauri/src/main.tsx b/windows/tauri/src/main.tsx index c76671484e..506e9f07d5 100644 --- a/windows/tauri/src/main.tsx +++ b/windows/tauri/src/main.tsx @@ -4,6 +4,7 @@ import App from "./App.tsx"; import { recordStartupMilestone } from "./features/bootstrap/startup-performance.ts"; import { initializeFrontendTerminalSession } from "./features/terminal/utils/frontend-terminal-session.ts"; import { traceWindowOpen } from "./features/window/utils/window-open-diagnostics.ts"; +import { coreRulesInitFailureMessage, initializeCoreRules } from "./core/core-rules.ts"; import { initializeFrontendLogging } from "./features/logging/frontend-log-runtime.ts"; void initializeFrontendLogging(); @@ -18,14 +19,32 @@ recordStartupMilestone("frontend:entry"); const renderStartedAt = performance.now(); traceWindowOpen("reactRender:start"); +function showStartupFailure(message: string) { + const root = document.getElementById("root")!; + const notice = document.createElement("pre"); + notice.setAttribute("role", "alert"); + notice.style.cssText = "white-space:pre-wrap;padding:24px;font:14px system-ui,sans-serif"; + notice.textContent = message; + root.replaceChildren(notice); +} + +// Rules that run on synchronous hot paths live in wasm. Chromium only compiles modules larger +// than 4 KB asynchronously, so load it first; the UI must not render without it. +const coreRulesReady = initializeCoreRules(); + void initializeFrontendTerminalSession() .catch((error) => { console.warn("Failed to clean up stale terminal sessions:", error); }) - .finally(() => { + .then(() => coreRulesReady) + .then( + () => { createRoot(document.getElementById("root")!).render(); traceWindowOpen("reactRender:scheduled", { durationMs: Math.round((performance.now() - renderStartedAt) * 100) / 100, }); recordStartupMilestone("react:scheduled"); - }); + }, + // Only the rules load can reject here; a render error is not a rules failure. + (error) => showStartupFailure(coreRulesInitFailureMessage(error)), + ); diff --git a/windows/tauri/src/test-utils/core-rules.ts b/windows/tauri/src/test-utils/core-rules.ts new file mode 100644 index 0000000000..94c8f9924e --- /dev/null +++ b/windows/tauri/src/test-utils/core-rules.ts @@ -0,0 +1,44 @@ +import { spawnSync } from "node:child_process"; +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { initializeCoreRules } from "@/core/core-rules"; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../.."); +const STAGED_WASM = path.join(ROOT, "windows/tauri/public/core-rules/lithe_core_rules.wasm"); +const PREPARE_SCRIPT = path.join(ROOT, "scripts/windows/prepare/prepare-core-rules-wasm.mjs"); +// A cold cargo build of the tiny rules crate takes seconds; the bound only guards against a hang. +const BUILD_TIMEOUT_MS = 300_000; + +function newestSourceTime(directory: string): number { + return readdirSync(directory, { withFileTypes: true }).reduce((newest, entry) => { + const full = path.join(directory, entry.name); + const time = entry.isDirectory() ? newestSourceTime(full) : statSync(full).mtimeMs; + return Math.max(newest, time); + }, 0); +} + +function stagedModuleIsCurrent(): boolean { + if (!existsSync(STAGED_WASM)) return false; + const rulesRoot = path.join(ROOT, "rust/lithe-core-rules"); + const sources = Math.max(newestSourceTime(path.join(rulesRoot, "src")), statSync(path.join(rulesRoot, "Cargo.toml")).mtimeMs); + return statSync(STAGED_WASM).mtimeMs >= sources; +} + +/** + * Loads the real wasm build of lithe-core-rules for a test. The module is the same artifact the + * app ships. When it is missing or older than the Rust sources, this builds it first through the + * production script, which needs rustup with the wasm32-unknown-unknown target. + */ +export async function loadCoreRulesForTests(): Promise { + if (!stagedModuleIsCurrent()) { + const build = spawnSync("node", [PREPARE_SCRIPT], { + encoding: "utf8", + timeout: BUILD_TIMEOUT_MS, + }); + if (build.status !== 0) { + throw new Error(`Could not build the lithe-core-rules wasm module:\n${build.stdout}\n${build.stderr}`); + } + } + await initializeCoreRules(readFileSync(STAGED_WASM)); +} From 8f3870c0423021163e57e25fb7d40fa7ccf97d30 Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Thu, 5 Nov 2026 17:42:56 -0800 Subject: [PATCH 2/6] =?UTF-8?q?WS-17=20=E5=8F=8C=E5=87=BB=20Shift=20?= =?UTF-8?q?=E6=89=8B=E5=8A=BF=E6=94=B9=E8=B5=B0=20Core=EF=BC=8C=E5=88=A0?= =?UTF-8?q?=E9=99=A4=E4=B8=A4=E7=AB=AF=E7=AE=97=E6=B3=95=20(#1662)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - macOS DoubleShiftGestureRecognizer 和 Windows double-shift-recognizer.ts 只保留薄适配层,状态机和阈值由 keymap.modifierGestureStep 提供;macOS 经 FFI 同步调用,Windows 经 wasm 同步调用 - 共享 fixture modifier-gesture-v1.json 由 Rust 测试、macOS 测试(真实 FFI)和 Windows bun 测试(真实 wasm)逐步驱动 - 删除功能矩阵、契约文档和代码注释里的 WS-17 例外说明,契约补充 keymap.modifierGestureStep - 新增 Agent Note 2026-11-05-core-rules-wasm-sync-bridge.md,并在 develop-lithe Skill 加一行指引 Co-Authored-By: Claude Sonnet 5.5 --- .../2026-11-05-core-rules-wasm-sync-bridge.md | 78 ++++++++++++++++ .agents/skills/develop-lithe/SKILL.md | 1 + .../Core/Rust/RustDoubleShiftGesture.swift | 85 ++++++++++++++++++ .../MacOS/UI/MacShortcutDetector.swift | 74 +-------------- .../KeymapTerminalCoreFixtureTests.swift | 5 +- shared/contracts/rust-core-api.md | 12 ++- .../features/keymap-customization.json | 8 +- .../utils/double-shift-recognizer.test.ts | 26 ++++-- .../keymaps/utils/double-shift-recognizer.ts | 89 +++++++------------ 9 files changed, 234 insertions(+), 144 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-11-05-core-rules-wasm-sync-bridge.md create mode 100644 macos/Sources/Lithe/Core/Rust/RustDoubleShiftGesture.swift diff --git a/.agents/notes/implemented/architecture/2026-11-05-core-rules-wasm-sync-bridge.md b/.agents/notes/implemented/architecture/2026-11-05-core-rules-wasm-sync-bridge.md new file mode 100644 index 0000000000..3f5e9293e9 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-11-05-core-rules-wasm-sync-bridge.md @@ -0,0 +1,78 @@ +# Agent 笔记:同步规则桥(lithe-core-rules 与 wasm) + +状态:已实现 + +## 先说结论 + +少数必须“同步”执行的小规则(例如双击 Shift 手势,每个按键事件都要立刻出结果)放进新的纯 Rust crate +`rust/lithe-core-rules`。`lithe-core` 依赖它,macOS 通过原来的 FFI(Swift 调用 Rust 的 C 接口)调用; +同一份源码再编译成 wasm(WebAssembly,浏览器可运行的二进制),Windows 前端在启动时异步加载一次,之后同步调用。 +规则只有一份源码,不再在 Swift 和 TypeScript 各写一遍再靠 fixture 对照。 + +## 问题 + +Issue #1662 要求把两端重复的确定性逻辑下沉到 Core。Windows 前端调用 Core 只有异步的 Tauri `invoke`, +而几条规则卡在同步热路径上,下沉不了:LSP-16 在 zustand 的 `set()` 里同步规范化 pane 树;WS-04 在文件树渲染时 +同步判断隐藏;WS-17 在每个按键事件上同步运行。macOS 通过 FFI 同步调用 Core,没有这个问题。 +结果是这类规则只能两端各留一份实现,只靠共享 fixture 防止走样,而这本身就是 #1662 想消除的重复。 + +## 决策 + +- **新 crate `lithe-core-rules`**:只依赖 serde 和 serde_json,不碰文件系统、进程、线程、时钟(时间由调用方传入), + 能编译到 `wasm32-unknown-unknown`。规则以“状态 + 事件 + 时间戳 → 新状态 + 结果”的纯函数表达,状态由宿主持有。 +- **命令仍是 Core 命令**:`lithe-core` 的 JSON 分发把这些命令转给 crate,命令名、请求/响应 envelope(信封)和 + 错误码与其他 Core 命令一致,例如 `keymap.modifierGestureStep`。macOS 和 Windows 的 Rust 后端走原通道。 +- **wasm ABI 不用 wasm-bindgen**:只导出 `lithe_rules_alloc`、`lithe_rules_dealloc`、`lithe_rules_execute`、 + `lithe_rules_result_len` 四个 `extern "C"` 函数,输入输出都是 JSON 字节,复用 Core 的 envelope。 +- **启动时异步加载**:Chromium(WebView2)不允许主线程同步编译大于 4 KB 的 wasm,所以 + `windows/tauri/src/core/core-rules.ts` 的 `initializeCoreRules()` 在 `main.tsx` 里先异步 instantiate, + 成功后才渲染 React;之后 `executeCoreRuleSync(command, payload)` 全部同步。加载失败显示本地化错误 + (`coreRules.initFailed`),**不回退到 TypeScript 算法**。 +- **WS-17 作为首个端到端迁移**:Swift `DoubleShiftGestureRecognizer` 和 TS `double-shift-recognizer.ts` 只剩薄适配层 + (转换原生事件、保存 Core 返回的状态、把 `triggered` 变成动作)。macOS 每次事件一次同步 FFI,body 约 100 字节, + `keymap.*` 不进请求追踪;按键速度下可以接受。 + +## 边界 + +只放**纯计算、体量小、处在同步热路径上**的规则。凡是涉及 I/O、进程、文件、网络、长耗时或大数据的逻辑, +仍放 `lithe-core` 并走异步 IPC。判断方法:如果 Windows 调用方可以 `await`,就不要放进这个 crate。 +不要把它当成第二个 Core 来堆功能;wasm 体积会拖慢每次启动。 + +## 考虑过的备选方案 + +- **Windows pane store 全部改为异步**:`set()` 里不能 `await`,要重写所有读写 store 的组件并处理中间状态竞态, + 风险和范围远大于这几条规则,被否决。 +- **TS 与 Swift 各留一份算法,靠共享 fixture 对照**:这是迁移前的做法。fixture 只能发现已写入的用例, + 新分支仍会漂移,且违反“可共享逻辑进 Core”的规则,被否决。 +- **引入 wasm-bindgen**:需要额外工具链(wasm-bindgen-cli)、生成胶水 JS,版本需与依赖严格匹配; + 我们只需要 JSON 进 JSON 出,手写四个 C 函数更小、更可审计,被否决。 + +## 构建与 CI + +- `scripts/windows/prepare/prepare-core-rules-wasm.mjs` 把 `lithe_core_rules.wasm` 编译(release,`opt-level="s"`、LTO、strip) + 并放到 `windows/tauri/public/core-rules/`。该目录被 `.gitignore` 忽略,**不提交**。 +- 接入 Windows `dev`、`build`(`desktop:build` 的 beforeBuildCommand 经 `bun run build` 间接包含)、`ci-windows.yml` + 前端 job(安装 `wasm32-unknown-unknown` target 并构建)、`release-windows.yml`、`release-preview-windows.yml`。 + bun 测试辅助 `src/test-utils/core-rules.ts` 在产物缺失或旧于源码时调用同一个脚本重建。 +- 工作树资源复用:产物没有可靠的 identity stamp,登记为不可复用的 `core-rules-wasm`,各工作树自行重建(几秒)。 +- 运行时只读:wasm 是打包进前端资源的只读输入,运行时不写入,不影响代码签名和 Sparkle delta。 +- 注释检查 `verify-rust-core-comments.sh`、rustfmt、日志检查和 Rust 单测已把新 crate 纳入覆盖范围。 + +## 后果 + +- 新增规则要在 `lithe-core-rules` 的 `COMMANDS` 和 `lithe-core` 的 `CoreCommand` 同时注册; + `every_rules_crate_command_is_routed_by_core` 测试会拦截遗漏。 +- Windows 本地开发需要 `rustup target add wasm32-unknown-unknown`;缺失时脚本给出明确提示。 +- 当前 wasm 体积约 141 KB(release,未压缩)。 + +## 验证 + +- `cargo test -p lithe-core-rules` 与 `cargo test -p lithe-core --lib protocol`:同一份 fixture 经 JSON envelope 逐步驱动。 +- `cargo build -p lithe-core-rules --target wasm32-unknown-unknown --release` 成功,且产物经 bun 测试真实加载。 +- `bun` 测试 `double-shift-recognizer.test.ts` 与 `core-rules.test.ts` 通过 wasm 跑同一份 + `shared/fixtures/keymap/modifier-gesture-v1.json`;macOS `KeymapTerminalCoreFixtureTests` 通过真实 FFI 跑它。 +- `bun run build` 产物中包含 `core-rules/lithe_core_rules.wasm`。真实 Windows WebView2 加载需在 Windows 机器上确认。 + +## 适用范围 + +适用于所有需要在 Windows 前端同步执行的纯规则。第二阶段计划迁入 LSP-16(pane 树规范化)和 WS-04(隐藏判断)。 diff --git a/.agents/skills/develop-lithe/SKILL.md b/.agents/skills/develop-lithe/SKILL.md index c3a88011ef..f67a19834d 100644 --- a/.agents/skills/develop-lithe/SKILL.md +++ b/.agents/skills/develop-lithe/SKILL.md @@ -52,6 +52,7 @@ description: 在 Lithe 仓库进行实现、重构、缺陷修复、审查、调 | 修复缺陷 | 本文件第八节,以及 `.agents/notes/implemented/bug-fix/` 中涉及同一模块的 Note | | 新增、迁移或审查日志事件、写入器和诊断采集;新增界面警告或错误;写会丢弃错误的代码 | [日志接入](references/logging.md),声明规则以 `shared/logging/README.md` 为准 | | 新增、移动或审查脚本 | [脚本放置与分类](references/scripts.md) | +| 新增 Windows 前端必须同步调用的纯规则(按键、`set()`、渲染路径) | 先读 `.agents/notes/implemented/architecture/2026-11-05-core-rules-wasm-sync-bridge.md`,放进 `rust/lithe-core-rules` | | 创建、迁移、更新、归档或审查 Agent Note | `.agents/skills/agent-notes/SKILL.md` | | 创建、修改或审查测试及测试基础设施 | `.agents/skills/write-stable-tests/SKILL.md` | diff --git a/macos/Sources/Lithe/Core/Rust/RustDoubleShiftGesture.swift b/macos/Sources/Lithe/Core/Rust/RustDoubleShiftGesture.swift new file mode 100644 index 0000000000..71afa41d33 --- /dev/null +++ b/macos/Sources/Lithe/Core/Rust/RustDoubleShiftGesture.swift @@ -0,0 +1,85 @@ +import Foundation + +/// Thin adapter over Core's `keymap.modifierGestureStep`. The double-Shift state machine and its +/// time threshold live in rust/lithe-core-rules; Windows runs the same Rust source as wasm. This +/// type only carries the state Core returned between events and converts AppKit input to the +/// rule's input. Each call is one synchronous FFI round trip with a ~100 byte JSON body. +struct DoubleShiftGestureRecognizer { + /// Core-owned state. Swift never reads or branches on the fields; it only sends them back. + private struct State: Codable { + var shiftWasDown = false + var currentPressIsStandalone = false + var lastStandaloneTap: TimeInterval? + } + + private enum Event: Encodable { + case keyDown + case reset + case flags(shift: Bool, otherModifiers: Bool) + + private enum Keys: String, CodingKey { case kind, shift, otherModifiers } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: Keys.self) + switch self { + case .keyDown: try container.encode("keyDown", forKey: .kind) + case .reset: try container.encode("reset", forKey: .kind) + case let .flags(shift, otherModifiers): + try container.encode("flags", forKey: .kind) + try container.encode(shift, forKey: .shift) + try container.encode(otherModifiers, forKey: .otherModifiers) + } + } + } + + private struct Request: Encodable { + let state: State + let event: Event + let timestamp: TimeInterval + } + + private struct Response: Decodable { + let state: State + let triggered: Bool + } + + private let core: RustCoreBridge + private var state = State() + + init(core: RustCoreBridge = RustCoreBridge()) { + self.core = core + } + + mutating func handleKeyDown() { + _ = step(.keyDown, timestamp: 0) + } + + mutating func reset() { + _ = step(.reset, timestamp: 0) + } + + mutating func handleFlagsChanged( + isShiftDown: Bool, + hasOtherModifiers: Bool, + timestamp: TimeInterval + ) -> Bool { + step(.flags(shift: isShiftDown, otherModifiers: hasOtherModifiers), timestamp: timestamp) + } + + private mutating func step(_ event: Event, timestamp: TimeInterval) -> Bool { + let result: Result = core.executeResult( + command: "keymap.modifierGestureStep", + payload: Request(state: state, event: event, timestamp: timestamp) + ) + switch result { + case let .success(response): + state = response.state + return response.triggered + case .failure: + // A valid request cannot fail unless the linked Core is broken; there is no + // second implementation to fall back to, so the gesture simply does not fire. + assertionFailure("keymap.modifierGestureStep failed") + return false + } + } +} diff --git a/macos/Sources/Lithe/Platform/MacOS/UI/MacShortcutDetector.swift b/macos/Sources/Lithe/Platform/MacOS/UI/MacShortcutDetector.swift index c054668580..37e7b76b8b 100644 --- a/macos/Sources/Lithe/Platform/MacOS/UI/MacShortcutDetector.swift +++ b/macos/Sources/Lithe/Platform/MacOS/UI/MacShortcutDetector.swift @@ -183,8 +183,6 @@ enum MacShortcutInputPolicy { /// Matches ordinary key presses and double-modifier taps for application commands. private final class MacShortcutDetector: ShortcutDetector, @unchecked Sendable { - private static let doubleTapThreshold: TimeInterval = 0.35 - private var registrations: [KeyboardShortcutRegistration] = [] private var isSuspended = false private var doubleShiftRecognizer: DoubleShiftGestureRecognizer @@ -194,9 +192,7 @@ private final class MacShortcutDetector: ShortcutDetector, @unchecked Sendable { init(onCommand: @escaping @MainActor @Sendable (String) -> Void) { self.onCommand = onCommand - doubleShiftRecognizer = DoubleShiftGestureRecognizer( - threshold: Self.doubleTapThreshold - ) + doubleShiftRecognizer = DoubleShiftGestureRecognizer() } func update(registrations: [KeyboardShortcutRegistration]) { @@ -304,71 +300,3 @@ private final class MacShortcutDetector: ShortcutDetector, @unchecked Sendable { doubleShiftRecognizer.reset() } } - -/// Recognizes two standalone Shift taps while rejecting Shift-modified typing. -/// It runs on every native key event, so it stays out of Core (no per-key IPC). Windows keeps the -/// same machine in double-shift-recognizer.ts; both are tested against -/// shared/fixtures/keymap/modifier-gesture-v1.json and must change together. -struct DoubleShiftGestureRecognizer { - let threshold: TimeInterval - private(set) var shiftWasDown = false - private var currentPressIsStandalone = false - private var lastStandaloneTap: TimeInterval? - - init(threshold: TimeInterval) { - self.threshold = threshold - } - - mutating func handleKeyDown() { - currentPressIsStandalone = false - lastStandaloneTap = nil - } - - mutating func reset() { - shiftWasDown = false - currentPressIsStandalone = false - lastStandaloneTap = nil - } - - mutating func handleFlagsChanged( - isShiftDown: Bool, - hasOtherModifiers: Bool, - timestamp: TimeInterval - ) -> Bool { - if isShiftDown, !shiftWasDown { - currentPressIsStandalone = !hasOtherModifiers - shiftWasDown = true - return false - } - - if isShiftDown, shiftWasDown { - if hasOtherModifiers { - currentPressIsStandalone = false - lastStandaloneTap = nil - } - return false - } - - if !isShiftDown, shiftWasDown { - shiftWasDown = false - defer { currentPressIsStandalone = false } - guard currentPressIsStandalone, !hasOtherModifiers else { - lastStandaloneTap = nil - return false - } - if let lastStandaloneTap, - timestamp - lastStandaloneTap >= 0, - timestamp - lastStandaloneTap < threshold { - self.lastStandaloneTap = nil - return true - } - lastStandaloneTap = timestamp - return false - } - - if hasOtherModifiers { - lastStandaloneTap = nil - } - return false - } -} diff --git a/macos/Tests/LitheTests/KeymapTerminalCoreFixtureTests.swift b/macos/Tests/LitheTests/KeymapTerminalCoreFixtureTests.swift index 7ef37b8fa6..bf0523a10d 100644 --- a/macos/Tests/LitheTests/KeymapTerminalCoreFixtureTests.swift +++ b/macos/Tests/LitheTests/KeymapTerminalCoreFixtureTests.swift @@ -12,12 +12,11 @@ struct KeymapTerminalCoreFixtureTests { } @Test - func doubleShiftRecognizerMatchesTheSharedGestureFixture() throws { + func doubleShiftAdapterMatchesTheSharedGestureFixtureThroughCore() throws { let fixture = try Self.fixture("keymap/modifier-gesture-v1.json") - let threshold = try #require(fixture["thresholdSeconds"] as? Double) for sequence in try #require(fixture["sequences"] as? [[String: Any]]) { let name = sequence["name"] as? String ?? "" - var recognizer = DoubleShiftGestureRecognizer(threshold: threshold) + var recognizer = DoubleShiftGestureRecognizer() for (index, step) in (sequence["steps"] as? [[String: Any]] ?? []).enumerated() { switch step["event"] as? String { case "keyDown": recognizer.handleKeyDown() diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index b9c4e7b363..fc9da7ad7a 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -149,9 +149,15 @@ is the double-tap Shift gesture. Parse failures are listed in `invalid` with `em command. Native key-event conversion and the synchronous matching path stay in the host. See `shared/fixtures/keymap/binding-conflicts-v1.json`. -The double-Shift gesture state machine is intentionally not a Core command: it must -run synchronously on every native key event. Both hosts keep a thin implementation and -are tested step by step against `shared/fixtures/keymap/modifier-gesture-v1.json`. +`keymap.modifierGestureStep` accepts `{state?, event, timestamp}` and returns `{state, triggered}`. It is the +double-Shift gesture as a pure transition: `event` is `{kind: "keyDown"}`, `{kind: "reset"}` or +`{kind: "flags", shift, otherModifiers}`; `timestamp` is seconds on a clock the host keeps; `state` is opaque +host-held memory (omit it for the idle state) that the host sends back with the next event. The rule lives in the +pure crate `rust/lithe-core-rules` (no I/O, no clock) so it can run on a synchronous key path: macOS calls it +through the C ABI, and the Windows frontend runs the same source compiled to wasm (`lithe_rules_alloc`, +`lithe_rules_dealloc`, `lithe_rules_execute` over JSON envelopes), loaded asynchronously at startup. A malformed +payload is `invalid_request`. Hosts only convert native events to this input and act on `triggered`; both are +tested step by step against `shared/fixtures/keymap/modifier-gesture-v1.json`. ## Terminal link targets diff --git a/shared/platform-feature-matrix/features/keymap-customization.json b/shared/platform-feature-matrix/features/keymap-customization.json index 0612cd43cb..5acb8b5ac7 100644 --- a/shared/platform-feature-matrix/features/keymap-customization.json +++ b/shared/platform-feature-matrix/features/keymap-customization.json @@ -10,6 +10,8 @@ "macos/Sources/Lithe/Models/AppModel/AppModel+SearchModule.swift", "macos/Tests/LitheTests/SearchEverywhereQueryModeTests.swift", "macos/Sources/Lithe/Core/Rust/RustKeymapConflicts.swift", + "macos/Sources/Lithe/Core/Rust/RustDoubleShiftGesture.swift", + "rust/lithe-core-rules/src/keymap/modifier_gesture.rs", "macos/Tests/LitheTests/KeymapTerminalCoreFixtureTests.swift", "shared/fixtures/keymap/binding-conflicts-v1.json", "shared/fixtures/keymap/modifier-gesture-v1.json" @@ -32,12 +34,14 @@ "windows/tauri/src/features/keymaps/utils/save-keybinding.ts", "windows/tauri/src/features/keymaps/utils/binding-conflicts.native.integration.test.ts", "shared/fixtures/keymap/modifier-gesture-v1.json", - "windows/tauri/src/features/keymaps/utils/double-shift-recognizer.test.ts" + "windows/tauri/src/features/keymaps/utils/double-shift-recognizer.test.ts", + "windows/tauri/src/core/core-rules.ts", + "rust/lithe-core-rules/src/keymap/modifier_gesture.rs" ], "implementationStatus": "implemented", "verificationStatus": "pending" }, "owner": "Settings", "verification": "macOS 使用默认 Command-Shift-A 从编辑器、侧栏及第二个项目窗口打开 Find Action,确认输入聚焦、/ 命令过滤、Return 仅执行一次、Escape 关闭且重新打开无旧查询;弹窗已打开时再次调用应切换到 / 并聚焦。改绑、禁用、恢复及录制时确认菜单与分发使用有效绑定;无工作区或搜索模块不可用时应禁用,激活失败应关闭。IDEA Classic 和 Eclipse 的 Find Action 暂无默认绑定,普通 Search Everywhere 保留双击 Shift 和 Shift-Command-O。macOS 从设置 → 快捷键依次选择 macOS、IntelliJ IDEA Classic、Eclipse,核对运行、调试、查找等操作在界面和实际监听器中的绑定;IDEA Classic 的 Stop Run 应为 Control-F2,Eclipse 的 Resume 应为 F8,继承父 keymap 的命令应使用上游绑定,Find、Replace、GotoImplementation 等显式清空的命令应无绑定;检查分类树初始折叠、全部展开和折叠、分类展开、搜索时自动显示匹配操作、选中行高亮、右侧紧凑键帽及工具栏编辑菜单;通过键帽、工具栏或右键菜单新增、删除与恢复默认,并检查键盘辅助功能;分别修改两套预设的同一命令,切换与重启后确认自定义绑定互不覆盖,并验证旧版单套覆盖迁移到 macOS;按命令名称、ID 和按键搜索,制造冲突并恢复当前预设或应用全部默认;导出再导入 keybindings.json,确认预设与自定义绑定恢复且命令实际执行。Windows 保持现有快捷键编辑、导出和导入验证。", - "notes": "Windows 已接入现有快捷键编辑器,修复 #718 的占位入口;WS-09 / BUG-2 的 when 解析、校验和布尔结果由 Core 生成,导入/保存先校验,按键时只查预编译结果。验证导入 @、单个 &/|/=、未闭合引号/括号、缺少操作数和超预算条件时拒绝且保留原配置;检查坏历史条件不阻塞后续按键,并检查有效条件随焦点切换。原生 ABI 与 DOM 测试不替代真实 Windows WebView2、文件对话框及整机快捷键验收。 WS-08:快捷键冲突由 Core 的 keymap.analyzeConflicts 按最终生效绑定(默认、预设、用户逐层覆盖)判定,修饰键顺序、大小写与别名不同的等价绑定视为同一个;两端保存冲突绑定都会被拒绝,Windows 导入同样先整体校验。验证:Windows 把一个命令改绑成另一个命令的默认键(含不同修饰键顺序)应被拒绝并提示冲突命令,行内冲突提示覆盖默认和预设绑定;导入含冲突的文件时不改预设也不写入。WS-17:双击 Shift 手势状态机是有意保留的双端实现(macOS DoubleShiftGestureRecognizer,Windows double-shift-recognizer.ts):按键路径必须同步且不能每个原生按键事件走一次 Core IPC(Windows 的 Tauri invoke 是异步的),两端状态机由同一份 shared/fixtures/keymap/modifier-gesture-v1.json 逐步对照,改动任一端必须同时改 fixture 与两端测试。" + "notes": "Windows 已接入现有快捷键编辑器,修复 #718 的占位入口;WS-09 / BUG-2 的 when 解析、校验和布尔结果由 Core 生成,导入/保存先校验,按键时只查预编译结果。验证导入 @、单个 &/|/=、未闭合引号/括号、缺少操作数和超预算条件时拒绝且保留原配置;检查坏历史条件不阻塞后续按键,并检查有效条件随焦点切换。原生 ABI 与 DOM 测试不替代真实 Windows WebView2、文件对话框及整机快捷键验收。 WS-08:快捷键冲突由 Core 的 keymap.analyzeConflicts 按最终生效绑定(默认、预设、用户逐层覆盖)判定,修饰键顺序、大小写与别名不同的等价绑定视为同一个;两端保存冲突绑定都会被拒绝,Windows 导入同样先整体校验。验证:Windows 把一个命令改绑成另一个命令的默认键(含不同修饰键顺序)应被拒绝并提示冲突命令,行内冲突提示覆盖默认和预设绑定;导入含冲突的文件时不改预设也不写入。WS-17:双击 Shift 手势状态机由 Core 的 keymap.modifierGestureStep(rust/lithe-core-rules)提供,macOS 通过 FFI 同步调用,Windows 启动时异步加载同一份源码编译的 wasm 后同步调用;两端只把原生事件转成输入、把输出变成动作,由 shared/fixtures/keymap/modifier-gesture-v1.json 经 Rust 与 wasm 逐步验证。" } diff --git a/windows/tauri/src/features/keymaps/utils/double-shift-recognizer.test.ts b/windows/tauri/src/features/keymaps/utils/double-shift-recognizer.test.ts index 4136fd4545..5de5ed61dc 100644 --- a/windows/tauri/src/features/keymaps/utils/double-shift-recognizer.test.ts +++ b/windows/tauri/src/features/keymaps/utils/double-shift-recognizer.test.ts @@ -1,16 +1,17 @@ -import { describe, expect, test } from "bun:test"; +import { beforeAll, describe, expect, test } from "bun:test"; import fixture from "../../../../../../shared/fixtures/keymap/modifier-gesture-v1.json"; -import { DoubleShiftGestureRecognizer, DOUBLE_SHIFT_THRESHOLD_SECONDS } from "./double-shift-recognizer"; +import { executeCoreRuleSync } from "@/core/core-rules"; +import { loadCoreRulesForTests } from "@/test-utils/core-rules"; +import { DoubleShiftGestureRecognizer } from "./double-shift-recognizer"; -// The same fixture drives the Swift recognizer, so the two platform state machines cannot drift. -describe("double Shift gesture (shared fixture)", () => { - test("threshold matches the fixture", () => { - expect(DOUBLE_SHIFT_THRESHOLD_SECONDS).toBe(fixture.thresholdSeconds); - }); +// The same fixture drives the Rust tests in lithe-core-rules and lithe-core and the macOS +// adapter test, so every host sees one gesture definition. Here it runs through the real wasm. +beforeAll(loadCoreRulesForTests); +describe("double Shift gesture (shared fixture through wasm)", () => { for (const sequence of fixture.sequences) { test(sequence.name, () => { - const recognizer = new DoubleShiftGestureRecognizer(fixture.thresholdSeconds); + const recognizer = new DoubleShiftGestureRecognizer(); for (const [index, step] of sequence.steps.entries()) { if (step.event === "keyDown") recognizer.handleKeyDown(); else if (step.event === "reset") recognizer.reset(); @@ -25,4 +26,13 @@ describe("double Shift gesture (shared fixture)", () => { } }); } + + test("malformed rule requests fail with the Core error code", () => { + expect(() => executeCoreRuleSync("keymap.modifierGestureStep", { event: { kind: "bogus" } })).toThrow( + expect.objectContaining({ code: "invalid_request" }), + ); + expect(() => executeCoreRuleSync("keymap.unknown", {})).toThrow( + expect.objectContaining({ code: "not_supported" }), + ); + }); }); diff --git a/windows/tauri/src/features/keymaps/utils/double-shift-recognizer.ts b/windows/tauri/src/features/keymaps/utils/double-shift-recognizer.ts index 8c0c64c637..f9a45e6171 100644 --- a/windows/tauri/src/features/keymaps/utils/double-shift-recognizer.ts +++ b/windows/tauri/src/features/keymaps/utils/double-shift-recognizer.ts @@ -1,30 +1,43 @@ +import { executeCoreRuleSync } from "@/core/core-rules"; + /** - * Double-Shift gesture state machine. It runs synchronously on every key event, so it - * deliberately stays out of Core (no per-key IPC). macOS keeps the same machine in - * MacShortcutDetector.swift; both are tested against - * shared/fixtures/keymap/modifier-gesture-v1.json and must change together. + * Thin adapter over Core's `keymap.modifierGestureStep` rule. The gesture state machine itself + * lives in rust/lithe-core-rules and runs here as wasm, so key events stay synchronous while + * macOS and Windows share one implementation. This class only holds the opaque state Core + * returned and turns DOM events into the rule's input. */ -export const DOUBLE_SHIFT_THRESHOLD_SECONDS = 0.35; +const MODIFIER_GESTURE_STEP = "keymap.modifierGestureStep"; -export class DoubleShiftGestureRecognizer { - readonly threshold: number; - private shiftWasDown = false; - private currentPressIsStandalone = false; - private lastStandaloneTap: number | null = null; +interface GestureStepResult { + state: unknown; + triggered: boolean; +} + +type GestureEvent = + | { kind: "keyDown" } + | { kind: "reset" } + | { kind: "flags"; shift: boolean; otherModifiers: boolean }; - constructor(threshold = DOUBLE_SHIFT_THRESHOLD_SECONDS) { - this.threshold = threshold; +export class DoubleShiftGestureRecognizer { + // Core defines the shape; an empty object is its idle state. + private state: unknown = {}; + + private step(event: GestureEvent, timestamp: number): boolean { + const result = executeCoreRuleSync(MODIFIER_GESTURE_STEP, { + state: this.state, + event, + timestamp, + }); + this.state = result.state; + return result.triggered; } handleKeyDown() { - this.currentPressIsStandalone = false; - this.lastStandaloneTap = null; + this.step({ kind: "keyDown" }, 0); } reset() { - this.shiftWasDown = false; - this.currentPressIsStandalone = false; - this.lastStandaloneTap = null; + this.step({ kind: "reset" }, 0); } handleFlagsChanged( @@ -32,44 +45,10 @@ export class DoubleShiftGestureRecognizer { hasOtherModifiers: boolean, timestamp: number, ): boolean { - if (isShiftDown && !this.shiftWasDown) { - this.currentPressIsStandalone = !hasOtherModifiers; - this.shiftWasDown = true; - return false; - } - - if (isShiftDown && this.shiftWasDown) { - if (hasOtherModifiers) { - this.currentPressIsStandalone = false; - this.lastStandaloneTap = null; - } - return false; - } - - if (!isShiftDown && this.shiftWasDown) { - this.shiftWasDown = false; - const wasStandalone = this.currentPressIsStandalone; - this.currentPressIsStandalone = false; - if (!wasStandalone || hasOtherModifiers) { - this.lastStandaloneTap = null; - return false; - } - if ( - this.lastStandaloneTap !== null && - timestamp - this.lastStandaloneTap >= 0 && - timestamp - this.lastStandaloneTap < this.threshold - ) { - this.lastStandaloneTap = null; - return true; - } - this.lastStandaloneTap = timestamp; - return false; - } - - if (hasOtherModifiers) { - this.lastStandaloneTap = null; - } - return false; + return this.step( + { kind: "flags", shift: isShiftDown, otherModifiers: hasOtherModifiers }, + timestamp, + ); } } From 9de0799abdf2de9f346e60bf80c9781e0b30ab2f Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Thu, 5 Nov 2026 18:34:19 -0800 Subject: [PATCH 3/6] =?UTF-8?q?fix(windows):=20=E5=90=8C=E6=AD=A5=20src-ta?= =?UTF-8?q?uri=20Cargo.lock=20=E4=B8=AD=20lithe-core=20=E7=9A=84=20similar?= =?UTF-8?q?=20=E4=BE=9D=E8=B5=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lithe-core 新增 similar(diff.compareTexts,#1758)时只更新了 rust/Cargo.lock, windows/tauri/src-tauri/Cargo.lock 缺少 similar 与 bstr 条目,导致 `cargo build --locked` 在 preview 上报 cannot update the lock file。 补上这两个条目,版本与 rust/Cargo.lock 一致,不升级其他依赖。 Co-Authored-By: Claude Opus 5.5 --- windows/tauri/src-tauri/Cargo.lock | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/windows/tauri/src-tauri/Cargo.lock b/windows/tauri/src-tauri/Cargo.lock index d70367fac1..5064ad2629 100644 --- a/windows/tauri/src-tauri/Cargo.lock +++ b/windows/tauri/src-tauri/Cargo.lock @@ -495,6 +495,16 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "bstr" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" +dependencies = [ + "memchr", + "serde_core", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -2909,6 +2919,7 @@ dependencies = [ "sha1", "sha2", "shell-words", + "similar", "toml 1.1.4+spec-1.1.0", "toml_edit 0.25.13+spec-1.1.0", "tree-sitter", @@ -5200,6 +5211,15 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "similar" +version = "3.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f8bec09ae8fae0f4f265a4559a9334be3929d8504e9b536df0a41302bd1733" +dependencies = [ + "bstr", +] + [[package]] name = "siphasher" version = "1.0.3" From 7d24541237cbcd73a3afdc5cdf1d3d3ccc4fb96e Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Thu, 5 Nov 2026 19:24:06 -0800 Subject: [PATCH 4/6] =?UTF-8?q?LSP-16=20=E7=BC=96=E8=BE=91=E5=99=A8?= =?UTF-8?q?=E5=88=86=E7=BB=84=E6=88=90=E5=91=98=E7=8A=B6=E6=80=81=E6=94=B9?= =?UTF-8?q?=E8=B5=B0=20Core=EF=BC=8C=E5=88=A0=E9=99=A4=E4=B8=A4=E7=AB=AF?= =?UTF-8?q?=E6=97=A7=E8=A7=84=E5=88=99=20(#1662)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 Core 命令 editor.workbenchState.reduce(rust/lithe-core-rules,同时经 lithe-core JSON 分发):规范“组成员状态”取两端并集(有序去重成员、选中项、MRU、固定、预览),动作含 normalize、open、close、activate、move、reorder、固定、预览、mergeGroup,返回新状态和 removedGroupIds - 两端在选中项离开分组时的既有行为不同,用 selectionFallback(adjacent / first)保留:Windows 选相邻标签,macOS 回到第一个标签 - Windows:pane-tree.ts 的 normalizeGroup、成员打开/关闭/移动/固定/预览等算法删除,经 workbench-state.ts 适配层在 zustand set() 里同步调用 wasm,每个动作一次 Core 调用;布局树只按 removedGroupIds 修剪;会话恢复不再自己去重 - macOS:EditorTabOrderFeatureModel 的 reconcileGroups、drop、insert、move 的归属与选中规则改为经 RustEditorWorkbenchState 调用 Core,EditorGroupLayout 只保留树结构和比例;MRU、固定、预览传空值 - 共享 fixture shared/fixtures/editor/workbench-state-v1.json(56 例)由 Rust、macOS(真实 FFI)和 Windows(真实 wasm)测试驱动;补 pane-tree、pane.store(含 Core 调用次数、拖动比例不调用 Core)、会话测试,三个 buffer 测试加载真实 wasm - 契约、功能矩阵 workbench-panes 和同步桥 Note 已更新 Co-Authored-By: Claude Sonnet 5.5 --- .../2026-11-05-core-rules-wasm-sync-bridge.md | 6 + .../Features/EditorTabOrderFeatureModel.swift | 122 +++- .../Core/Rust/RustEditorWorkbenchState.swift | 104 +++ .../EditorWorkbenchStateCoreTests.swift | 105 +++ rust/lithe-core-rules/src/dispatch.rs | 8 +- rust/lithe-core-rules/src/editor/mod.rs | 8 + .../src/editor/workbench_state.rs | 605 ++++++++++++++++++ rust/lithe-core-rules/src/lib.rs | 1 + .../tests/workbench_state_fixture.rs | 69 ++ rust/lithe-core/src/protocol/command.rs | 5 + rust/lithe-core/src/runtime/dispatcher.rs | 6 + rust/lithe-core/src/tests/protocol.rs | 31 + shared/contracts/rust-core-api.md | 23 + .../fixtures/editor/workbench-state-v1.json | 398 ++++++++++++ .../features/workbench-panes.json | 19 +- .../stores/buffer-agent-conversation.test.ts | 22 +- .../stores/buffer-close-saved-tabs.test.ts | 6 +- .../editor/stores/buffer-new-tab.test.ts | 10 +- .../features/panes/stores/pane.store.test.ts | 241 +++++++ .../src/features/panes/stores/pane.store.ts | 132 ++-- .../features/panes/utils/pane-tree.test.ts | 296 +++++++++ .../src/features/panes/utils/pane-tree.ts | 414 +++++------- .../panes/utils/workbench-state.test.ts | 90 +++ .../features/panes/utils/workbench-state.ts | 126 ++++ .../stores/workspace-pane-session.test.ts | 49 ++ .../window/stores/workspace-pane-session.ts | 34 +- 26 files changed, 2528 insertions(+), 402 deletions(-) create mode 100644 macos/Sources/Lithe/Core/Rust/RustEditorWorkbenchState.swift create mode 100644 macos/Tests/LitheTests/EditorWorkbenchStateCoreTests.swift create mode 100644 rust/lithe-core-rules/src/editor/mod.rs create mode 100644 rust/lithe-core-rules/src/editor/workbench_state.rs create mode 100644 rust/lithe-core-rules/tests/workbench_state_fixture.rs create mode 100644 shared/fixtures/editor/workbench-state-v1.json create mode 100644 windows/tauri/src/features/panes/stores/pane.store.test.ts create mode 100644 windows/tauri/src/features/panes/utils/pane-tree.test.ts create mode 100644 windows/tauri/src/features/panes/utils/workbench-state.test.ts create mode 100644 windows/tauri/src/features/panes/utils/workbench-state.ts create mode 100644 windows/tauri/src/features/window/stores/workspace-pane-session.test.ts diff --git a/.agents/notes/implemented/architecture/2026-11-05-core-rules-wasm-sync-bridge.md b/.agents/notes/implemented/architecture/2026-11-05-core-rules-wasm-sync-bridge.md index 3f5e9293e9..faa995d49d 100644 --- a/.agents/notes/implemented/architecture/2026-11-05-core-rules-wasm-sync-bridge.md +++ b/.agents/notes/implemented/architecture/2026-11-05-core-rules-wasm-sync-bridge.md @@ -31,6 +31,12 @@ Issue #1662 要求把两端重复的确定性逻辑下沉到 Core。Windows 前 - **WS-17 作为首个端到端迁移**:Swift `DoubleShiftGestureRecognizer` 和 TS `double-shift-recognizer.ts` 只剩薄适配层 (转换原生事件、保存 Core 返回的状态、把 `triggered` 变成动作)。macOS 每次事件一次同步 FFI,body 约 100 字节, `keymap.*` 不进请求追踪;按键速度下可以接受。 +- **LSP-16 编辑器分组成员状态**:`editor.workbenchState.reduce` 在 `lithe-core-rules` 里定义取两端并集的规范“组成员状态” + (每组:有序去重的成员、选中项、MRU、固定、预览),动作为 normalize、open、close、activate、move、reorder、固定、预览和 mergeGroup, + 返回新状态和 `removedGroupIds`。macOS 把 `items`、`placements`、`groupSelections` 转成规范状态(MRU、固定、预览传空值), + Windows 把每个 `PaneGroup` 转成规范组,两端旧规则已删除;布局树、拆分方向和比例仍在各自平台,只按 `removedGroupIds` 修剪。 + 两端在“选中项离开分组”时的既有行为不同(macOS 回落到第一个标签,Windows 选中相邻标签),用请求里的 `selectionFallback` + (`first` / `adjacent`)保留,不改用户可见行为。fixture 为 `shared/fixtures/editor/workbench-state-v1.json`。 ## 边界 diff --git a/macos/Sources/Lithe/Application/Features/EditorTabOrderFeatureModel.swift b/macos/Sources/Lithe/Application/Features/EditorTabOrderFeatureModel.swift index 7be829f774..1a860c2d71 100644 --- a/macos/Sources/Lithe/Application/Features/EditorTabOrderFeatureModel.swift +++ b/macos/Sources/Lithe/Application/Features/EditorTabOrderFeatureModel.swift @@ -58,6 +58,8 @@ final class EditorTabOrderFeatureModel: ObservableObject { @Published private(set) var agentSessionByTab: [UUID: AgentEditorConversation] = [:] private var placements: [EditorTabItem: UUID] = [:] private var focusedGroup: UUID? + /// Membership and selection rules live in Core; this model keeps the platform state they act on. + private let workbench = RustEditorWorkbenchState() var groupDropRequestID = UUID() func items(in group: UUID) -> [EditorTabItem] { @@ -137,17 +139,15 @@ final class EditorTabOrderFeatureModel: ObservableObject { func drop(_ item: EditorTabItem, into group: UUID, side: EditorGroupDropSide) -> Bool { guard canDrop(item, into: group, side: side) else { return false } if side == .center { - placements[item] = group + guard let members = relocate(item, to: group) else { return false } var ordered = items.filter { $0 != item } - let last = ordered.lastIndex { placements[$0] == group } - ordered.insert(item, at: last.map { $0 + 1 } ?? ordered.count) + ordered.insert(item, at: globalIndex(afterPredecessorOf: item, in: members, within: ordered)) if ordered != items { items = ordered } } else { let created = UUID() groupLayout = groupLayout.splitting(group, by: side, newGroup: created) - placements[item] = created + guard relocate(item, to: created) != nil else { return false } } - reconcileGroups() focus(item) return true } @@ -156,27 +156,24 @@ final class EditorTabOrderFeatureModel: ObservableObject { @discardableResult func insert(_ item: EditorTabItem, into group: UUID?, side: EditorGroupDropSide) -> Bool { guard !items.contains(item) else { return false } - let groups = groupLayout.groups guard let group else { - let destination = focusedGroup.flatMap { groups.contains($0) ? $0 : nil } ?? groups[0] - placements[item] = destination + // A tab without a home joins the focused group when the items change. var next = items next.append(item) items = next focus(item) return true } - guard groups.contains(group) else { return false } + guard groupLayout.groups.contains(group) else { return false } if side == .center { - placements[item] = group + guard let members = relocate(item, to: group) else { return false } var next = items - let last = next.lastIndex { placements[$0] == group } - next.insert(item, at: last.map { $0 + 1 } ?? next.count) + next.insert(item, at: globalIndex(afterPredecessorOf: item, in: members, within: next)) items = next } else { let created = UUID() groupLayout = groupLayout.splitting(group, by: side, newGroup: created) - placements[item] = created + guard relocate(item, to: created) != nil else { return false } var next = items next.append(item) items = next @@ -185,24 +182,93 @@ final class EditorTabOrderFeatureModel: ObservableObject { return true } - private func reconcileGroups() { - let groups = groupLayout.groups - let destination = focusedGroup.flatMap { groups.contains($0) ? $0 : nil } ?? groups[0] - placements = placements.filter { items.contains($0.key) } - for item in items where placements[item] == nil { placements[item] = destination } - let occupied = Set(placements.values) - groupLayout = groupLayout.keeping(occupied) ?? .group(groups[0]) - let remaining = groupLayout.groups - if !remaining.contains(destination) { focusedGroup = remaining[0] } + /// Canonical Core state for the current groups. Tabs without a valid group are reported as + /// `unplaced`; `extra` makes a tab that is not in `items` yet resolvable in Core's answer. + private func workbenchSnapshot(extra: [EditorTabItem] = []) -> ( + state: RustEditorWorkbenchState.State, unplaced: [String], lookup: [String: EditorTabItem] + ) { + let groupIDs = groupLayout.groups + let known = Set(groupIDs) + var lookup: [String: EditorTabItem] = [:] + var members: [UUID: [String]] = [:] + var unplaced: [String] = [] + for item in items + extra { lookup[item.workbenchKey] = item } + for item in items { + if let group = placements[item], known.contains(group) { + members[group, default: []].append(item.workbenchKey) + } else { + unplaced.append(item.workbenchKey) + } + } + let groups = groupIDs.map { id in + RustEditorWorkbenchState.Group( + id: id.uuidString, + members: members[id] ?? [], + selected: groupSelections[id]?.workbenchKey + ) + } + let state = RustEditorWorkbenchState.State(groups: groups, focusedGroup: focusedGroup?.uuidString) + return (state, unplaced, lookup) + } + + /// Writes Core's answer back: placements, per-group selection, focus and the pruned layout tree. + private func applyWorkbench(_ reduction: RustEditorWorkbenchState.Reduction, lookup: [String: EditorTabItem]) { + var nextPlacements: [EditorTabItem: UUID] = [:] var selections: [UUID: EditorTabItem] = [:] - for group in remaining { - let tabs = items(in: group) - if let selected = groupSelections[group], tabs.contains(selected) { selections[group] = selected } - else { selections[group] = tabs.first } + var remaining: [UUID] = [] + for group in reduction.state.groups { + guard let id = UUID(uuidString: group.id) else { continue } + remaining.append(id) + for key in group.members { if let item = lookup[key] { nextPlacements[item] = id } } + if let key = group.selected, let item = lookup[key] { selections[id] = item } + } + placements = nextPlacements + focusedGroup = reduction.state.focusedGroup.flatMap(UUID.init(uuidString:)) + if !reduction.removedGroupIds.isEmpty { + // Core decided which groups vanished; only the layout tree is pruned here. + groupLayout = groupLayout.keeping(Set(remaining)) ?? .group(remaining.first ?? groupLayout.groups[0]) } if selections != groupSelections { groupSelections = selections } } + private func reconcileGroups() { + let snapshot = workbenchSnapshot() + guard let reduction = workbench.reduce(snapshot.state, .normalize(unplaced: snapshot.unplaced)) else { return } + applyWorkbench(reduction, lookup: snapshot.lookup) + } + + /// Moves `item` into `destination` (at the end, or next to `anchor`) and returns the + /// destination's tabs afterwards. The item may not be in `items` yet. + private func relocate( + _ item: EditorTabItem, + to destination: UUID, + anchor: (item: EditorTabItem, after: Bool)? = nil + ) -> [EditorTabItem]? { + let snapshot = workbenchSnapshot(extra: [item]) + let action = RustEditorWorkbenchState.Action.move( + member: item.workbenchKey, + from: placements[item]?.uuidString ?? "", + to: destination.uuidString, + before: anchor.flatMap { $0.after ? nil : $0.item.workbenchKey }, + after: anchor.flatMap { $0.after ? $0.item.workbenchKey : nil } + ) + guard let reduction = workbench.reduce(snapshot.state, action) else { return nil } + applyWorkbench(reduction, lookup: snapshot.lookup) + return reduction.state.groups.first { $0.id == destination.uuidString }? + .members.compactMap { snapshot.lookup[$0] } ?? [] + } + + /// Position in the mixed tab order that keeps `item` right after the tab Core placed before it. + private func globalIndex( + afterPredecessorOf item: EditorTabItem, + in members: [EditorTabItem], + within ordered: [EditorTabItem] + ) -> Int { + guard let index = members.firstIndex(of: item), index > 0, + let predecessor = ordered.firstIndex(of: members[index - 1]) else { return ordered.count } + return predecessor + 1 + } + @Published var repositoryDiffSelected = false var repositoryDiffReturnTabs: [UUID: EditorTabItem] = [:] var repositoryDiffKeys: [UUID: RepositoryDiffTabKey] = [:] @@ -343,7 +409,9 @@ final class EditorTabOrderFeatureModel: ObservableObject { guard let targetIndex = next.firstIndex(of: target) else { return false } next.insert(item, at: targetIndex + (insertAfter ? 1 : 0)) guard next != items || placements[item] != placements[target] else { return false } - if let destination = placements[target] { placements[item] = destination } + if let destination = placements[target] { + guard relocate(item, to: destination, anchor: (target, insertAfter)) != nil else { return false } + } items = next return true } diff --git a/macos/Sources/Lithe/Core/Rust/RustEditorWorkbenchState.swift b/macos/Sources/Lithe/Core/Rust/RustEditorWorkbenchState.swift new file mode 100644 index 0000000000..fc521cae1f --- /dev/null +++ b/macos/Sources/Lithe/Core/Rust/RustEditorWorkbenchState.swift @@ -0,0 +1,104 @@ +import Foundation + +/// Thin adapter over Core's `editor.workbenchState.reduce`. Which tab belongs to which editor group, +/// which tab a group shows, and what happens to that when tabs move, close or have no home yet are +/// rules in rust/lithe-core-rules; the Windows frontend runs the same Rust source as wasm. This type +/// only converts model values to the canonical state and back. Each call is one synchronous FFI round +/// trip on a user action (opening, closing, moving or dropping a tab), never during drags or resizes. +/// +/// macOS has no MRU list, pinned tabs or preview tab, so those canonical fields are always empty +/// here and Core returns them unchanged. +struct RustEditorWorkbenchState { + struct Group: Codable, Equatable { + var id: String + var members: [String] + var selected: String? + var mru: [String] = [] + var pinned: [String] = [] + var preview: String? + } + + struct State: Codable, Equatable { + var groups: [Group] + var focusedGroup: String? + } + + struct Reduction: Decodable { + let state: State + let removedGroupIds: [String] + } + + /// The only two transitions the macOS model asks for. Empty groups are always removed (keeping + /// the first so the editor never has zero groups), and a vanished selection falls back to the + /// group's first tab. + enum Action: Encodable { + /// Repairs every group; `unplaced` tabs join the focused group. + case normalize(unplaced: [String]) + /// Moves or repositions a tab; `before` / `after` name an anchor tab in the destination. + case move(member: String, from: String, to: String, before: String?, after: String?) + + private enum Keys: String, CodingKey { + case kind, unplaced, member, from, to, before, after, activate, emptyGroups, selectionFallback + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: Keys.self) + try container.encode("removeKeepingFirst", forKey: .emptyGroups) + switch self { + case let .normalize(unplaced): + try container.encode("normalize", forKey: .kind) + try container.encode(unplaced, forKey: .unplaced) + case let .move(member, from, to, before, after): + try container.encode("move", forKey: .kind) + try container.encode(member, forKey: .member) + try container.encode(from, forKey: .from) + try container.encode(to, forKey: .to) + try container.encodeIfPresent(before, forKey: .before) + try container.encodeIfPresent(after, forKey: .after) + // The model selects through `focus`, so a move itself never changes the selection. + try container.encode(false, forKey: .activate) + try container.encode("first", forKey: .selectionFallback) + } + } + } + + private struct Request: Encodable { + let state: State + let action: Action + } + + private let core: RustCoreBridge + + init(core: RustCoreBridge = RustCoreBridge()) { + self.core = core + } + + /// Returns nil only if the linked Core rejects a well-formed request, which means Core is + /// broken; there is no second implementation to fall back to, so the model stays unchanged. + func reduce(_ state: State, _ action: Action) -> Reduction? { + let result: Result = core.executeResult( + command: "editor.workbenchState.reduce", + payload: Request(state: state, action: action) + ) + switch result { + case let .success(reduction): return reduction + case .failure: + assertionFailure("editor.workbenchState.reduce failed") + return nil + } + } +} + +extension EditorTabItem { + /// Stable string Core uses to name this tab; the model maps results back with a lookup table. + var workbenchKey: String { + switch self { + case .document(let id): "document:\(id.uuidString)" + case .terminal(let id): "terminal:\(id.uuidString)" + case .media(let id): "media:\(id.uuidString)" + case .repositoryDiff(let id): "repositoryDiff:\(id.uuidString)" + case .languageLogs: "languageLogs" + case .agentConversation(let id): "agentConversation:\(id.uuidString)" + } + } +} diff --git a/macos/Tests/LitheTests/EditorWorkbenchStateCoreTests.swift b/macos/Tests/LitheTests/EditorWorkbenchStateCoreTests.swift new file mode 100644 index 0000000000..2e5354bb80 --- /dev/null +++ b/macos/Tests/LitheTests/EditorWorkbenchStateCoreTests.swift @@ -0,0 +1,105 @@ +import Foundation +import LitheCoreContracts +import Testing +@testable import Lithe + +/// shared/fixtures/editor/workbench-state-v1.json is the contract both hosts are tested against. +/// Here every case runs through the real linked Rust Core, the same command the model uses. +@MainActor +struct EditorWorkbenchStateCoreTests { + private static func fixtureCases() throws -> [[String: Any]] { + let root = URL(fileURLWithPath: #filePath).deletingLastPathComponent() + .deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() + let data = try Data(contentsOf: root.appendingPathComponent("shared/fixtures/editor/workbench-state-v1.json")) + let fixture = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any]) + return try #require(fixture["cases"] as? [[String: Any]]) + } + + private struct Request: Encodable { + let state: ToolingJSONValue + let action: ToolingJSONValue + } + + private struct Answer: Decodable { + let state: ToolingJSONValue + let removedGroupIds: ToolingJSONValue + } + + @Test + func everySharedFixtureCaseMatchesThroughCore() throws { + let core = RustCoreBridge() + let cases = try Self.fixtureCases() + #expect(cases.count >= 40) + for entry in cases { + let name = entry["name"] as? String ?? "" + let expected = try #require(entry["expected"] as? [String: Any]) + let result: Result = core.executeResult( + command: "editor.workbenchState.reduce", + payload: Request( + state: try #require(ToolingJSONValue.fromFoundation(entry["state"] as Any)), + action: try #require(ToolingJSONValue.fromFoundation(entry["action"] as Any)) + ) + ) + let answer = try #require(try? result.get(), "\(name)") + #expect(answer.state == ToolingJSONValue.fromFoundation(expected["state"] as Any), "\(name)") + #expect(answer.removedGroupIds == ToolingJSONValue.fromFoundation(expected["removedGroupIds"] as Any), "\(name)") + } + } + + @Test + func droppingNextToATabKeepsTheMixedOrderOfOtherGroupsAndTheSelection() throws { + let feature = EditorTabOrderFeatureModel() + let a = EditorTabItem.document(UUID()), b = EditorTabItem.document(UUID()) + let c = EditorTabItem.terminal(UUID()), d = EditorTabItem.document(UUID()) + for item in [a, b, c, d] { feature.moveToEnd(item) } + let left = try #require(feature.groupLayout.groups.first) + #expect(feature.drop(c, into: left, side: .right)) + let right = try #require(feature.group(containing: c)) + #expect(feature.items(in: left) == [a, b, d]) + feature.focus(a) + + // Reordering inside one group moves membership only; it keeps the group's selection. + #expect(feature.move(d, before: a)) + #expect(feature.items(in: left) == [d, a, b]) + #expect(feature.selectedItem(in: left) == a) + + // Moving next to a tab of another group joins that group at the anchor. + #expect(feature.move(b, after: c)) + #expect(feature.items(in: right) == [c, b]) + #expect(feature.items(in: left) == [d, a]) + #expect(feature.items == [d, a, c, b]) + #expect(feature.selectedItem(in: right) == c) + } + + @Test + func aMovedSelectedTabLeavesItsOldGroupOnTheFirstRemainingTab() throws { + let feature = EditorTabOrderFeatureModel() + let a = EditorTabItem.document(UUID()), b = EditorTabItem.document(UUID()), c = EditorTabItem.document(UUID()) + for item in [a, b, c] { feature.moveToEnd(item) } + let left = try #require(feature.groupLayout.groups.first) + feature.focus(b) + #expect(feature.drop(b, into: left, side: .bottom)) + #expect(feature.selectedItem(in: left) == a) + #expect(feature.items(in: left) == [a, c]) + } + + @Test + func tabsWithoutAHomeJoinTheFocusedGroupAndEmptyGroupsDisappear() throws { + let feature = EditorTabOrderFeatureModel() + let a = EditorTabItem.document(UUID()), b = EditorTabItem.document(UUID()), x = EditorTabItem.document(UUID()) + feature.moveToEnd(a) + feature.moveToEnd(x) + let first = try #require(feature.groupLayout.groups.first) + #expect(feature.drop(a, into: first, side: .right)) + let second = try #require(feature.group(containing: a)) + feature.focus(a) + + feature.moveToEnd(b) + #expect(feature.group(containing: b) == second) + feature.remove(a) + feature.remove(b) + #expect(feature.groupLayout.groups == [first]) + #expect(feature.items(in: first) == [x]) + #expect(feature.selectedItem(in: first) == x) + } +} diff --git a/rust/lithe-core-rules/src/dispatch.rs b/rust/lithe-core-rules/src/dispatch.rs index 29b1deb574..99a58d0b5c 100644 --- a/rust/lithe-core-rules/src/dispatch.rs +++ b/rust/lithe-core-rules/src/dispatch.rs @@ -1,11 +1,14 @@ //! Command routing and the JSON envelope shared with `lithe-core`. -use crate::{keymap, RuleError, RuleErrorCode}; +use crate::{editor, keymap, RuleError, RuleErrorCode}; use serde::Deserialize; use serde_json::{json, Value}; /// Every command name this crate owns. `lithe-core` routes exactly these names here. -pub const COMMANDS: &[&str] = &[keymap::MODIFIER_GESTURE_STEP]; +pub const COMMANDS: &[&str] = &[ + keymap::MODIFIER_GESTURE_STEP, + editor::REDUCE_WORKBENCH_STATE, +]; /// Reports whether `command` is owned by this crate. pub fn is_rule_command(command: &str) -> bool { @@ -16,6 +19,7 @@ pub fn is_rule_command(command: &str) -> bool { pub fn execute(command: &str, payload: Value) -> Result { match command { keymap::MODIFIER_GESTURE_STEP => keymap::modifier_gesture_step_json(payload), + editor::REDUCE_WORKBENCH_STATE => editor::reduce_json(payload), _ => Err(RuleError::new( RuleErrorCode::NotSupported, "Unknown rule command", diff --git a/rust/lithe-core-rules/src/editor/mod.rs b/rust/lithe-core-rules/src/editor/mod.rs new file mode 100644 index 0000000000..a7a504e964 --- /dev/null +++ b/rust/lithe-core-rules/src/editor/mod.rs @@ -0,0 +1,8 @@ +//! Editor rules that must run synchronously inside UI state updates. + +mod workbench_state; + +pub use workbench_state::{ + reduce, reduce_json, Action, EmptyGroups, Group, Reduction, SelectionFallback, State, + REDUCE_WORKBENCH_STATE, +}; diff --git a/rust/lithe-core-rules/src/editor/workbench_state.rs b/rust/lithe-core-rules/src/editor/workbench_state.rs new file mode 100644 index 0000000000..612270a336 --- /dev/null +++ b/rust/lithe-core-rules/src/editor/workbench_state.rs @@ -0,0 +1,605 @@ +//! Membership and selection rules for editor tab groups. +//! +//! Both hosts keep a different shape of the same fact: which tabs live in which group, which tab a +//! group shows, and what happens to that when a tab opens, closes or moves. This module defines one +//! canonical group model (the union of what the two hosts track) and one reducer over it. A host +//! maps its own state into [`State`], runs one [`Action`], and maps the result back. +//! +//! Layout trees, split ratios, pixels and dragging stay in the host. The reducer only reports which +//! groups became empty and were removed so the host can prune its own layout tree. +//! +//! Members are opaque strings; ordering inside a group is the tab order. Every action first +//! de-duplicates the members of the groups it touches and finishes by normalizing them, so a group +//! that leaves the reducer always satisfies the invariants described on [`Group`]. + +use crate::{RuleError, RuleErrorCode}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::HashSet; + +/// Command name of the reducer in the Core command namespace. +pub const REDUCE_WORKBENCH_STATE: &str = "editor.workbenchState.reduce"; + +/// One editor group. +/// +/// After the reducer touches a group: `members` has no duplicates, `selected` is a member (the +/// first one when the previous value was missing or stale) or `null` when the group is empty, +/// `mru` lists `selected` first, then the previous MRU order, then the remaining members, and +/// `pinned` and `preview` only name members. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Group { + /// Host identifier of the group. + pub id: String, + /// Tabs in display order. + #[serde(default)] + pub members: Vec, + /// Tab the group currently shows. + #[serde(default)] + pub selected: Option, + /// Most recently used members, newest first. Hosts without MRU send an empty list. + #[serde(default)] + pub mru: Vec, + /// Pinned members. Hosts without pinning send an empty list. + #[serde(default)] + pub pinned: Vec, + /// Transient preview member, replaced when another tab previews. Hosts without it send `null`. + #[serde(default)] + pub preview: Option, +} + +/// All groups a host wants the reducer to consider, in layout order. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct State { + /// Groups in layout order. + #[serde(default)] + pub groups: Vec, + /// Group that receives new tabs; falls back to the first group when missing or stale. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub focused_group: Option, +} + +/// What happens to a group that became empty. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum EmptyGroups { + /// Empty groups stay; the host may want to fill them next. + #[default] + Keep, + /// Remove them, even when that leaves no group at all. + Remove, + /// Remove them, but never leave the state without a group: the first group stays. + RemoveKeepingFirst, +} + +/// How the selection is chosen when the selected member leaves a group. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum SelectionFallback { + /// The member that slides into the vacated position (or the new last one). + #[default] + Adjacent, + /// The first remaining member. + First, +} + +fn yes() -> bool { + true +} + +/// One state transition requested by a host. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde( + tag = "kind", + rename_all = "camelCase", + rename_all_fields = "camelCase", + deny_unknown_fields +)] +pub enum Action { + /// Repairs every group. `unplaced` members that belong to no group join the focused group. + /// Empty groups are removed according to `empty_groups`. + Normalize { + /// What to do with groups that are empty. + #[serde(default)] + empty_groups: EmptyGroups, + /// Members the host knows about that no group lists yet. + #[serde(default)] + unplaced: Vec, + }, + /// Adds `member` to the end of `group` if missing and optionally selects it. + Open { + /// Target group. + group: String, + /// Member to add. + member: String, + /// Select the member and make it the most recently used. + #[serde(default = "yes")] + activate: bool, + }, + /// Removes `member` from `group`. + Close { + /// Group to remove from. + group: String, + /// Member to remove. + member: String, + /// What to do if the group is now empty. + #[serde(default)] + empty_groups: EmptyGroups, + /// How to choose the next selection when the closed member was selected. + #[serde(default)] + selection_fallback: SelectionFallback, + }, + /// Selects `member` (or repairs the selection when `null`). + Activate { + /// Target group. + group: String, + /// Member to select; unknown members leave the selection alone. + #[serde(default)] + member: Option, + }, + /// Moves `member` out of `from` into `to`, at the end or next to an anchor. With `from == to` + /// the member is only repositioned. + Move { + /// Member to move. + member: String, + /// Group that loses the member. + from: String, + /// Group that receives the member; an unknown group makes the move a no-op. + to: String, + /// Insert right before this member of `to`. + #[serde(default)] + before: Option, + /// Insert right after this member of `to`. + #[serde(default)] + after: Option, + /// Select the member in `to`. + #[serde(default = "yes")] + activate: bool, + /// What to do if `from` is now empty. + #[serde(default)] + empty_groups: EmptyGroups, + /// How to choose the next selection in `from`. + #[serde(default)] + selection_fallback: SelectionFallback, + }, + /// Moves the member at `from_index` to `to_index` inside one group. + Reorder { + /// Target group. + group: String, + /// Current position; out-of-range values (including negative ones) change nothing. + from_index: i64, + /// New position; out-of-range values change nothing. + to_index: i64, + }, + /// Pins or unpins `member` in one group. Pinning clears a preview of the same member. + SetPinned { + /// Target group. + group: String, + /// Member to change. + member: String, + /// New pinned state. + pinned: bool, + }, + /// Pins or unpins `member` in every group that lists it. + SetPinnedEverywhere { + /// Member to change. + member: String, + /// New pinned state. + pinned: bool, + }, + /// Sets or clears the preview member of a group. Previewing clears a pin of the same member. + SetPreview { + /// Target group. + group: String, + /// Member to preview, or `null` to clear. + #[serde(default)] + member: Option, + }, + /// Clears the preview in every group that previews `member`. + ClearPreviewEverywhere { + /// Member whose preview is cleared. + member: String, + }, + /// Folds every member of `from` into `into` and removes `from`. + MergeGroup { + /// Group that disappears. + from: String, + /// Group that receives the members; an unknown group makes the action a no-op. + into: String, + /// Select the member `from` was showing inside `into`. + #[serde(default)] + activate_selected: bool, + }, +} + +/// Payload of `editor.workbenchState.reduce`. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Request { + #[serde(default)] + state: State, + action: Action, +} + +/// Result of one reduction. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Reduction { + /// The new state; groups keep their relative order. + pub state: State, + /// Ids of groups that left the state, in their previous order. + pub removed_group_ids: Vec, +} + +/// Decodes a command payload, runs the reducer and encodes the result. +pub fn reduce_json(payload: Value) -> Result { + let request: Request = serde_json::from_value(payload).map_err(|error| { + RuleError::new( + RuleErrorCode::InvalidRequest, + "Invalid workbench state request", + ) + .with_details(error.to_string()) + })?; + let reduction = reduce(request.state, request.action); + serde_json::to_value(reduction).map_err(|error| { + RuleError::new( + RuleErrorCode::InvalidRequest, + "Workbench state could not be encoded", + ) + .with_details(error.to_string()) + }) +} + +/// Applies `action` to `state`. +/// +/// Groups an action does not touch are returned unchanged, so a host can send only the groups an +/// action involves. Actions that name an unknown group leave the state as it is. +pub fn reduce(mut state: State, action: Action) -> Reduction { + let mut empty_groups = EmptyGroups::Keep; + let mut affected: Vec = Vec::new(); + let mut removed: Vec = Vec::new(); + let all_ids: Vec = state.groups.iter().map(|group| group.id.clone()).collect(); + // Hosts that never send a focused group (Windows) get no focus bookkeeping back. + let tracks_focus = state.focused_group.is_some() || matches!(action, Action::Normalize { .. }); + let focus_destination = destination_group(&state); + + match action { + Action::Normalize { + empty_groups: policy, + unplaced, + } => { + for group in &mut state.groups { + normalize(group); + } + if let Some(destination) = focus_destination.clone() { + let placed: HashSet<&str> = state + .groups + .iter() + .flat_map(|group| group.members.iter().map(String::as_str)) + .collect(); + let missing: Vec = unplaced + .into_iter() + .filter(|member| !placed.contains(member.as_str())) + .collect(); + if let Some(group) = group_mut(&mut state, &destination) { + for member in missing { + if !group.members.contains(&member) { + group.members.push(member); + } + } + normalize(group); + } + } + empty_groups = policy; + affected = all_ids.clone(); + } + Action::Open { + group, + member, + activate, + } => { + if let Some(target) = group_mut(&mut state, &group) { + add_member(target, &member, None, activate); + } + } + Action::Close { + group, + member, + empty_groups: policy, + selection_fallback, + } => { + if let Some(target) = group_mut(&mut state, &group) { + remove_member(target, &member, selection_fallback); + } + empty_groups = policy; + affected = vec![group]; + } + Action::Activate { group, member } => { + if let Some(target) = group_mut(&mut state, &group) { + dedupe(&mut target.members); + if let Some(member) = member { + if target.members.contains(&member) { + target.selected = Some(member.clone()); + target.mru.retain(|id| id != &member); + target.mru.insert(0, member); + } + } else { + target.selected = None; + } + normalize(target); + } + } + Action::Move { + member, + from, + to, + before, + after, + activate, + empty_groups: policy, + selection_fallback, + } => { + if group_mut(&mut state, &to).is_some() { + let anchor = before + .map(|id| (id, false)) + .or_else(|| after.map(|id| (id, true))); + if from == to { + // Repositioning inside one group is not a close and an open: the selection, + // pins and preview stay unless the caller asks to activate the member. + if let Some(target) = group_mut(&mut state, &to) { + reposition_member(target, &member, anchor, activate); + } + } else { + if let Some(source) = group_mut(&mut state, &from) { + remove_member(source, &member, selection_fallback); + } + if let Some(target) = group_mut(&mut state, &to) { + add_member(target, &member, anchor, activate); + } + affected = vec![from]; + } + empty_groups = policy; + } + } + Action::Reorder { + group, + from_index, + to_index, + } => { + if let Some(target) = group_mut(&mut state, &group) { + let count = target.members.len() as i64; + let in_range = |index: i64| (0..count).contains(&index); + if in_range(from_index) && in_range(to_index) && from_index != to_index { + let moved = target.members.remove(from_index as usize); + target.members.insert(to_index as usize, moved); + } + } + } + Action::SetPinned { + group, + member, + pinned, + } => { + if let Some(target) = group_mut(&mut state, &group) { + set_pinned(target, &member, pinned); + } + } + Action::SetPinnedEverywhere { member, pinned } => { + for group in &mut state.groups { + if group.members.contains(&member) { + set_pinned(group, &member, pinned); + } + } + } + Action::SetPreview { group, member } => { + if let Some(target) = group_mut(&mut state, &group) { + dedupe(&mut target.members); + match member { + Some(member) if !target.members.contains(&member) => {} + Some(member) => { + target.pinned.retain(|id| id != &member); + target.preview = Some(member); + } + None => target.preview = None, + } + normalize(target); + } + } + Action::ClearPreviewEverywhere { member } => { + for group in &mut state.groups { + if group.preview.as_deref() == Some(member.as_str()) { + group.preview = None; + normalize(group); + } + } + } + Action::MergeGroup { + from, + into, + activate_selected, + } => { + if from != into && group_mut(&mut state, &into).is_some() { + if let Some(source) = state.groups.iter().find(|group| group.id == from).cloned() { + let mut source = source; + dedupe(&mut source.members); + if let Some(target) = group_mut(&mut state, &into) { + for member in &source.members { + add_member(target, member, None, false); + } + if let Some(selected) = + source.selected.as_ref().filter(|_| activate_selected) + { + if target.members.contains(selected) { + target.selected = Some(selected.clone()); + target.mru.retain(|id| id != selected); + target.mru.insert(0, selected.clone()); + normalize(target); + } + } + } + state.groups.retain(|group| group.id != from); + removed.push(from); + } + } + } + } + + removed.extend(prune_empty(&mut state, empty_groups, &affected)); + if let Some(destination) = focus_destination.filter(|_| tracks_focus) { + if !state.groups.iter().any(|group| group.id == destination) { + state.focused_group = state.groups.first().map(|group| group.id.clone()); + } + } + let removed_group_ids = all_ids + .into_iter() + .filter(|id| removed.contains(id)) + .collect(); + Reduction { + state, + removed_group_ids, + } +} + +fn group_mut<'a>(state: &'a mut State, id: &str) -> Option<&'a mut Group> { + state.groups.iter_mut().find(|group| group.id == id) +} + +/// Group that receives members without a home: the focused group, or the first one. +fn destination_group(state: &State) -> Option { + state + .focused_group + .as_ref() + .filter(|id| state.groups.iter().any(|group| &group.id == *id)) + .cloned() + .or_else(|| state.groups.first().map(|group| group.id.clone())) +} + +fn dedupe(items: &mut Vec) { + let mut seen = HashSet::new(); + items.retain(|item| seen.insert(item.clone())); +} + +/// Restores the invariants documented on [`Group`]. +fn normalize(group: &mut Group) { + dedupe(&mut group.members); + let members: HashSet<&str> = group.members.iter().map(String::as_str).collect(); + let selected = group + .selected + .clone() + .filter(|id| members.contains(id.as_str())) + .or_else(|| group.members.first().cloned()); + + let mut mru: Vec = Vec::with_capacity(group.members.len()); + let mut seen: HashSet = HashSet::new(); + let ordered = selected + .iter() + .chain(group.mru.iter()) + .chain(group.members.iter()); + for id in ordered { + if members.contains(id.as_str()) && seen.insert(id.clone()) { + mru.push(id.clone()); + } + } + + group.pinned.retain(|id| members.contains(id.as_str())); + dedupe(&mut group.pinned); + if group + .preview + .as_deref() + .is_some_and(|id| !members.contains(id)) + { + group.preview = None; + } + group.selected = selected; + group.mru = mru; +} + +fn add_member(group: &mut Group, member: &str, anchor: Option<(String, bool)>, activate: bool) { + dedupe(&mut group.members); + if !group.members.iter().any(|id| id == member) { + let position = anchor + .and_then(|(id, after)| { + group + .members + .iter() + .position(|candidate| candidate == &id) + .map(|index| if after { index + 1 } else { index }) + }) + .unwrap_or(group.members.len()); + group.members.insert(position, member.to_string()); + } + if activate { + group.selected = Some(member.to_string()); + group.mru.retain(|id| id != member); + group.mru.insert(0, member.to_string()); + } + normalize(group); +} + +fn reposition_member( + group: &mut Group, + member: &str, + anchor: Option<(String, bool)>, + activate: bool, +) { + dedupe(&mut group.members); + group.members.retain(|id| id != member); + add_member(group, member, anchor, activate); +} + +fn remove_member(group: &mut Group, member: &str, fallback: SelectionFallback) { + dedupe(&mut group.members); + let index = group.members.iter().position(|id| id == member); + group.members.retain(|id| id != member); + if group.selected.as_deref() == Some(member) { + group.selected = match (fallback, index) { + _ if group.members.is_empty() => None, + (SelectionFallback::Adjacent, Some(index)) => group + .members + .get(index.min(group.members.len() - 1)) + .cloned(), + _ => group.members.first().cloned(), + }; + } + group.mru.retain(|id| id != member); + group.pinned.retain(|id| id != member); + if group.preview.as_deref() == Some(member) { + group.preview = None; + } + normalize(group); +} + +fn set_pinned(group: &mut Group, member: &str, pinned: bool) { + dedupe(&mut group.members); + if group.members.iter().any(|id| id == member) { + if pinned { + group.pinned.push(member.to_string()); + if group.preview.as_deref() == Some(member) { + group.preview = None; + } + } else { + group.pinned.retain(|id| id != member); + } + } + normalize(group); +} + +/// Removes empty groups among `affected` according to `policy`; returns their ids. +fn prune_empty(state: &mut State, policy: EmptyGroups, affected: &[String]) -> Vec { + if policy == EmptyGroups::Keep { + return Vec::new(); + } + let first = state.groups.first().map(|group| group.id.clone()); + let mut doomed: Vec = state + .groups + .iter() + .filter(|group| group.members.is_empty() && affected.contains(&group.id)) + .map(|group| group.id.clone()) + .collect(); + if policy == EmptyGroups::RemoveKeepingFirst && doomed.len() == state.groups.len() { + doomed.retain(|id| Some(id) != first.as_ref()); + } + state.groups.retain(|group| !doomed.contains(&group.id)); + doomed +} diff --git a/rust/lithe-core-rules/src/lib.rs b/rust/lithe-core-rules/src/lib.rs index 8e25075b30..e1f6ba99fe 100644 --- a/rust/lithe-core-rules/src/lib.rs +++ b/rust/lithe-core-rules/src/lib.rs @@ -14,6 +14,7 @@ //! `{id, ok, data | error}` out, with the same stable error codes. mod dispatch; +pub mod editor; mod error; pub mod keymap; #[cfg(target_arch = "wasm32")] diff --git a/rust/lithe-core-rules/tests/workbench_state_fixture.rs b/rust/lithe-core-rules/tests/workbench_state_fixture.rs new file mode 100644 index 0000000000..d5abdd99ca --- /dev/null +++ b/rust/lithe-core-rules/tests/workbench_state_fixture.rs @@ -0,0 +1,69 @@ +//! Drives the shared workbench-state fixture through the JSON envelope, exactly like the hosts do. + +use lithe_core_rules::{execute_json, is_rule_command}; +use serde_json::{json, Value}; + +const FIXTURE: &str = include_str!("../../../shared/fixtures/editor/workbench-state-v1.json"); +const COMMAND: &str = "editor.workbenchState.reduce"; + +fn call(payload: Value) -> Value { + let request = json!({"id": "fixture", "command": COMMAND, "payload": payload}); + serde_json::from_str(&execute_json(&request.to_string())).unwrap() +} + +#[test] +fn command_is_owned_by_the_rules_crate() { + assert!(is_rule_command(COMMAND)); +} + +#[test] +fn every_fixture_case_matches() { + let fixture: Value = serde_json::from_str(FIXTURE).unwrap(); + assert_eq!(fixture["version"], 1); + let cases = fixture["cases"].as_array().unwrap(); + assert!(cases.len() >= 40, "fixture should keep its coverage"); + for case in cases { + let name = case["name"].as_str().unwrap(); + let response = call(json!({"state": case["state"], "action": case["action"]})); + assert_eq!(response["ok"], true, "{name}: {response}"); + assert_eq!(response["id"], "fixture"); + assert_eq!( + response["data"]["state"], case["expected"]["state"], + "{name}" + ); + assert_eq!( + response["data"]["removedGroupIds"], case["expected"]["removedGroupIds"], + "{name}" + ); + } +} + +#[test] +fn reducing_a_result_again_changes_nothing_for_normalizing_actions() { + let fixture: Value = serde_json::from_str(FIXTURE).unwrap(); + for case in fixture["cases"].as_array().unwrap() { + let name = case["name"].as_str().unwrap(); + let again = + call(json!({"state": case["expected"]["state"], "action": {"kind": "normalize"}})); + // Re-normalizing a reducer result is idempotent: the reducer never emits a state it + // would repair. + assert_eq!(again["data"]["state"], case["expected"]["state"], "{name}"); + assert_eq!(again["data"]["removedGroupIds"], json!([]), "{name}"); + } +} + +#[test] +fn malformed_requests_are_invalid() { + for payload in [ + json!({}), + json!({"action": {"kind": "teleport"}}), + json!({"action": {"kind": "open", "group": "g"}}), + json!({"action": {"kind": "open", "group": "g", "member": "a", "extra": 1}}), + json!({"state": {"groups": [{"members": []}]}, "action": {"kind": "normalize"}}), + json!({"action": {"kind": "close", "group": "g", "member": "a", "emptyGroups": "nope"}}), + ] { + let response = call(payload.clone()); + assert_eq!(response["ok"], false, "{payload}"); + assert_eq!(response["error"]["code"], "invalid_request", "{payload}"); + } +} diff --git a/rust/lithe-core/src/protocol/command.rs b/rust/lithe-core/src/protocol/command.rs index 6d62c7a097..cb617fa2c9 100644 --- a/rust/lithe-core/src/protocol/command.rs +++ b/rust/lithe-core/src/protocol/command.rs @@ -409,6 +409,10 @@ pub enum CoreCommand { /// Advances the double-Shift gesture recognizer by one event (`keymap.modifierGestureStep`). /// The rule itself lives in `lithe-core-rules` so the Windows frontend can run it synchronously. KeymapModifierGestureStep, + /// Applies one editor-group state transition: open, close, move, pin, preview, merge or normalize + /// (`editor.workbenchState.reduce`). The rule lives in `lithe-core-rules` so the Windows frontend + /// can run it synchronously inside store updates. + EditorWorkbenchStateReduce, /// Resolves terminal link candidates into file or external targets (`terminal.parseLinkTarget`). TerminalParseLinkTarget, /// Creates one transport-neutral Debug Adapter Protocol session (`debug.createSession`). @@ -1034,6 +1038,7 @@ impl CoreCommand { "keymap.evaluateContext" => Some(Self::KeymapEvaluateContext), "keymap.analyzeConflicts" => Some(Self::KeymapAnalyzeConflicts), "keymap.modifierGestureStep" => Some(Self::KeymapModifierGestureStep), + "editor.workbenchState.reduce" => Some(Self::EditorWorkbenchStateReduce), "terminal.parseLinkTarget" => Some(Self::TerminalParseLinkTarget), "debug.createSession" => Some(Self::DebugCreateSession), "debug.launch" => Some(Self::DebugLaunch), diff --git a/rust/lithe-core/src/runtime/dispatcher.rs b/rust/lithe-core/src/runtime/dispatcher.rs index 7646ba47d6..43b82cdcb9 100644 --- a/rust/lithe-core/src/runtime/dispatcher.rs +++ b/rust/lithe-core/src/runtime/dispatcher.rs @@ -1542,6 +1542,12 @@ fn execute_parsed(mut parsed: CoreRequest) -> CoreResponse { Err(error) => CoreResponse::failure(id, error.into()), } } + CoreCommand::EditorWorkbenchStateReduce => { + match lithe_core_rules::execute("editor.workbenchState.reduce", parsed.payload) { + Ok(data) => CoreResponse::success(id, data), + Err(error) => CoreResponse::failure(id, error.into()), + } + } CoreCommand::TerminalParseLinkTarget => { let result = serde_json::from_value::(parsed.payload) diff --git a/rust/lithe-core/src/tests/protocol.rs b/rust/lithe-core/src/tests/protocol.rs index 553f46fdbf..689cefc38a 100644 --- a/rust/lithe-core/src/tests/protocol.rs +++ b/rust/lithe-core/src/tests/protocol.rs @@ -441,6 +441,37 @@ fn modifier_gesture_fixture_runs_through_core_dispatch() { assert_eq!(rejected["error"]["code"], "invalid_request"); } +#[test] +fn workbench_state_fixture_runs_through_core_dispatch() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../shared/fixtures/editor/workbench-state-v1.json" + )) + .unwrap(); + for case in fixture["cases"].as_array().unwrap() { + let result = call_fixture_command( + "editor.workbenchState.reduce", + serde_json::json!({"state": case["state"], "action": case["action"]}), + ); + assert_eq!(result["ok"], true, "{}: {result}", case["name"]); + assert_eq!( + result["data"]["state"], case["expected"]["state"], + "{}", + case["name"] + ); + assert_eq!( + result["data"]["removedGroupIds"], case["expected"]["removedGroupIds"], + "{}", + case["name"] + ); + } + let rejected = call_fixture_command( + "editor.workbenchState.reduce", + serde_json::json!({"action": {"kind": "bogus"}}), + ); + assert_eq!(rejected["ok"], false); + assert_eq!(rejected["error"]["code"], "invalid_request"); +} + #[test] fn every_rules_crate_command_is_routed_by_core() { for command in lithe_core_rules::COMMANDS { diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index fc9da7ad7a..9caf4b61e5 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -159,6 +159,29 @@ through the C ABI, and the Windows frontend runs the same source compiled to was payload is `invalid_request`. Hosts only convert native events to this input and act on `triggered`; both are tested step by step against `shared/fixtures/keymap/modifier-gesture-v1.json`. +`editor.workbenchState.reduce` accepts `{state?, action}` and returns `{state, removedGroupIds}`. `state` is +`{groups, focusedGroup?}`; a group is `{id, members, selected?, mru?, pinned?, preview?}` (ordered members, the shown +member, most-recent-first order, pinned members, one transient preview), which is the union of what macOS (a flat +tab list with placements and per-group selection) and Windows (pane groups with MRU, pins and preview) track. +`action` is tagged by `kind`: `normalize {emptyGroups?, unplaced?}`, `open {group, member, activate?}`, +`close {group, member, emptyGroups?, selectionFallback?}`, `activate {group, member?}`, +`move {member, from, to, before?, after?, activate?, emptyGroups?, selectionFallback?}` (with `from == to` it only +repositions), `reorder {group, fromIndex, toIndex}`, `setPinned {group, member, pinned}`, +`setPinnedEverywhere {member, pinned}`, `setPreview {group, member?}`, `clearPreviewEverywhere {member}` and +`mergeGroup {from, into, activateSelected?}`. Every group an action touches leaves with unique members, a selection +that is a member (the first one when it was missing or stale, `null` when empty), an MRU order that starts at the +selection and lists every member once, and pins and preview that only name members; groups an action does not touch are +returned as sent, so a host may send only the groups an action involves. `emptyGroups` is `keep` (default), `remove` +(may leave no group) or `removeKeepingFirst`; it applies to the group a `close` or `move` emptied and to every group for +`normalize`. `selectionFallback` is `adjacent` (default: the member that slid into the vacated position) or `first`. +`normalize.unplaced` members no group lists join the focused group (the first group when `focusedGroup` is missing or +stale), and `focusedGroup` moves to the first remaining group when its group was removed. Actions that name an unknown +group or an unknown move destination change nothing. `removedGroupIds` lists removed groups in their previous order so +hosts prune their own layout tree; split directions, ratios, pixels and dragging never reach Core. A malformed payload is +`invalid_request`. It lives in `rust/lithe-core-rules` and is called synchronously: macOS through the C ABI, Windows +through the wasm build inside zustand `set()`, one call per user action. Both hosts run every case of +`shared/fixtures/editor/workbench-state-v1.json` through it. + ## Terminal link targets `terminal.parseLinkTarget` accepts `{texts: string[], baseDirectory?, homeDirectory?, providerSchemes?: string[]}` diff --git a/shared/fixtures/editor/workbench-state-v1.json b/shared/fixtures/editor/workbench-state-v1.json new file mode 100644 index 0000000000..1229d6935a --- /dev/null +++ b/shared/fixtures/editor/workbench-state-v1.json @@ -0,0 +1,398 @@ +{ + "version": 1, + "description": "State transitions of editor.workbenchState.reduce. Each case sends `state` and `action` as the payload and expects exactly `expected.state` and `expected.removedGroupIds`. Groups omit empty fields on input; the reducer always returns them in full.", + "cases": [ + { + "name": "open-new-member-appends-and-selects", + "note": "Opening a new tab appends it, selects it and makes it the most recent.", + "state": {"groups":[{"id":"g1","members":["a","b"],"selected":"a","mru":["a","b"]}]}, + "action": {"kind":"open","group":"g1","member":"c"}, + "expected": {"state":{"groups":[{"id":"g1","members":["a","b","c"],"selected":"c","mru":["c","a","b"],"pinned":[],"preview":null}]},"removedGroupIds":[]} + }, + { + "name": "open-existing-member-does-not-duplicate", + "note": "Opening a tab that is already open only selects it.", + "state": {"groups":[{"id":"g1","members":["a","b"],"selected":"b","mru":["b","a"]}]}, + "action": {"kind":"open","group":"g1","member":"a"}, + "expected": {"state":{"groups":[{"id":"g1","members":["a","b"],"selected":"a","mru":["a","b"],"pinned":[],"preview":null}]},"removedGroupIds":[]} + }, + { + "name": "open-without-activation-keeps-selection", + "note": "A background open (activate false) leaves the selection and MRU head alone.", + "state": {"groups":[{"id":"g1","members":["a","b"],"selected":"a","mru":["a","b"]}]}, + "action": {"kind":"open","group":"g1","member":"c","activate":false}, + "expected": {"state":{"groups":[{"id":"g1","members":["a","b","c"],"selected":"a","mru":["a","b","c"],"pinned":[],"preview":null}]},"removedGroupIds":[]} + }, + { + "name": "open-repairs-duplicates-and-stale-selection", + "note": "Duplicate members collapse to the first occurrence and a stale selection falls back to the first member.", + "state": {"groups":[{"id":"g1","members":["a","a","b"],"selected":"ghost","mru":["ghost","b"]}]}, + "action": {"kind":"open","group":"g1","member":"b","activate":false}, + "expected": {"state":{"groups":[{"id":"g1","members":["a","b"],"selected":"a","mru":["a","b"],"pinned":[],"preview":null}]},"removedGroupIds":[]} + }, + { + "name": "open-into-unknown-group-is-a-no-op", + "note": "A group the host did not send is ignored.", + "state": {"groups":[{"id":"g1","members":["a"],"selected":"a","mru":["a"]}]}, + "action": {"kind":"open","group":"nope","member":"c"}, + "expected": {"state":{"groups":[{"id":"g1","members":["a"],"selected":"a","mru":["a"],"pinned":[],"preview":null}]},"removedGroupIds":[]} + }, + { + "name": "close-selected-middle-selects-adjacent", + "note": "Closing the selected tab selects the one that slid into its position.", + "state": {"groups":[{"id":"g1","members":["a","b","c"],"selected":"b","mru":["b","a","c"]}]}, + "action": {"kind":"close","group":"g1","member":"b"}, + "expected": {"state":{"groups":[{"id":"g1","members":["a","c"],"selected":"c","mru":["c","a"],"pinned":[],"preview":null}]},"removedGroupIds":[]} + }, + { + "name": "close-selected-last-selects-new-last", + "note": "Closing the selected last tab selects the new last tab.", + "state": {"groups":[{"id":"g1","members":["a","b","c"],"selected":"c","mru":["c","b","a"]}]}, + "action": {"kind":"close","group":"g1","member":"c"}, + "expected": {"state":{"groups":[{"id":"g1","members":["a","b"],"selected":"b","mru":["b","a"],"pinned":[],"preview":null}]},"removedGroupIds":[]} + }, + { + "name": "close-selected-with-first-fallback", + "note": "A host that wants the first remaining tab (macOS reconcile rule) asks for selectionFallback first.", + "state": {"groups":[{"id":"g1","members":["a","b","c"],"selected":"b","mru":["b","a","c"]}]}, + "action": {"kind":"close","group":"g1","member":"b","selectionFallback":"first"}, + "expected": {"state":{"groups":[{"id":"g1","members":["a","c"],"selected":"a","mru":["a","c"],"pinned":[],"preview":null}]},"removedGroupIds":[]} + }, + { + "name": "close-unselected-keeps-selection", + "note": "Closing another tab does not move the selection.", + "state": {"groups":[{"id":"g1","members":["a","b","c"],"selected":"b","mru":["b","c","a"]}]}, + "action": {"kind":"close","group":"g1","member":"a"}, + "expected": {"state":{"groups":[{"id":"g1","members":["b","c"],"selected":"b","mru":["b","c"],"pinned":[],"preview":null}]},"removedGroupIds":[]} + }, + { + "name": "close-clears-pin-and-preview-of-the-member", + "note": "Pins and the preview that named the closed tab are dropped; others stay.", + "state": {"groups":[{"id":"g1","members":["a","b"],"selected":"a","mru":["a","b"],"pinned":["a","b"],"preview":"b"}]}, + "action": {"kind":"close","group":"g1","member":"b"}, + "expected": {"state":{"groups":[{"id":"g1","members":["a"],"selected":"a","mru":["a"],"pinned":["a"],"preview":null}]},"removedGroupIds":[]} + }, + { + "name": "close-last-member-keeps-empty-group-by-default", + "note": "Without an empty-group policy the emptied group stays.", + "state": {"groups":[{"id":"g1","members":["a"],"selected":"a","mru":["a"]},{"id":"g2","members":["b"],"selected":"b","mru":["b"]}]}, + "action": {"kind":"close","group":"g1","member":"a"}, + "expected": {"state":{"groups":[{"id":"g1","members":[],"selected":null,"mru":[],"pinned":[],"preview":null},{"id":"g2","members":["b"],"selected":"b","mru":["b"],"pinned":[],"preview":null}]},"removedGroupIds":[]} + }, + { + "name": "close-last-member-removes-empty-group", + "note": "Closing the last tab removes the emptied group and reports it.", + "state": {"groups":[{"id":"g1","members":["a"],"selected":"a","mru":["a"]},{"id":"g2","members":["b"],"selected":"b","mru":["b"]}]}, + "action": {"kind":"close","group":"g1","member":"a","emptyGroups":"remove"}, + "expected": {"state":{"groups":[{"id":"g2","members":["b"],"selected":"b","mru":["b"],"pinned":[],"preview":null}]},"removedGroupIds":["g1"]} + }, + { + "name": "close-last-member-of-only-group-keeps-one-group", + "note": "removeKeepingFirst never leaves the workbench without a group.", + "state": {"groups":[{"id":"g1","members":["a"],"selected":"a","mru":["a"]}]}, + "action": {"kind":"close","group":"g1","member":"a","emptyGroups":"removeKeepingFirst"}, + "expected": {"state":{"groups":[{"id":"g1","members":[],"selected":null,"mru":[],"pinned":[],"preview":null}]},"removedGroupIds":[]} + }, + { + "name": "close-last-member-of-only-group-can-remove-it", + "note": "A host that rebuilds its own root (Windows) asks for plain remove.", + "state": {"groups":[{"id":"g1","members":["a"],"selected":"a","mru":["a"]}]}, + "action": {"kind":"close","group":"g1","member":"a","emptyGroups":"remove"}, + "expected": {"state":{"groups":[]},"removedGroupIds":["g1"]} + }, + { + "name": "close-missing-member-only-repairs-the-group", + "note": "Closing a tab the group does not list changes nothing but invalid state.", + "state": {"groups":[{"id":"g1","members":["a","b"],"selected":"ghost","mru":["ghost","b","a"],"pinned":["ghost","a","a"],"preview":"ghost"}]}, + "action": {"kind":"close","group":"g1","member":"zz"}, + "expected": {"state":{"groups":[{"id":"g1","members":["a","b"],"selected":"a","mru":["a","b"],"pinned":["a"],"preview":null}]},"removedGroupIds":[]} + }, + { + "name": "activate-member-moves-it-to-the-mru-head", + "note": "Selecting a tab makes it the most recently used.", + "state": {"groups":[{"id":"g1","members":["a","b","c"],"selected":"a","mru":["a","b","c"]}]}, + "action": {"kind":"activate","group":"g1","member":"c"}, + "expected": {"state":{"groups":[{"id":"g1","members":["a","b","c"],"selected":"c","mru":["c","a","b"],"pinned":[],"preview":null}]},"removedGroupIds":[]} + }, + { + "name": "activate-unknown-member-keeps-selection", + "note": "Activating a tab that is not in the group leaves the selection.", + "state": {"groups":[{"id":"g1","members":["a","b"],"selected":"b","mru":["b","a"]}]}, + "action": {"kind":"activate","group":"g1","member":"ghost"}, + "expected": {"state":{"groups":[{"id":"g1","members":["a","b"],"selected":"b","mru":["b","a"],"pinned":[],"preview":null}]},"removedGroupIds":[]} + }, + { + "name": "activate-null-repairs-selection-to-first", + "note": "Clearing the selection converges to the first member.", + "state": {"groups":[{"id":"g1","members":["a","b"],"selected":"b","mru":["b","a"]}]}, + "action": {"kind":"activate","group":"g1","member":null}, + "expected": {"state":{"groups":[{"id":"g1","members":["a","b"],"selected":"a","mru":["a","b"],"pinned":[],"preview":null}]},"removedGroupIds":[]} + }, + { + "name": "move-between-groups-selects-in-destination", + "note": "Moving appends to the destination, selects there and re-selects adjacent in the source.", + "state": {"groups":[{"id":"g1","members":["a","b"],"selected":"a","mru":["a","b"]},{"id":"g2","members":["c"],"selected":"c","mru":["c"]}]}, + "action": {"kind":"move","member":"a","from":"g1","to":"g2"}, + "expected": {"state":{"groups":[{"id":"g1","members":["b"],"selected":"b","mru":["b"],"pinned":[],"preview":null},{"id":"g2","members":["c","a"],"selected":"a","mru":["a","c"],"pinned":[],"preview":null}]},"removedGroupIds":[]} + }, + { + "name": "move-last-member-removes-source-group", + "note": "A cross-group move that empties the source removes it when asked.", + "state": {"groups":[{"id":"g1","members":["a"],"selected":"a","mru":["a"]},{"id":"g2","members":["c"],"selected":"c","mru":["c"]}]}, + "action": {"kind":"move","member":"a","from":"g1","to":"g2","emptyGroups":"remove"}, + "expected": {"state":{"groups":[{"id":"g2","members":["c","a"],"selected":"a","mru":["a","c"],"pinned":[],"preview":null}]},"removedGroupIds":["g1"]} + }, + { + "name": "move-last-member-can-keep-source-group", + "note": "A host that wants to refill the source (preserve empty) keeps it.", + "state": {"groups":[{"id":"g1","members":["a"],"selected":"a","mru":["a"]},{"id":"g2","members":["c"],"selected":"c","mru":["c"]}]}, + "action": {"kind":"move","member":"a","from":"g1","to":"g2"}, + "expected": {"state":{"groups":[{"id":"g1","members":[],"selected":null,"mru":[],"pinned":[],"preview":null},{"id":"g2","members":["c","a"],"selected":"a","mru":["a","c"],"pinned":[],"preview":null}]},"removedGroupIds":[]} + }, + { + "name": "move-source-selection-can-fall-back-to-first", + "note": "macOS reconcile picks the first remaining tab in the source.", + "state": {"groups":[{"id":"g1","members":["a","b","c"],"selected":"b","mru":["b","a","c"]},{"id":"g2","members":["d"],"selected":"d","mru":["d"]}]}, + "action": {"kind":"move","member":"b","from":"g1","to":"g2","selectionFallback":"first"}, + "expected": {"state":{"groups":[{"id":"g1","members":["a","c"],"selected":"a","mru":["a","c"],"pinned":[],"preview":null},{"id":"g2","members":["d","b"],"selected":"b","mru":["b","d"],"pinned":[],"preview":null}]},"removedGroupIds":[]} + }, + { + "name": "move-before-anchor-without-activation", + "note": "Dropping next to a tab inserts before it and leaves the destination selection.", + "state": {"groups":[{"id":"g1","members":["a"],"selected":"a","mru":["a"]},{"id":"g2","members":["b","c"],"selected":"b","mru":["b","c"]}]}, + "action": {"kind":"move","member":"a","from":"g1","to":"g2","before":"c","activate":false,"emptyGroups":"removeKeepingFirst"}, + "expected": {"state":{"groups":[{"id":"g2","members":["b","a","c"],"selected":"b","mru":["b","c","a"],"pinned":[],"preview":null}]},"removedGroupIds":["g1"]} + }, + { + "name": "move-after-anchor", + "note": "Inserting after an anchor.", + "state": {"groups":[{"id":"g1","members":["a","x"],"selected":"x","mru":["x","a"]},{"id":"g2","members":["b","c"],"selected":"b","mru":["b","c"]}]}, + "action": {"kind":"move","member":"a","from":"g1","to":"g2","after":"b","activate":false}, + "expected": {"state":{"groups":[{"id":"g1","members":["x"],"selected":"x","mru":["x"],"pinned":[],"preview":null},{"id":"g2","members":["b","a","c"],"selected":"b","mru":["b","c","a"],"pinned":[],"preview":null}]},"removedGroupIds":[]} + }, + { + "name": "move-with-missing-anchor-appends", + "note": "An anchor that is not in the destination falls back to the end.", + "state": {"groups":[{"id":"g1","members":["a","x"],"selected":"x","mru":["x","a"]},{"id":"g2","members":["b"],"selected":"b","mru":["b"]}]}, + "action": {"kind":"move","member":"a","from":"g1","to":"g2","before":"ghost","activate":false}, + "expected": {"state":{"groups":[{"id":"g1","members":["x"],"selected":"x","mru":["x"],"pinned":[],"preview":null},{"id":"g2","members":["b","a"],"selected":"b","mru":["b","a"],"pinned":[],"preview":null}]},"removedGroupIds":[]} + }, + { + "name": "move-within-one-group-goes-to-the-end", + "note": "Moving to the same group removes then re-adds at the end and never removes the group.", + "state": {"groups":[{"id":"g1","members":["a","b","c"],"selected":"b","mru":["b","a","c"]}]}, + "action": {"kind":"move","member":"a","from":"g1","to":"g1","emptyGroups":"remove"}, + "expected": {"state":{"groups":[{"id":"g1","members":["b","c","a"],"selected":"a","mru":["a","b","c"],"pinned":[],"preview":null}]},"removedGroupIds":[]} + }, + { + "name": "move-within-one-group-without-activation-keeps-selection-and-pins", + "note": "macOS reorders tabs inside a group with move; that never changes the selection or pins.", + "state": {"groups":[{"id":"g1","members":["a","b","c"],"selected":"b","mru":["b","a","c"],"pinned":["a"],"preview":"c"}]}, + "action": {"kind":"move","member":"a","from":"g1","to":"g1","after":"c","activate":false,"selectionFallback":"first","emptyGroups":"removeKeepingFirst"}, + "expected": {"state":{"groups":[{"id":"g1","members":["b","c","a"],"selected":"b","mru":["b","a","c"],"pinned":["a"],"preview":"c"}]},"removedGroupIds":[]} + }, + { + "name": "move-within-one-group-before-an-anchor", + "note": "Repositioning before an anchor.", + "state": {"groups":[{"id":"g1","members":["a","b","c"],"selected":"a","mru":["a","b","c"]}]}, + "action": {"kind":"move","member":"c","from":"g1","to":"g1","before":"b","activate":false}, + "expected": {"state":{"groups":[{"id":"g1","members":["a","c","b"],"selected":"a","mru":["a","b","c"],"pinned":[],"preview":null}]},"removedGroupIds":[]} + }, + { + "name": "move-to-unknown-group-is-a-no-op", + "note": "A destination the host did not send leaves everything in place instead of losing the tab.", + "state": {"groups":[{"id":"g1","members":["a","b"],"selected":"a","mru":["a","b"]}]}, + "action": {"kind":"move","member":"a","from":"g1","to":"ghost"}, + "expected": {"state":{"groups":[{"id":"g1","members":["a","b"],"selected":"a","mru":["a","b"],"pinned":[],"preview":null}]},"removedGroupIds":[]} + }, + { + "name": "move-from-unknown-group-still-adds", + "note": "A source the host did not send only skips the removal.", + "state": {"groups":[{"id":"g2","members":["b"],"selected":"b","mru":["b"]}]}, + "action": {"kind":"move","member":"a","from":"ghost","to":"g2"}, + "expected": {"state":{"groups":[{"id":"g2","members":["b","a"],"selected":"a","mru":["a","b"],"pinned":[],"preview":null}]},"removedGroupIds":[]} + }, + { + "name": "move-drops-pin-and-preview-from-the-source", + "note": "Pin and preview belong to the group, so they do not follow the tab.", + "state": {"groups":[{"id":"g1","members":["a","b"],"selected":"a","mru":["a","b"],"pinned":["a","b"],"preview":"a"},{"id":"g2","members":["c"],"selected":"c","mru":["c"]}]}, + "action": {"kind":"move","member":"a","from":"g1","to":"g2"}, + "expected": {"state":{"groups":[{"id":"g1","members":["b"],"selected":"b","mru":["b"],"pinned":["b"],"preview":null},{"id":"g2","members":["c","a"],"selected":"a","mru":["a","c"],"pinned":[],"preview":null}]},"removedGroupIds":[]} + }, + { + "name": "reorder-moves-a-member-without-touching-selection", + "note": "Reordering changes only the member order.", + "state": {"groups":[{"id":"g1","members":["a","b","c"],"selected":"b","mru":["b","c","a"]}]}, + "action": {"kind":"reorder","group":"g1","fromIndex":0,"toIndex":2}, + "expected": {"state":{"groups":[{"id":"g1","members":["b","c","a"],"selected":"b","mru":["b","c","a"],"pinned":[],"preview":null}]},"removedGroupIds":[]} + }, + { + "name": "reorder-out-of-range-is-a-no-op", + "note": "Indexes outside the group change nothing.", + "state": {"groups":[{"id":"g1","members":["a","b"],"selected":"a","mru":["a","b"]}]}, + "action": {"kind":"reorder","group":"g1","fromIndex":0,"toIndex":5}, + "expected": {"state":{"groups":[{"id":"g1","members":["a","b"],"selected":"a","mru":["a","b"],"pinned":[],"preview":null}]},"removedGroupIds":[]} + }, + { + "name": "reorder-negative-index-is-a-no-op", + "note": "Negative indexes are out of range.", + "state": {"groups":[{"id":"g1","members":["a","b"],"selected":"a","mru":["a","b"]}]}, + "action": {"kind":"reorder","group":"g1","fromIndex":-1,"toIndex":1}, + "expected": {"state":{"groups":[{"id":"g1","members":["a","b"],"selected":"a","mru":["a","b"],"pinned":[],"preview":null}]},"removedGroupIds":[]} + }, + { + "name": "pin-clears-the-preview-of-the-same-member", + "note": "Pinning a previewed tab promotes it.", + "state": {"groups":[{"id":"g1","members":["a","b"],"selected":"a","mru":["a","b"],"pinned":[],"preview":"b"}]}, + "action": {"kind":"setPinned","group":"g1","member":"b","pinned":true}, + "expected": {"state":{"groups":[{"id":"g1","members":["a","b"],"selected":"a","mru":["a","b"],"pinned":["b"],"preview":null}]},"removedGroupIds":[]} + }, + { + "name": "pin-twice-keeps-one-pin", + "note": "Pins are unique.", + "state": {"groups":[{"id":"g1","members":["a","b"],"selected":"a","mru":["a","b"],"pinned":["a"]}]}, + "action": {"kind":"setPinned","group":"g1","member":"a","pinned":true}, + "expected": {"state":{"groups":[{"id":"g1","members":["a","b"],"selected":"a","mru":["a","b"],"pinned":["a"],"preview":null}]},"removedGroupIds":[]} + }, + { + "name": "pin-non-member-is-ignored", + "note": "A tab the group does not list cannot be pinned.", + "state": {"groups":[{"id":"g1","members":["a"],"selected":"a","mru":["a"]}]}, + "action": {"kind":"setPinned","group":"g1","member":"ghost","pinned":true}, + "expected": {"state":{"groups":[{"id":"g1","members":["a"],"selected":"a","mru":["a"],"pinned":[],"preview":null}]},"removedGroupIds":[]} + }, + { + "name": "unpin-removes-the-pin", + "note": "Unpinning removes only that member.", + "state": {"groups":[{"id":"g1","members":["a","b"],"selected":"a","mru":["a","b"],"pinned":["a","b"]}]}, + "action": {"kind":"setPinned","group":"g1","member":"a","pinned":false}, + "expected": {"state":{"groups":[{"id":"g1","members":["a","b"],"selected":"a","mru":["a","b"],"pinned":["b"],"preview":null}]},"removedGroupIds":[]} + }, + { + "name": "pin-everywhere-skips-groups-without-the-member", + "note": "Only groups that list the tab change; the others are returned as sent.", + "state": {"groups":[{"id":"g1","members":["a","b"],"selected":"a","mru":["a","b"],"pinned":[],"preview":"a"},{"id":"g2","members":["a"],"selected":"a","mru":["a"]},{"id":"g3","members":["b"],"selected":"b","mru":["b"]}]}, + "action": {"kind":"setPinnedEverywhere","member":"a","pinned":true}, + "expected": {"state":{"groups":[{"id":"g1","members":["a","b"],"selected":"a","mru":["a","b"],"pinned":["a"],"preview":null},{"id":"g2","members":["a"],"selected":"a","mru":["a"],"pinned":["a"],"preview":null},{"id":"g3","members":["b"],"selected":"b","mru":["b"],"pinned":[],"preview":null}]},"removedGroupIds":[]} + }, + { + "name": "unpin-everywhere", + "note": "Unpinning everywhere.", + "state": {"groups":[{"id":"g1","members":["a"],"selected":"a","mru":["a"],"pinned":["a"]},{"id":"g2","members":["a","b"],"selected":"a","mru":["a","b"],"pinned":["a","b"]}]}, + "action": {"kind":"setPinnedEverywhere","member":"a","pinned":false}, + "expected": {"state":{"groups":[{"id":"g1","members":["a"],"selected":"a","mru":["a"],"pinned":[],"preview":null},{"id":"g2","members":["a","b"],"selected":"a","mru":["a","b"],"pinned":["b"],"preview":null}]},"removedGroupIds":[]} + }, + { + "name": "preview-clears-the-pin-of-the-same-member", + "note": "Previewing a pinned tab unpins it.", + "state": {"groups":[{"id":"g1","members":["a","b"],"selected":"a","mru":["a","b"],"pinned":["b"]}]}, + "action": {"kind":"setPreview","group":"g1","member":"b"}, + "expected": {"state":{"groups":[{"id":"g1","members":["a","b"],"selected":"a","mru":["a","b"],"pinned":[],"preview":"b"}]},"removedGroupIds":[]} + }, + { + "name": "preview-replaces-the-previous-preview", + "note": "Only one preview exists per group.", + "state": {"groups":[{"id":"g1","members":["a","b"],"selected":"a","mru":["a","b"],"pinned":[],"preview":"a"}]}, + "action": {"kind":"setPreview","group":"g1","member":"b"}, + "expected": {"state":{"groups":[{"id":"g1","members":["a","b"],"selected":"a","mru":["a","b"],"pinned":[],"preview":"b"}]},"removedGroupIds":[]} + }, + { + "name": "preview-unknown-member-is-ignored", + "note": "Previewing a tab the group does not list changes nothing.", + "state": {"groups":[{"id":"g1","members":["a"],"selected":"a","mru":["a"],"pinned":[],"preview":"a"}]}, + "action": {"kind":"setPreview","group":"g1","member":"ghost"}, + "expected": {"state":{"groups":[{"id":"g1","members":["a"],"selected":"a","mru":["a"],"pinned":[],"preview":"a"}]},"removedGroupIds":[]} + }, + { + "name": "preview-null-clears-it", + "note": "Passing null clears the preview.", + "state": {"groups":[{"id":"g1","members":["a"],"selected":"a","mru":["a"],"pinned":[],"preview":"a"}]}, + "action": {"kind":"setPreview","group":"g1","member":null}, + "expected": {"state":{"groups":[{"id":"g1","members":["a"],"selected":"a","mru":["a"],"pinned":[],"preview":null}]},"removedGroupIds":[]} + }, + { + "name": "clear-preview-everywhere-only-touches-matching-groups", + "note": "Groups previewing another tab stay as they are.", + "state": {"groups":[{"id":"g1","members":["a","b"],"selected":"a","mru":["a","b"],"pinned":[],"preview":"a"},{"id":"g2","members":["a","b"],"selected":"a","mru":["a","b"],"pinned":[],"preview":"b"}]}, + "action": {"kind":"clearPreviewEverywhere","member":"a"}, + "expected": {"state":{"groups":[{"id":"g1","members":["a","b"],"selected":"a","mru":["a","b"],"pinned":[],"preview":null},{"id":"g2","members":["a","b"],"selected":"a","mru":["a","b"],"pinned":[],"preview":"b"}]},"removedGroupIds":[]} + }, + { + "name": "merge-group-folds-members-and-selects-the-shown-tab", + "note": "Closing a group moves its tabs to the fallback group; the shown tab becomes selected when asked.", + "state": {"groups":[{"id":"g1","members":["a","b"],"selected":"b","mru":["b","a"]},{"id":"g2","members":["c"],"selected":"c","mru":["c"]}]}, + "action": {"kind":"mergeGroup","from":"g1","into":"g2","activateSelected":true}, + "expected": {"state":{"groups":[{"id":"g2","members":["c","a","b"],"selected":"b","mru":["b","c","a"],"pinned":[],"preview":null}]},"removedGroupIds":["g1"]} + }, + { + "name": "merge-group-keeps-destination-selection-by-default", + "note": "Without activation the destination keeps showing its own tab and shared tabs are not duplicated.", + "state": {"groups":[{"id":"g1","members":["a","c"],"selected":"a","mru":["a","c"]},{"id":"g2","members":["c"],"selected":"c","mru":["c"]}]}, + "action": {"kind":"mergeGroup","from":"g1","into":"g2"}, + "expected": {"state":{"groups":[{"id":"g2","members":["c","a"],"selected":"c","mru":["c","a"],"pinned":[],"preview":null}]},"removedGroupIds":["g1"]} + }, + { + "name": "merge-group-into-unknown-group-is-a-no-op", + "note": "Without a destination nothing is removed.", + "state": {"groups":[{"id":"g1","members":["a"],"selected":"a","mru":["a"]}]}, + "action": {"kind":"mergeGroup","from":"g1","into":"ghost","activateSelected":true}, + "expected": {"state":{"groups":[{"id":"g1","members":["a"],"selected":"a","mru":["a"],"pinned":[],"preview":null}]},"removedGroupIds":[]} + }, + { + "name": "normalize-repairs-every-reference", + "note": "Duplicates, stale selection, stale MRU, stale pins and a stale preview are all repaired.", + "state": {"groups":[{"id":"g1","members":["a","b","a"],"selected":"ghost","mru":["ghost","b","b"],"pinned":["ghost","b","b"],"preview":"ghost"}]}, + "action": {"kind":"normalize"}, + "expected": {"state":{"groups":[{"id":"g1","members":["a","b"],"selected":"a","mru":["a","b"],"pinned":["b"],"preview":null}]},"removedGroupIds":[]} + }, + { + "name": "normalize-keeps-valid-state-and-mru-order", + "note": "Valid state, including a pin and a preview together, is returned unchanged.", + "state": {"groups":[{"id":"g1","members":["a","b","c"],"selected":"b","mru":["b","c","a"],"pinned":["a"],"preview":"c"}]}, + "action": {"kind":"normalize"}, + "expected": {"state":{"groups":[{"id":"g1","members":["a","b","c"],"selected":"b","mru":["b","c","a"],"pinned":["a"],"preview":"c"}]},"removedGroupIds":[]} + }, + { + "name": "normalize-places-unplaced-members-in-the-focused-group", + "note": "Members no group lists join the focused group; already placed ones are ignored.", + "state": {"groups":[{"id":"g1","members":["a"],"selected":"a"},{"id":"g2","members":["b"],"selected":"b"}],"focusedGroup":"g2"}, + "action": {"kind":"normalize","unplaced":["c","a"]}, + "expected": {"state":{"groups":[{"id":"g1","members":["a"],"selected":"a","mru":["a"],"pinned":[],"preview":null},{"id":"g2","members":["b","c"],"selected":"b","mru":["b","c"],"pinned":[],"preview":null}],"focusedGroup":"g2"},"removedGroupIds":[]} + }, + { + "name": "normalize-places-unplaced-members-in-the-first-group-without-focus", + "note": "A stale focused group falls back to the first group.", + "state": {"groups":[{"id":"g1","members":["a"],"selected":"a"},{"id":"g2","members":["b"],"selected":"b"}],"focusedGroup":"ghost"}, + "action": {"kind":"normalize","unplaced":["c"]}, + "expected": {"state":{"groups":[{"id":"g1","members":["a","c"],"selected":"a","mru":["a","c"],"pinned":[],"preview":null},{"id":"g2","members":["b"],"selected":"b","mru":["b"],"pinned":[],"preview":null}],"focusedGroup":"ghost"},"removedGroupIds":[]} + }, + { + "name": "normalize-removes-empty-groups-and-moves-focus", + "note": "Every empty group goes; focus that pointed at a removed group moves to the first remaining one.", + "state": {"groups":[{"id":"g1","members":[]},{"id":"g2","members":["a"],"selected":"a"},{"id":"g3","members":[]}],"focusedGroup":"g1"}, + "action": {"kind":"normalize","emptyGroups":"removeKeepingFirst"}, + "expected": {"state":{"groups":[{"id":"g2","members":["a"],"selected":"a","mru":["a"],"pinned":[],"preview":null}],"focusedGroup":"g2"},"removedGroupIds":["g1","g3"]} + }, + { + "name": "normalize-with-no-members-keeps-the-first-group", + "note": "When every group is empty exactly the first one stays.", + "state": {"groups":[{"id":"g1","members":[]},{"id":"g2","members":[]}]}, + "action": {"kind":"normalize","emptyGroups":"removeKeepingFirst"}, + "expected": {"state":{"groups":[{"id":"g1","members":[],"selected":null,"mru":[],"pinned":[],"preview":null}]},"removedGroupIds":["g2"]} + }, + { + "name": "normalize-without-pruning-keeps-empty-groups", + "note": "The default policy keeps empty groups (Windows restores layouts this way).", + "state": {"groups":[{"id":"g1","members":[]},{"id":"g2","members":["a"]}]}, + "action": {"kind":"normalize"}, + "expected": {"state":{"groups":[{"id":"g1","members":[],"selected":null,"mru":[],"pinned":[],"preview":null},{"id":"g2","members":["a"],"selected":"a","mru":["a"],"pinned":[],"preview":null}]},"removedGroupIds":[]} + }, + { + "name": "normalize-leaves-focus-alone-when-its-group-survives", + "note": "Focus only changes when its group disappears.", + "state": {"groups":[{"id":"g1","members":[]},{"id":"g2","members":["a"]}],"focusedGroup":"g2"}, + "action": {"kind":"normalize","emptyGroups":"remove"}, + "expected": {"state":{"groups":[{"id":"g2","members":["a"],"selected":"a","mru":["a"],"pinned":[],"preview":null}],"focusedGroup":"g2"},"removedGroupIds":["g1"]} + } + ] +} diff --git a/shared/platform-feature-matrix/features/workbench-panes.json b/shared/platform-feature-matrix/features/workbench-panes.json index 59d549b214..886f82d05d 100644 --- a/shared/platform-feature-matrix/features/workbench-panes.json +++ b/shared/platform-feature-matrix/features/workbench-panes.json @@ -27,21 +27,30 @@ "macos/Tests/LitheTests/WorkbenchMavenLayoutTests.swift", "macos/Tests/LitheTests/CommitActionButtonStyleTests.swift", ".agents/notes/implemented/architecture/2026-09-13-resizable-ui-performance-boundaries.md", - "macos/Tests/LitheTests/LitheSplitPaneCollapseTests.swift" + "macos/Tests/LitheTests/LitheSplitPaneCollapseTests.swift", + "macos/Sources/Lithe/Application/Features/EditorTabOrderFeatureModel.swift", + "macos/Sources/Lithe/Core/Rust/RustEditorWorkbenchState.swift", + "macos/Tests/LitheTests/EditorWorkbenchStateCoreTests.swift", + "shared/fixtures/editor/workbench-state-v1.json" ], "implementationStatus": "implemented", "verificationStatus": "pending", - "notes": "macOS 无已保存布局的工作区侧栏默认 360pt,上半区默认高度比例 0.72,使中文修订提交保持一行、底部工具窗初始更矮;主布局及停靠预览共用常量,已有布局和手动拖动尺寸优先,不提高最小宽高。" + "notes": "macOS 无已保存布局的工作区侧栏默认 360pt,上半区默认高度比例 0.72,使中文修订提交保持一行、底部工具窗初始更矮;主布局及停靠预览共用常量,已有布局和手动拖动尺寸优先,不提高最小宽高。LSP-16:编辑器分组的标签归属、选中项收敛、空组移除和移动后的状态由 Core 的 editor.workbenchState.reduce(rust/lithe-core-rules)提供,EditorTabOrderFeatureModel 只把 items、placements 和 groupSelections 转成规范状态并写回,EditorGroupLayout 只保留树结构;macOS 没有 MRU、固定和预览标签,这三项始终传空值,选中项离开分组时回落到第一个标签(selectionFallback first)。" }, "windows": { "evidence": [ "windows/tauri/src/features/panes", - "windows/tauri/src/features/layout" + "windows/tauri/src/features/layout", + "windows/tauri/src/features/panes/utils/workbench-state.ts", + "windows/tauri/src/features/panes/utils/pane-tree.test.ts", + "windows/tauri/src/features/panes/stores/pane.store.test.ts", + "shared/fixtures/editor/workbench-state-v1.json" ], "implementationStatus": "implemented", - "verificationStatus": "pending" + "verificationStatus": "pending", + "notes": "LSP-16:pane 组的成员去重、打开/关闭/移动/固定/预览后的选中项与 MRU、空组判定由 Core 的 editor.workbenchState.reduce 提供,pane.store 在 zustand set() 里经 wasm 同步调用,每个动作一次调用;pane-tree.ts 只保留布局树结构、拆分方向和比例,并按 Core 返回的 removedGroupIds 修剪被移除的组。关闭选中标签时仍选中滑入原位置的相邻标签(selectionFallback adjacent)。" }, "owner": "Workbench", - "verification": "打开、关闭、移动和调整面板,验证布局持久化及高频拖动稳定性;macOS 主工作区窗口不再被固定的 980×640 内容下限阻止缩小,侧栏、右侧工具窗、编辑区和底部工具窗按 IDEA 主分隔器默认 30pt 下限与实时可用空间调整,无固定 520/760pt 上限,极窄窗口不产生负尺寸或越界,恢复窗口后保留原先保存的面板宽度;拖动右侧 Maven 工具窗时保留当前 Commit 侧栏宽度并由编辑区承受剩余空间变化,底部 Git Log 与终端始终横跨 Project、编辑区及右侧 Maven 所在的整行,向上拖动时上半行按共享 30pt 主分隔器下限收缩,Commit 不设专属高度下限;未打开文件时,将编辑区缩窄或缩矮,空状态提示保持原始文字尺寸、不换行或缩字,超出内容在编辑区内裁剪,恢复尺寸后完整显示;编辑区和左右工具窗共用按可见尺寸布局的面板容器,在 30pt 高度下 Project、Commit、Search 及右侧 Maven 标题仍贴顶且四角圆角保持可见;macOS Project、Commit、Search、Database 和 Pull Requests 侧栏标题右端使用 IDEA 明暗收起图标与共用悬停样式,点击后收起侧栏并保留活动栏和编辑区,再点击对应活动栏图标恢复侧栏及原宽度;在 macOS 启用背景图后检查项目栏、编辑器、底部及右侧工具窗之间是否仍有清晰的分割区间;将 macOS 左侧工具窗缩至 30pt 并确认目录树始终贴左、不随宽度向右移动、内容不溢出、右上和右下圆角仍可见,关闭并重新打开项目后仍保持窄侧栏及其他布局尺寸,重新展开后布局正常;在 Maven 项目产生构建输出时确认左侧模块按钮可滚动、设置按钮始终完整显示;在亮色主题且有未读通知时确认未打开的铃铛图标清晰可见、打开通知面板后白色图标与蓝色背景成对出现;在深色 Lithe 主题下对照外框渐变、面板对比度、背景图片回退、左右工具窗的 40pt 按钮槽、30pt 选中块及 IDEA Community 图标对比度,悬停工具窗按钮时确认箭头光标,检查项目栏标题颜色和弹窗主按钮主题色;检查 macOS 40pt 顶栏 Logo 与系统窗口按钮垂直居中,项目切换器使用 20pt 项目字母头像和 30pt 悬停块,项目与分支切换块悬停保持箭头光标,红绿灯右侧留白与 IDEA 接近,项目头像、欢迎列表及切换菜单按标准化项目路径共享稳定颜色(超过 9 个项目、移除后重开仍一致),顶栏运行配置使用 16pt 图标与 13pt Regular 文字、内容自然宽度和 6pt 图文间距;运行与调试使用 Community 描边资源、34×40pt 槽位及 30×30pt 悬停区域,运行、调试、更新和背景按钮悬停保持默认箭头,检查深浅主题、菜单开合、窄窗口和原动作;顶栏使用 IDEA Islands 主题项目色并按 85% 混色、600pt 水平和 300pt 垂直范围渐变,深色窗口分隔条四角与底栏缺口无浅色亮斑,缩放窗口及从欢迎页进入工作区再返回后仍对齐。 macOS 主工具栏仅保留运行配置、Run、Debug 及执行中的 Stop;确认右上角无新增搜索和设置按钮,原运行/调试动作及既有快捷键、应用菜单入口正常。 macOS 共享分隔条普通、hover 和按住拖动使用相同分隔线颜色,不增亮;原调整光标、命中范围和释放保存尺寸保持可用。 macOS 设置入口位于主工具栏最右侧,移除左下重复入口,沿用 IDEA settings SVG 和原设置动作,核对点击、悬停与窄窗口显示。 右上设置按钮先打开共享动作菜单,仅有设置/插件、分隔线、主题/快捷键四项,设置显示当前 Keymap 快捷键;各项打开现有通用/插件/快捷键分类,主题在通用主题设置中配置,Esc/外部点击关闭菜单。 右上齿轮与右侧通知/数据库入口共用竖直中心线,窄宽窗口保持对齐;右侧活动栏不再显示插件按钮,齿轮菜单插件项仍有效。 设置图标普通、悬停及菜单展开时使用右侧入口的 toolWindowButtonText 前景色,深浅主题跟随相同 token;保留现有菜单和悬停背景。 运行配置/运行/调试组与末端设置之间有可收缩留白,宽窗口对照参考位置左移,窄窗口收缩留白且设置保持右侧栏对齐;打开运行配置弹窗及运行/调试动作保持原行为。 A/B 分屏后在 A 发起跨文件 Rename/Code Action,验证 B 的同步 owner 与保存/关闭屏障不被接管,待处理输入先排空,修订递增并检查撤销;各组右键 Other/Left/Right/All 按组内重排后的可见顺序关闭文档,不关闭其他组或混合终端标签。 macOS 内置与声明式插件工具窗口通过稳定贡献 ID 注册,宿主菜单支持底部/右侧位置、顺序、入口显隐和默认布局恢复;几何尺寸提交不覆盖这些偏好。 macOS Project、Commit、Search 和 Pull Requests 入口通过贡献注册表显示(Pull Requests 仍受原功能开关限制);把内置侧栏和插件工具窗移动到左侧/右侧/底部,检查原内容、收起按钮、独立显隐与按工作区保存的入口顺序,重开项目恢复左栏选择和展开状态;旧会话名称与旧尺寸配置继续可读,禁用模块不实例化且关闭内容,插件左栏恢复必须激活所属模块。 macOS 动作状态:确认窗口入口、工具栏、菜单与命令面板共用可用和勾选状态,激活中命令禁用且 Enter/鼠标均不能触发,窗口入口允许取消;同时打开同一插件的两个窗口只构造一次模块,取消其中一个不影响另一个;激活中禁用再启用,确认无资源残留或晚到贡献,旧动作引用不绕过状态校验。 插件卸载后恢复包含旧侧栏 ID 的会话,确认回退项目入口且不出现未知底部加载窗口。 macOS Run/Debug/Stop 主工具栏入口与插件按钮共用显隐和顺序偏好,重开项目及尺寸提交后保持;找回隐藏工具栏或菜单入口不执行命令,菜单项可从菜单命令管理入口恢复。 恢复先前可见的 Services 后,不借助其他入口完成所属模块激活并能操作;Show 不会 toggle 关闭已经可见的窗口。Maven renderer 在非 Maven 项目不可见时,旧布局中的可见标记不占据停靠内容空间。prepareForSleep 挂起时并发 activate 等待休眠完成,只在新实例中重新激活,贡献不重复发布、旧实例资源全部释放。 关闭模块在 gate 暂停时取消等待激活的调用方,放行关闭后构造与贡献发布仍各一次,无实例及资源残留;首次点击和 Show 打开 A 时暂停激活,同组选择 B 后放行 A,最终仍显示 B,不同组可以同时显示;默认右侧 Database → Agent → Maven 中将 Maven 前移,再后移,检查实际 tools(in:) 顺序随之变化。 开启/替换/关闭工作台背景时,检查Debug Console正文、左侧工具栏及Performance展开/收起区均露出同一工作台背景;移除背景恢复主题底色,保留ANSI背景、选区、输入草稿和原生文本控件身份。 所有主工具窗口隐藏按钮共用整块面板悬停环境;鼠标离开时隐藏且不可鼠标命中,保留槽位与键盘/VoiceOver 焦点显示;分别检查普通宿主与 Terminal/Git 保留宿主,Project 正文悬停也显示定位、刷新和展开/收起动作。 新工作区检查侧栏默认 360pt 及底部约28%高度;中英文明暗 Commit 原生布局验证修订提交一行且不与 AI/暂存数重叠;保存320pt侧栏和自定义上半高度后重开仍保持原值,极窄布局、底部隐藏恢复与拖动不回跳。资源截图不替代完整工作台验收。 共享分隔容器在横向/纵向、面板为空或含多个直接子视图时,仍保留两块独立面板及分隔条;搜索的更多提示不改变面板数量。核对尺寸限制、收起恢复、嵌套拖动和原生表面身份不变。", + "verification": "LSP-16:在 macOS 与 Windows 上各打开三个以上标签并拆分出第二个分组,重复打开同一个文件只切换选中不重复出现;关闭选中的中间标签后,Windows 选中滑入原位置的相邻标签,macOS 分组选中回到该组第一个标签;把分组里最后一个标签拖到另一分组后源分组消失、其余分组与比例不变,关闭最后一个分组的最后一个标签后仍保留一个空分组;Windows 固定标签后将其设为预览会取消固定,反之亦然,关闭标签后固定和预览标记同时消失;重启恢复布局后引用了已不存在的标签的分组被修复。 打开、关闭、移动和调整面板,验证布局持久化及高频拖动稳定性;macOS 主工作区窗口不再被固定的 980×640 内容下限阻止缩小,侧栏、右侧工具窗、编辑区和底部工具窗按 IDEA 主分隔器默认 30pt 下限与实时可用空间调整,无固定 520/760pt 上限,极窄窗口不产生负尺寸或越界,恢复窗口后保留原先保存的面板宽度;拖动右侧 Maven 工具窗时保留当前 Commit 侧栏宽度并由编辑区承受剩余空间变化,底部 Git Log 与终端始终横跨 Project、编辑区及右侧 Maven 所在的整行,向上拖动时上半行按共享 30pt 主分隔器下限收缩,Commit 不设专属高度下限;未打开文件时,将编辑区缩窄或缩矮,空状态提示保持原始文字尺寸、不换行或缩字,超出内容在编辑区内裁剪,恢复尺寸后完整显示;编辑区和左右工具窗共用按可见尺寸布局的面板容器,在 30pt 高度下 Project、Commit、Search 及右侧 Maven 标题仍贴顶且四角圆角保持可见;macOS Project、Commit、Search、Database 和 Pull Requests 侧栏标题右端使用 IDEA 明暗收起图标与共用悬停样式,点击后收起侧栏并保留活动栏和编辑区,再点击对应活动栏图标恢复侧栏及原宽度;在 macOS 启用背景图后检查项目栏、编辑器、底部及右侧工具窗之间是否仍有清晰的分割区间;将 macOS 左侧工具窗缩至 30pt 并确认目录树始终贴左、不随宽度向右移动、内容不溢出、右上和右下圆角仍可见,关闭并重新打开项目后仍保持窄侧栏及其他布局尺寸,重新展开后布局正常;在 Maven 项目产生构建输出时确认左侧模块按钮可滚动、设置按钮始终完整显示;在亮色主题且有未读通知时确认未打开的铃铛图标清晰可见、打开通知面板后白色图标与蓝色背景成对出现;在深色 Lithe 主题下对照外框渐变、面板对比度、背景图片回退、左右工具窗的 40pt 按钮槽、30pt 选中块及 IDEA Community 图标对比度,悬停工具窗按钮时确认箭头光标,检查项目栏标题颜色和弹窗主按钮主题色;检查 macOS 40pt 顶栏 Logo 与系统窗口按钮垂直居中,项目切换器使用 20pt 项目字母头像和 30pt 悬停块,项目与分支切换块悬停保持箭头光标,红绿灯右侧留白与 IDEA 接近,项目头像、欢迎列表及切换菜单按标准化项目路径共享稳定颜色(超过 9 个项目、移除后重开仍一致),顶栏运行配置使用 16pt 图标与 13pt Regular 文字、内容自然宽度和 6pt 图文间距;运行与调试使用 Community 描边资源、34×40pt 槽位及 30×30pt 悬停区域,运行、调试、更新和背景按钮悬停保持默认箭头,检查深浅主题、菜单开合、窄窗口和原动作;顶栏使用 IDEA Islands 主题项目色并按 85% 混色、600pt 水平和 300pt 垂直范围渐变,深色窗口分隔条四角与底栏缺口无浅色亮斑,缩放窗口及从欢迎页进入工作区再返回后仍对齐。 macOS 主工具栏仅保留运行配置、Run、Debug 及执行中的 Stop;确认右上角无新增搜索和设置按钮,原运行/调试动作及既有快捷键、应用菜单入口正常。 macOS 共享分隔条普通、hover 和按住拖动使用相同分隔线颜色,不增亮;原调整光标、命中范围和释放保存尺寸保持可用。 macOS 设置入口位于主工具栏最右侧,移除左下重复入口,沿用 IDEA settings SVG 和原设置动作,核对点击、悬停与窄窗口显示。 右上设置按钮先打开共享动作菜单,仅有设置/插件、分隔线、主题/快捷键四项,设置显示当前 Keymap 快捷键;各项打开现有通用/插件/快捷键分类,主题在通用主题设置中配置,Esc/外部点击关闭菜单。 右上齿轮与右侧通知/数据库入口共用竖直中心线,窄宽窗口保持对齐;右侧活动栏不再显示插件按钮,齿轮菜单插件项仍有效。 设置图标普通、悬停及菜单展开时使用右侧入口的 toolWindowButtonText 前景色,深浅主题跟随相同 token;保留现有菜单和悬停背景。 运行配置/运行/调试组与末端设置之间有可收缩留白,宽窗口对照参考位置左移,窄窗口收缩留白且设置保持右侧栏对齐;打开运行配置弹窗及运行/调试动作保持原行为。 A/B 分屏后在 A 发起跨文件 Rename/Code Action,验证 B 的同步 owner 与保存/关闭屏障不被接管,待处理输入先排空,修订递增并检查撤销;各组右键 Other/Left/Right/All 按组内重排后的可见顺序关闭文档,不关闭其他组或混合终端标签。 macOS 内置与声明式插件工具窗口通过稳定贡献 ID 注册,宿主菜单支持底部/右侧位置、顺序、入口显隐和默认布局恢复;几何尺寸提交不覆盖这些偏好。 macOS Project、Commit、Search 和 Pull Requests 入口通过贡献注册表显示(Pull Requests 仍受原功能开关限制);把内置侧栏和插件工具窗移动到左侧/右侧/底部,检查原内容、收起按钮、独立显隐与按工作区保存的入口顺序,重开项目恢复左栏选择和展开状态;旧会话名称与旧尺寸配置继续可读,禁用模块不实例化且关闭内容,插件左栏恢复必须激活所属模块。 macOS 动作状态:确认窗口入口、工具栏、菜单与命令面板共用可用和勾选状态,激活中命令禁用且 Enter/鼠标均不能触发,窗口入口允许取消;同时打开同一插件的两个窗口只构造一次模块,取消其中一个不影响另一个;激活中禁用再启用,确认无资源残留或晚到贡献,旧动作引用不绕过状态校验。 插件卸载后恢复包含旧侧栏 ID 的会话,确认回退项目入口且不出现未知底部加载窗口。 macOS Run/Debug/Stop 主工具栏入口与插件按钮共用显隐和顺序偏好,重开项目及尺寸提交后保持;找回隐藏工具栏或菜单入口不执行命令,菜单项可从菜单命令管理入口恢复。 恢复先前可见的 Services 后,不借助其他入口完成所属模块激活并能操作;Show 不会 toggle 关闭已经可见的窗口。Maven renderer 在非 Maven 项目不可见时,旧布局中的可见标记不占据停靠内容空间。prepareForSleep 挂起时并发 activate 等待休眠完成,只在新实例中重新激活,贡献不重复发布、旧实例资源全部释放。 关闭模块在 gate 暂停时取消等待激活的调用方,放行关闭后构造与贡献发布仍各一次,无实例及资源残留;首次点击和 Show 打开 A 时暂停激活,同组选择 B 后放行 A,最终仍显示 B,不同组可以同时显示;默认右侧 Database → Agent → Maven 中将 Maven 前移,再后移,检查实际 tools(in:) 顺序随之变化。 开启/替换/关闭工作台背景时,检查Debug Console正文、左侧工具栏及Performance展开/收起区均露出同一工作台背景;移除背景恢复主题底色,保留ANSI背景、选区、输入草稿和原生文本控件身份。 所有主工具窗口隐藏按钮共用整块面板悬停环境;鼠标离开时隐藏且不可鼠标命中,保留槽位与键盘/VoiceOver 焦点显示;分别检查普通宿主与 Terminal/Git 保留宿主,Project 正文悬停也显示定位、刷新和展开/收起动作。 新工作区检查侧栏默认 360pt 及底部约28%高度;中英文明暗 Commit 原生布局验证修订提交一行且不与 AI/暂存数重叠;保存320pt侧栏和自定义上半高度后重开仍保持原值,极窄布局、底部隐藏恢复与拖动不回跳。资源截图不替代完整工作台验收。 共享分隔容器在横向/纵向、面板为空或含多个直接子视图时,仍保留两块独立面板及分隔条;搜索的更多提示不改变面板数量。核对尺寸限制、收起恢复、嵌套拖动和原生表面身份不变。", "notes": " 工作台共享表面向后代传递背景图片状态,原生输出视图使用同一状态控制滚动视图底色,防止局部不透明背景遮挡;同样覆盖既有共享输出调用者。" } diff --git a/windows/tauri/src/features/editor/stores/buffer-agent-conversation.test.ts b/windows/tauri/src/features/editor/stores/buffer-agent-conversation.test.ts index 2565abfaeb..4401a7131d 100644 --- a/windows/tauri/src/features/editor/stores/buffer-agent-conversation.test.ts +++ b/windows/tauri/src/features/editor/stores/buffer-agent-conversation.test.ts @@ -1,4 +1,5 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test"; +import { loadCoreRulesForTests } from "@/test-utils/core-rules"; import { workspaceRuntimeRegistry } from "@/features/workspace/runtime/workspace-runtime-registry"; import { useSettingsStore } from "@/features/settings/stores/settings.store"; import { useBufferStore } from "./buffer.store"; @@ -12,6 +13,9 @@ function setMaxOpenTabs(maxOpenTabs: number) { }); } +// Pane membership rules run in the real lithe-core-rules wasm build. +beforeAll(loadCoreRulesForTests); + beforeEach(() => { workspaceRuntimeRegistry.resetForTests(); workspaceRuntimeRegistry.ensureWorkspace({ id: WORKSPACE, name: "Agent" }, "ready"); @@ -28,9 +32,7 @@ describe("openAgentConversationBuffer", () => { const first = store.getState().actions.openAgentConversationBuffer("session-a"); const second = store.getState().actions.openAgentConversationBuffer("session-a"); - const tabs = store - .getState() - .buffers.filter((buffer) => buffer.type === "agentConversation"); + const tabs = store.getState().buffers.filter((buffer) => buffer.type === "agentConversation"); expect(second).toBe(first); expect(tabs).toHaveLength(1); expect(store.getState().activeBufferId).toBe(first); @@ -41,9 +43,7 @@ describe("openAgentConversationBuffer", () => { store.getState().actions.openAgentConversationBuffer("session-a"); store.getState().actions.openAgentConversationBuffer("session-b"); - const tabs = store - .getState() - .buffers.filter((buffer) => buffer.type === "agentConversation"); + const tabs = store.getState().buffers.filter((buffer) => buffer.type === "agentConversation"); expect(tabs).toHaveLength(2); expect(tabs.map((buffer) => buffer.path).sort()).toEqual([ "agent-conversation://session-a", @@ -55,9 +55,7 @@ describe("openAgentConversationBuffer", () => { const store = useBufferStore.getStore(WORKSPACE); store.getState().actions.openAgentConversationBuffer("session-a", "claude-acp"); - const tab = store - .getState() - .buffers.find((buffer) => buffer.type === "agentConversation"); + const tab = store.getState().buffers.find((buffer) => buffer.type === "agentConversation"); expect(tab?.type).toBe("agentConversation"); if (tab?.type !== "agentConversation") throw new Error("Agent tab missing"); expect(tab.agentId).toBe("claude-acp"); @@ -71,7 +69,9 @@ describe("openAgentConversationBuffer", () => { store.getState().actions.openAgentConversationBuffer("session-b"); expect( - store.getState().buffers.some((buffer) => buffer.id === id && buffer.type === "agentConversation"), + store + .getState() + .buffers.some((buffer) => buffer.id === id && buffer.type === "agentConversation"), ).toBe(true); }); }); diff --git a/windows/tauri/src/features/editor/stores/buffer-close-saved-tabs.test.ts b/windows/tauri/src/features/editor/stores/buffer-close-saved-tabs.test.ts index 3077962e10..161dcbff4c 100644 --- a/windows/tauri/src/features/editor/stores/buffer-close-saved-tabs.test.ts +++ b/windows/tauri/src/features/editor/stores/buffer-close-saved-tabs.test.ts @@ -1,4 +1,5 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test"; +import { loadCoreRulesForTests } from "@/test-utils/core-rules"; import type { EditorContent, PaneContent } from "@/features/panes/types/pane-content.types"; import { workspaceRuntimeRegistry } from "@/features/workspace/runtime/workspace-runtime-registry"; import { useBufferStore } from "./buffer.store"; @@ -35,6 +36,9 @@ function openBufferIds(): string[] { .buffers.map((buffer) => buffer.id); } +// Pane membership rules run in the real lithe-core-rules wasm build. +beforeAll(loadCoreRulesForTests); + beforeEach(() => { workspaceRuntimeRegistry.resetForTests(); workspaceRuntimeRegistry.ensureWorkspace({ id: WORKSPACE, name: "Workspace" }, "ready"); diff --git a/windows/tauri/src/features/editor/stores/buffer-new-tab.test.ts b/windows/tauri/src/features/editor/stores/buffer-new-tab.test.ts index ec2d0649c8..5624e93d59 100644 --- a/windows/tauri/src/features/editor/stores/buffer-new-tab.test.ts +++ b/windows/tauri/src/features/editor/stores/buffer-new-tab.test.ts @@ -1,4 +1,5 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test"; +import { loadCoreRulesForTests } from "@/test-utils/core-rules"; import { workspaceRuntimeRegistry } from "@/features/workspace/runtime/workspace-runtime-registry"; import { getBufferById } from "../utils/buffer-index"; import type { EditorContent } from "@/features/panes/types/pane-content.types"; @@ -23,6 +24,9 @@ function setMaxOpenTabs(maxOpenTabs: number) { }); } +// Pane membership rules run in the real lithe-core-rules wasm build. +beforeAll(loadCoreRulesForTests); + beforeEach(() => { workspaceRuntimeRegistry.resetForTests(); workspaceRuntimeRegistry.ensureWorkspace({ id: WORKSPACE, name: "New Tab" }, "ready"); @@ -60,9 +64,7 @@ describe("showNewTabView", () => { store.getState().actions.showNewTabView(); store.getState().actions.showNewTabView(); - const opened = store - .getState() - .buffers.filter((buffer) => buffer.path.startsWith("untitled:")); + const opened = store.getState().buffers.filter((buffer) => buffer.path.startsWith("untitled:")); expect(opened).toHaveLength(2); // Closing the first tab leaves a single untitled tab behind. Counting the remaining diff --git a/windows/tauri/src/features/panes/stores/pane.store.test.ts b/windows/tauri/src/features/panes/stores/pane.store.test.ts new file mode 100644 index 0000000000..07aed9eb91 --- /dev/null +++ b/windows/tauri/src/features/panes/stores/pane.store.test.ts @@ -0,0 +1,241 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test"; +import { BOTTOM_PANE_ID, ROOT_PANE_ID } from "../constants/pane"; +import { loadCoreRulesForTests } from "@/test-utils/core-rules"; +import { workspaceRuntimeRegistry } from "@/features/workspace/runtime/workspace-runtime-registry"; +import type { PaneGroup, PaneNode } from "../types/pane.types"; + +import { usePaneStore } from "./pane.store"; + +// Counts Core round trips by wrapping the wasm entry point before the real module loads. Every call +// still runs the real rules, so the count is of the same ABI the app uses. +let coreCalls = 0; +const instantiate = WebAssembly.instantiate.bind(WebAssembly); +WebAssembly.instantiate = (async (bytes: BufferSource, imports?: WebAssembly.Imports) => { + const result = (await instantiate( + bytes, + imports, + )) as unknown as WebAssembly.WebAssemblyInstantiatedSource; + const exports = result.instance.exports as Record; + const execute = exports.lithe_rules_execute as (pointer: number, length: number) => number; + const counted = { + ...exports, + lithe_rules_execute: (pointer: number, length: number) => { + coreCalls += 1; + return execute(pointer, length); + }, + }; + return { module: result.module, instance: { exports: counted } }; +}) as unknown as typeof WebAssembly.instantiate; + +const WORKSPACE = "pane-store-test"; + +beforeAll(loadCoreRulesForTests); +afterAll(() => { + WebAssembly.instantiate = instantiate; +}); + +beforeEach(() => { + workspaceRuntimeRegistry.resetForTests(); + workspaceRuntimeRegistry.ensureWorkspace({ id: WORKSPACE, name: "Panes" }, "ready"); + coreCalls = 0; +}); + +function store() { + return usePaneStore.getStore(WORKSPACE); +} + +function actions() { + return store().getState().actions; +} + +function group(id: string, bufferIds: string[], activeBufferId: string | null): PaneGroup { + return { id, type: "group", bufferIds, activeBufferId }; +} + +function paneIds(root: PaneNode): string[] { + return root.type === "group" ? [root.id] : root.children.flatMap(paneIds); +} + +function openTabs(...bufferIds: string[]) { + for (const bufferId of bufferIds) actions().addBufferToPane(ROOT_PANE_ID, bufferId); +} + +describe("pane store membership rules", () => { + test("opening, activating and closing tabs follow the shared selection rules", () => { + openTabs("a", "b", "c"); + expect(actions().getPaneById(ROOT_PANE_ID)).toMatchObject({ + bufferIds: ["a", "b", "c"], + activeBufferId: "c", + mruBufferIds: ["c", "b", "a"], + }); + + actions().activatePaneBuffer(ROOT_PANE_ID, "b"); + actions().removeBufferFromPane(ROOT_PANE_ID, "b"); + expect(actions().getPaneById(ROOT_PANE_ID)).toMatchObject({ + bufferIds: ["a", "c"], + activeBufferId: "c", + }); + }); + + test("closing the last tab of a split pane collapses it and activates the survivor", () => { + openTabs("a"); + const created = actions().splitPane(ROOT_PANE_ID, "horizontal", "b"); + expect(created).not.toBeNull(); + expect(store().getState().activePaneId).toBe(created as string); + + actions().removeBufferFromPane(created!, "b"); + expect(paneIds(store().getState().root)).toEqual([ROOT_PANE_ID]); + expect(store().getState().activePaneId).toBe(ROOT_PANE_ID); + }); + + test("closing the last tab of the only pane keeps one empty root pane", () => { + openTabs("a"); + actions().removeBufferFromPane(ROOT_PANE_ID, "a"); + expect(store().getState().root).toMatchObject({ + id: ROOT_PANE_ID, + type: "group", + bufferIds: [], + activeBufferId: null, + }); + }); + + test("preserving the empty pane keeps the split", () => { + openTabs("a"); + const created = actions().splitPane(ROOT_PANE_ID, "vertical", "b")!; + actions().removeBufferFromPane(created, "b", true); + expect(paneIds(store().getState().root)).toEqual([ROOT_PANE_ID, created]); + expect(actions().getPaneById(created)?.bufferIds).toEqual([]); + }); + + test("moving a tab between panes empties and closes the source", () => { + openTabs("a"); + const created = actions().splitPane(ROOT_PANE_ID, "horizontal", "b")!; + actions().moveBufferToPane("b", created, ROOT_PANE_ID); + expect(paneIds(store().getState().root)).toEqual([ROOT_PANE_ID]); + expect(actions().getPaneById(ROOT_PANE_ID)).toMatchObject({ + bufferIds: ["a", "b"], + activeBufferId: "b", + }); + expect(store().getState().activePaneId).toBe(ROOT_PANE_ID); + }); + + test("moving a tab to the bottom tree takes it out of the root tree", () => { + openTabs("a", "b"); + actions().moveBufferToPane("b", ROOT_PANE_ID, BOTTOM_PANE_ID); + expect(actions().getPaneById(ROOT_PANE_ID)).toMatchObject({ + bufferIds: ["a"], + activeBufferId: "a", + }); + expect(actions().getPaneById(BOTTOM_PANE_ID)).toMatchObject({ + bufferIds: ["b"], + activeBufferId: "b", + }); + expect(store().getState().activePaneId).toBe(BOTTOM_PANE_ID); + }); + + test("moving the only tab of the root tree out leaves a fresh empty root", () => { + openTabs("a"); + actions().moveBufferToPane("a", ROOT_PANE_ID, BOTTOM_PANE_ID); + expect(store().getState().root).toMatchObject({ id: ROOT_PANE_ID, bufferIds: [] }); + expect(actions().getPaneById(BOTTOM_PANE_ID)?.bufferIds).toEqual(["a"]); + }); + + test("closing a pane moves its tabs to the most recent pane and shows its selection", () => { + openTabs("a"); + const created = actions().splitPane(ROOT_PANE_ID, "horizontal", "b")!; + actions().addBufferToPane(created, "c"); + actions().closePane(created); + expect(paneIds(store().getState().root)).toEqual([ROOT_PANE_ID]); + expect(actions().getPaneById(ROOT_PANE_ID)).toMatchObject({ + bufferIds: ["a", "b", "c"], + activeBufferId: "c", + }); + expect(store().getState().activePaneId).toBe(ROOT_PANE_ID); + }); + + test("pins and previews apply across both trees", () => { + openTabs("a"); + actions().addBufferToPane(BOTTOM_PANE_ID, "a"); + actions().setPanePreviewBuffer(ROOT_PANE_ID, "a"); + actions().setBufferPinnedEverywhere("a", true); + expect(actions().getPaneById(ROOT_PANE_ID)).toMatchObject({ + pinnedBufferIds: ["a"], + previewBufferId: null, + }); + expect(actions().getPaneById(BOTTOM_PANE_ID)?.pinnedBufferIds).toEqual(["a"]); + + actions().setPanePreviewBuffer(BOTTOM_PANE_ID, "a"); + actions().clearPreviewBufferEverywhere("a"); + expect(actions().getPaneById(BOTTOM_PANE_ID)?.previewBufferId).toBeNull(); + }); + + test("restoring a layout repairs both trees", () => { + actions().restoreLayout({ + root: group("r", ["a", "a", "b"], "ghost"), + bottomRoot: group("t", ["x", "x"], "x"), + activePaneId: "ghost", + fullscreenPaneId: null, + }); + const state = store().getState(); + expect(state.root).toMatchObject({ bufferIds: ["a", "b"], activeBufferId: "a" }); + expect(state.bottomRoot).toMatchObject({ bufferIds: ["x"], activeBufferId: "x" }); + }); +}); + +describe("pane store Core round trips", () => { + test("every member action makes exactly one Core call", () => { + openTabs("a", "b"); + const created = actions().splitPane(ROOT_PANE_ID, "horizontal", "c")!; + actions().addBufferToPane(created, "d"); + actions().addBufferToPane(BOTTOM_PANE_ID, "t"); + + const counted: Array<[string, () => void]> = [ + ["addBufferToPane", () => actions().addBufferToPane(ROOT_PANE_ID, "e")], + ["activatePaneBuffer", () => actions().activatePaneBuffer(ROOT_PANE_ID, "a")], + ["setPanePreviewBuffer", () => actions().setPanePreviewBuffer(ROOT_PANE_ID, "b")], + ["setPaneBufferPinned", () => actions().setPaneBufferPinned(ROOT_PANE_ID, "a", true)], + ["setPaneLocked", () => actions().setPaneLocked(ROOT_PANE_ID, true)], + ["setBufferPinnedEverywhere", () => actions().setBufferPinnedEverywhere("a", false)], + ["clearPreviewBufferEverywhere", () => actions().clearPreviewBufferEverywhere("b")], + ["reorderPaneBuffers", () => actions().reorderPaneBuffers(ROOT_PANE_ID, 0, 1)], + ["removeBufferFromPane", () => actions().removeBufferFromPane(ROOT_PANE_ID, "e")], + ["moveBufferToPane", () => actions().moveBufferToPane("d", created, BOTTOM_PANE_ID)], + [ + "moveBufferToPane across a split", + () => actions().moveBufferToPane("c", created, ROOT_PANE_ID), + ], + ["splitPane", () => actions().splitPane(ROOT_PANE_ID, "vertical", "z")], + ["closePane", () => actions().closePane(ROOT_PANE_ID)], + [ + "restoreLayout", + () => + actions().restoreLayout({ + root: store().getState().root, + bottomRoot: store().getState().bottomRoot, + activePaneId: store().getState().activePaneId, + fullscreenPaneId: null, + }), + ], + ]; + for (const [name, run] of counted) { + coreCalls = 0; + run(); + expect({ name, coreCalls }).toEqual({ name, coreCalls: 1 }); + } + }); + + test("resizing and distributing splits never call Core", () => { + openTabs("a"); + const created = actions().splitPane(ROOT_PANE_ID, "horizontal", "b")!; + const root = store().getState().root; + if (root.type !== "split") throw new Error("expected a split"); + coreCalls = 0; + actions().updatePaneSizes(root.id, [30, 70]); + actions().resizePaneSplit(root.id, 0, [40, 60]); + actions().distributePaneSplit(root.id); + actions().setActivePane(created); + actions().navigateToPane("left"); + actions().togglePaneFullscreen(created); + expect(coreCalls).toBe(0); + }); +}); diff --git a/windows/tauri/src/features/panes/stores/pane.store.ts b/windows/tauri/src/features/panes/stores/pane.store.ts index ad5312328b..323e0d05fd 100644 --- a/windows/tauri/src/features/panes/stores/pane.store.ts +++ b/windows/tauri/src/features/panes/stores/pane.store.ts @@ -16,8 +16,9 @@ import { getAdjacentPane, getAllPaneGroups, getFirstPaneGroup, + mergeClosedPaneInto, moveBufferBetweenPanes, - normalizePaneTree, + normalizePaneTrees, removeBufferFromPane, resizeFlattenedPaneSplit, setActivePaneBuffer, @@ -126,20 +127,9 @@ function getTreeForPane( return paneId === BOTTOM_PANE_ID ? "bottom" : "root"; } -function collapseEmptyPaneInTree(tree: PaneNode, paneId: string, fallbackId: string) { - const pane = findPaneGroup(tree, paneId); - if (!pane || pane.bufferIds.length > 0) { - return tree; - } - - if (tree.type === "group") { - return fallbackId === ROOT_PANE_ID ? createInitialRoot() : createInitialBottomRoot(); - } - - return ( - closePane(tree, paneId) ?? - (fallbackId === ROOT_PANE_ID ? createInitialRoot() : createInitialBottomRoot()) - ); +/** Root group a tree falls back to when Core removed its last group. */ +function createInitialTree(fallbackId: string): PaneGroup { + return fallbackId === ROOT_PANE_ID ? createInitialRoot() : createInitialBottomRoot(); } function addPaneIds(root: PaneNode, paneIds: Set) { @@ -279,19 +269,14 @@ const createPaneStore = () => state.mostRecentActivePaneIds, paneId, ); - let mergedTree = nextTree; - if (closingPane) { - for (const bufferId of closingPane.bufferIds) { - mergedTree = addBufferToPane(mergedTree, fallbackPaneId, bufferId, false); - } - if (state.activePaneId === paneId && closingPane.activeBufferId) { - mergedTree = setActivePaneBuffer( - mergedTree, + const mergedTree = closingPane + ? mergeClosedPaneInto( + nextTree, + closingPane, fallbackPaneId, - closingPane.activeBufferId, - ); - } - } + state.activePaneId === paneId, + ) + : nextTree; if (targetTree === "root") { state.root = mergedTree; @@ -358,22 +343,16 @@ const createPaneStore = () => set((state) => { const targetTree = getTreeForPane(state, paneId); if (targetTree === "root") { - state.root = removeBufferFromPane(state.root, paneId, bufferId); - if (!preserveEmptyPane) { - state.root = collapseEmptyPaneInTree(state.root, paneId, ROOT_PANE_ID); - } + state.root = + removeBufferFromPane(state.root, paneId, bufferId, !preserveEmptyPane) ?? + createInitialTree(ROOT_PANE_ID); if (state.activePaneId === paneId && !findPaneGroup(state.root, paneId)) { state.activePaneId = getFallbackActivePaneId(state); } } else { - state.bottomRoot = removeBufferFromPane(state.bottomRoot, paneId, bufferId); - if (!preserveEmptyPane) { - state.bottomRoot = collapseEmptyPaneInTree( - state.bottomRoot, - paneId, - BOTTOM_PANE_ID, - ); - } + state.bottomRoot = + removeBufferFromPane(state.bottomRoot, paneId, bufferId, !preserveEmptyPane) ?? + createInitialTree(BOTTOM_PANE_ID); if (state.activePaneId === paneId && !findPaneGroup(state.bottomRoot, paneId)) { state.activePaneId = getFallbackActivePaneId(state); } @@ -386,45 +365,34 @@ const createPaneStore = () => set((state) => { const fromTree = getTreeForPane(state, fromPaneId); const toTree = getTreeForPane(state, toPaneId); - + const fromRoot = fromTree === "root" ? state.root : state.bottomRoot; + const toRoot = toTree === "root" ? state.root : state.bottomRoot; + + // One Core call decides the whole transition, including which source group empties. + const moved = moveBufferBetweenPanes( + fromRoot, + toRoot, + bufferId, + fromPaneId, + toPaneId, + !preserveEmptySource, + ); + const nextFrom = + moved.fromRoot ?? + createInitialTree(fromTree === "root" ? ROOT_PANE_ID : BOTTOM_PANE_ID); if (fromTree === toTree) { - if (fromTree === "root") { - state.root = moveBufferBetweenPanes(state.root, bufferId, fromPaneId, toPaneId); - if (!preserveEmptySource) { - state.root = collapseEmptyPaneInTree(state.root, fromPaneId, ROOT_PANE_ID); - } - } else { - state.bottomRoot = moveBufferBetweenPanes( - state.bottomRoot, - bufferId, - fromPaneId, - toPaneId, - ); - if (!preserveEmptySource) { - state.bottomRoot = collapseEmptyPaneInTree( - state.bottomRoot, - fromPaneId, - BOTTOM_PANE_ID, - ); - } - } + if (fromTree === "root") state.root = nextFrom; + else state.bottomRoot = nextFrom; } else { + const nextTo = + moved.toRoot ?? + createInitialTree(toTree === "root" ? ROOT_PANE_ID : BOTTOM_PANE_ID); if (fromTree === "root") { - state.root = removeBufferFromPane(state.root, fromPaneId, bufferId); - if (!preserveEmptySource) { - state.root = collapseEmptyPaneInTree(state.root, fromPaneId, ROOT_PANE_ID); - } - state.bottomRoot = addBufferToPane(state.bottomRoot, toPaneId, bufferId, true); + state.root = nextFrom; + state.bottomRoot = nextTo; } else { - state.bottomRoot = removeBufferFromPane(state.bottomRoot, fromPaneId, bufferId); - if (!preserveEmptySource) { - state.bottomRoot = collapseEmptyPaneInTree( - state.bottomRoot, - fromPaneId, - BOTTOM_PANE_ID, - ); - } - state.root = addBufferToPane(state.root, toPaneId, bufferId, true); + state.bottomRoot = nextFrom; + state.root = nextTo; } } @@ -468,15 +436,20 @@ const createPaneStore = () => setBufferPinnedEverywhere: (bufferId, pinned) => { set((state) => { - state.root = setPaneBufferPinnedEverywhere(state.root, bufferId, pinned); - state.bottomRoot = setPaneBufferPinnedEverywhere(state.bottomRoot, bufferId, pinned); + [state.root, state.bottomRoot] = setPaneBufferPinnedEverywhere( + [state.root, state.bottomRoot], + bufferId, + pinned, + ); }); }, clearPreviewBufferEverywhere: (bufferId) => { set((state) => { - state.root = clearPanePreviewBufferEverywhere(state.root, bufferId); - state.bottomRoot = clearPanePreviewBufferEverywhere(state.bottomRoot, bufferId); + [state.root, state.bottomRoot] = clearPanePreviewBufferEverywhere( + [state.root, state.bottomRoot], + bufferId, + ); }); }, @@ -619,8 +592,7 @@ const createPaneStore = () => findPaneGroup(layout.bottomRoot, layout.fullscreenPaneId)) : null; - state.root = normalizePaneTree(layout.root); - state.bottomRoot = normalizePaneTree(layout.bottomRoot); + [state.root, state.bottomRoot] = normalizePaneTrees([layout.root, layout.bottomRoot]); state.mostRecentActivePaneIds = [ ...(layout.mostRecentActivePaneIds ?? []), layout.activePaneId, diff --git a/windows/tauri/src/features/panes/utils/pane-tree.test.ts b/windows/tauri/src/features/panes/utils/pane-tree.test.ts new file mode 100644 index 0000000000..642840ec4d --- /dev/null +++ b/windows/tauri/src/features/panes/utils/pane-tree.test.ts @@ -0,0 +1,296 @@ +import { beforeAll, describe, expect, test } from "bun:test"; +import { loadCoreRulesForTests } from "@/test-utils/core-rules"; +import type { PaneGroup, PaneNode, PaneSplit } from "../types/pane.types"; +import { + addBufferToPane, + clearPanePreviewBufferEverywhere, + createPaneGroup, + createPaneSplit, + findPaneGroup, + getAllPaneGroups, + mergeClosedPaneInto, + moveBufferBetweenPanes, + normalizePaneTree, + normalizePaneTrees, + removeBufferFromPane, + reorderPaneBuffers, + setActivePaneBuffer, + setPaneBufferPinned, + setPaneBufferPinnedEverywhere, + setPaneLocked, + setPanePreviewBuffer, +} from "./pane-tree"; + +// Member rules run through the real wasm build of lithe-core-rules (see workbench-state.test.ts +// for the shared fixture). These tests pin how the tree functions use them. +beforeAll(loadCoreRulesForTests); + +function group( + id: string, + bufferIds: string[], + activeBufferId: string | null = bufferIds[0] ?? null, + extra: Partial = {}, +): PaneGroup { + return { + id, + type: "group", + bufferIds, + activeBufferId, + mruBufferIds: activeBufferId + ? [activeBufferId, ...bufferIds.filter((id) => id !== activeBufferId)] + : bufferIds, + pinnedBufferIds: [], + previewBufferId: null, + ...extra, + }; +} + +function split(first: PaneNode, second: PaneNode, id = "split"): PaneSplit { + return { id, type: "split", direction: "horizontal", children: [first, second], sizes: [50, 50] }; +} + +function pane(root: PaneNode, id: string): PaneGroup { + const found = findPaneGroup(root, id); + if (!found) throw new Error(`pane ${id} missing`); + return found; +} + +describe("createPaneGroup", () => { + test("de-duplicates tabs and falls back to the first tab when the active one is unknown", () => { + const created = createPaneGroup(["a", "b", "a"], "ghost"); + expect(created.bufferIds).toEqual(["a", "b"]); + expect(created.activeBufferId).toBe("a"); + expect(created.mruBufferIds).toEqual(["a", "b"]); + }); + + test("puts the requested active tab first in the MRU order", () => { + const created = createPaneGroup(["a", "b", "c"], "c"); + expect(created.activeBufferId).toBe("c"); + expect(created.mruBufferIds).toEqual(["c", "a", "b"]); + }); + + test("an empty group has no selection", () => { + const created = createPaneGroup(); + expect(created).toMatchObject({ bufferIds: [], activeBufferId: null, mruBufferIds: [] }); + }); +}); + +describe("opening and activating tabs", () => { + test("opening an open tab selects it without duplicating it", () => { + const root = group("g1", ["a", "b"], "b"); + const next = addBufferToPane(root, "g1", "a") as PaneGroup; + expect(next.bufferIds).toEqual(["a", "b"]); + expect(next.activeBufferId).toBe("a"); + expect(next.mruBufferIds).toEqual(["a", "b"]); + }); + + test("opening in the background keeps the selection", () => { + const next = addBufferToPane(group("g1", ["a"]), "g1", "b", false) as PaneGroup; + expect(next.bufferIds).toEqual(["a", "b"]); + expect(next.activeBufferId).toBe("a"); + }); + + test("activating an unknown tab leaves the selection alone", () => { + const next = setActivePaneBuffer(group("g1", ["a", "b"], "b"), "g1", "ghost") as PaneGroup; + expect(next.activeBufferId).toBe("b"); + }); + + test("an unknown pane is a no-op that keeps the same tree", () => { + const root = group("g1", ["a"]); + expect(addBufferToPane(root, "nope", "b")).toBe(root); + }); +}); + +describe("closing tabs", () => { + test("closing the selected middle tab selects the tab that slid into its place", () => { + const next = removeBufferFromPane(group("g1", ["a", "b", "c"], "b"), "g1", "b") as PaneGroup; + expect(next.bufferIds).toEqual(["a", "c"]); + expect(next.activeBufferId).toBe("c"); + }); + + test("closing the selected last tab selects the new last tab", () => { + const next = removeBufferFromPane(group("g1", ["a", "b", "c"], "c"), "g1", "c") as PaneGroup; + expect(next.activeBufferId).toBe("b"); + }); + + test("closing drops pins and previews that named the tab", () => { + const root = group("g1", ["a", "b"], "a", { + pinnedBufferIds: ["a", "b"], + previewBufferId: "b", + }); + const next = removeBufferFromPane(root, "g1", "b") as PaneGroup; + expect(next.pinnedBufferIds).toEqual(["a"]); + expect(next.previewBufferId).toBeNull(); + }); + + test("an emptied pane stays when asked to be preserved", () => { + const root = split(group("g1", ["a"]), group("g2", ["b"])); + const next = removeBufferFromPane(root, "g1", "a", false) as PaneSplit; + expect(getAllPaneGroups(next).map((entry) => entry.id)).toEqual(["g1", "g2"]); + expect(pane(next, "g1").bufferIds).toEqual([]); + expect(pane(next, "g1").activeBufferId).toBeNull(); + }); + + test("an emptied pane is closed and its sibling takes the whole space", () => { + const sibling = group("g2", ["b"]); + const next = removeBufferFromPane(split(group("g1", ["a"]), sibling), "g1", "a", true); + expect(next).toBe(sibling); + }); + + test("closing the last tab of the only pane reports that the tree is gone", () => { + expect(removeBufferFromPane(group("g1", ["a"]), "g1", "a", true)).toBeNull(); + }); + + test("closing a tab the pane does not hold only repairs it", () => { + const root = group("g1", ["a", "b"], "ghost", { + pinnedBufferIds: ["ghost", "a", "a"], + previewBufferId: "ghost", + }); + const next = removeBufferFromPane(root, "g1", "zz") as PaneGroup; + expect(next).toMatchObject({ + bufferIds: ["a", "b"], + activeBufferId: "a", + pinnedBufferIds: ["a"], + previewBufferId: null, + }); + }); +}); + +describe("moving tabs between panes", () => { + test("selects the moved tab in the destination and the neighbour in the source", () => { + const root = split(group("g1", ["a", "b"], "a"), group("g2", ["c"])); + const { fromRoot, toRoot } = moveBufferBetweenPanes(root, root, "a", "g1", "g2", true); + expect(toRoot).toBe(fromRoot); + expect(pane(fromRoot!, "g1")).toMatchObject({ bufferIds: ["b"], activeBufferId: "b" }); + expect(pane(fromRoot!, "g2")).toMatchObject({ + bufferIds: ["c", "a"], + activeBufferId: "a", + mruBufferIds: ["a", "c"], + }); + }); + + test("moving the last tab closes the source pane unless it is preserved", () => { + const target = group("g2", ["c"]); + const root = split(group("g1", ["a"]), target); + const closed = moveBufferBetweenPanes(root, root, "a", "g1", "g2", true).fromRoot!; + expect(closed.type).toBe("group"); + expect((closed as PaneGroup).id).toBe("g2"); + expect((closed as PaneGroup).bufferIds).toEqual(["c", "a"]); + + const kept = moveBufferBetweenPanes(root, root, "a", "g1", "g2", false).fromRoot!; + expect(getAllPaneGroups(kept).map((entry) => entry.id)).toEqual(["g1", "g2"]); + expect(pane(kept, "g1").bufferIds).toEqual([]); + }); + + test("moves a tab across two trees with the source tree losing its last pane", () => { + const rootTree = group("g1", ["a"]); + const bottomTree = group("g2", ["t"]); + const moved = moveBufferBetweenPanes(rootTree, bottomTree, "a", "g1", "g2", true); + expect(moved.fromRoot).toBeNull(); + expect((moved.toRoot as PaneGroup).bufferIds).toEqual(["t", "a"]); + expect((moved.toRoot as PaneGroup).activeBufferId).toBe("a"); + }); + + test("moving to a pane that does not exist keeps the tab where it is", () => { + const root = group("g1", ["a", "b"]); + const moved = moveBufferBetweenPanes(root, root, "a", "g1", "ghost", true); + expect(moved.fromRoot).toBe(root); + }); +}); + +describe("pins, previews, locking and order", () => { + test("pinning promotes a previewed tab and previewing releases a pin", () => { + const pinned = setPaneBufferPinned( + group("g1", ["a", "b"], "a", { previewBufferId: "b" }), + "g1", + "b", + true, + ) as PaneGroup; + expect(pinned.pinnedBufferIds).toEqual(["b"]); + expect(pinned.previewBufferId).toBeNull(); + + const previewed = setPanePreviewBuffer(pinned, "g1", "b") as PaneGroup; + expect(previewed.pinnedBufferIds).toEqual([]); + expect(previewed.previewBufferId).toBe("b"); + }); + + test("pins and previews touch only the groups that hold the tab", () => { + const untouched = group("g3", ["b"]); + const root = split( + group("g1", ["a", "b"], "a", { previewBufferId: "a" }), + split(group("g2", ["a"]), untouched, "inner"), + ); + const [pinned] = setPaneBufferPinnedEverywhere([root], "a", true); + expect(pane(pinned, "g1")).toMatchObject({ pinnedBufferIds: ["a"], previewBufferId: null }); + expect(pane(pinned, "g2").pinnedBufferIds).toEqual(["a"]); + expect(pane(pinned, "g3")).toBe(untouched); + + const previewing = split( + group("g1", ["a"], "a", { previewBufferId: "a" }), + group("g2", ["a"], "a", { previewBufferId: "a" }), + ); + const [cleared] = clearPanePreviewBufferEverywhere([previewing], "a"); + expect(getAllPaneGroups(cleared).map((entry) => entry.previewBufferId)).toEqual([null, null]); + }); + + test("locking a pane keeps its tabs and sets the flag", () => { + const next = setPaneLocked(group("g1", ["a"]), "g1", true) as PaneGroup; + expect(next.locked).toBe(true); + expect(next.bufferIds).toEqual(["a"]); + }); + + test("reordering moves one tab and ignores out-of-range indexes", () => { + const root = group("g1", ["a", "b", "c"], "b"); + const moved = reorderPaneBuffers(root, "g1", 0, 2) as PaneGroup; + expect(moved.bufferIds).toEqual(["b", "c", "a"]); + expect(moved.activeBufferId).toBe("b"); + expect(reorderPaneBuffers(root, "g1", -1, 1)).toBe(root); + expect(reorderPaneBuffers(root, "g1", 0, 3)).toBe(root); + }); +}); + +describe("closing a pane merges its tabs", () => { + test("appends the tabs and shows the closed pane's tab only when asked", () => { + const closing = group("g1", ["a", "b"], "b"); + const target = group("g2", ["c"]); + const shown = mergeClosedPaneInto(target, closing, "g2", true) as PaneGroup; + expect(shown.bufferIds).toEqual(["c", "a", "b"]); + expect(shown.activeBufferId).toBe("b"); + expect(shown.mruBufferIds).toEqual(["b", "c", "a"]); + + const background = mergeClosedPaneInto(target, closing, "g2", false) as PaneGroup; + expect(background.activeBufferId).toBe("c"); + }); +}); + +describe("normalizing trees", () => { + test("repairs every group of several trees and also the split ratios", () => { + const broken = split( + group("g1", ["a", "a", "b"], "ghost", { + mruBufferIds: ["ghost", "b"], + pinnedBufferIds: ["ghost", "b", "b"], + previewBufferId: "ghost", + }), + group("g2", []), + ); + broken.sizes = [0, 100]; + const [fixed, other] = normalizePaneTrees([broken, group("g3", ["x", "x"], "x")]); + expect(pane(fixed, "g1")).toMatchObject({ + bufferIds: ["a", "b"], + activeBufferId: "a", + mruBufferIds: ["a", "b"], + pinnedBufferIds: ["b"], + previewBufferId: null, + }); + expect(pane(fixed, "g2")).toMatchObject({ bufferIds: [], activeBufferId: null }); + expect((fixed as PaneSplit).sizes[0]).toBeGreaterThan(0); + expect((other as PaneGroup).bufferIds).toEqual(["x"]); + expect(normalizePaneTree(group("g4", ["y", "y"]))).toMatchObject({ bufferIds: ["y"] }); + }); + + test("new splits get a normalized group", () => { + const created = createPaneGroup(["a", "a"], "a"); + const tree = createPaneSplit("horizontal", group("g1", ["b"]), created); + expect(getAllPaneGroups(tree)[1].bufferIds).toEqual(["a"]); + }); +}); diff --git a/windows/tauri/src/features/panes/utils/pane-tree.ts b/windows/tauri/src/features/panes/utils/pane-tree.ts index f4ad0ef888..0a1778b3a3 100644 --- a/windows/tauri/src/features/panes/utils/pane-tree.ts +++ b/windows/tauri/src/features/panes/utils/pane-tree.ts @@ -7,6 +7,7 @@ import type { SplitDirection, SplitPlacement, } from "../types/pane.types"; +import { reduceWorkbenchGroups, type WorkbenchAction } from "./workbench-state"; export interface FlatPaneEntry { node: PaneNode; @@ -14,24 +15,20 @@ export interface FlatPaneEntry { path: Array<{ splitId: string; childIndex: 0 | 1 }>; } +/** Builds a group whose members, selection and MRU order Core has already made consistent. */ export function createPaneGroup( bufferIds: string[] = [], activeBufferId: string | null = null, ): PaneGroup { - const uniqueBufferIds = dedupe(bufferIds); - const safeActiveBufferId = - activeBufferId && uniqueBufferIds.includes(activeBufferId) - ? activeBufferId - : (uniqueBufferIds[0] ?? null); - + const draft: PaneGroup = { id: nanoid(), type: "group", bufferIds, activeBufferId }; + const { groups } = reduceWorkbenchGroups([draft], { kind: "normalize" }); + const [group] = groups; return { - id: nanoid(), + id: draft.id, type: "group", - bufferIds: uniqueBufferIds, - activeBufferId: safeActiveBufferId, - mruBufferIds: safeActiveBufferId - ? [safeActiveBufferId, ...uniqueBufferIds.filter((id) => id !== safeActiveBufferId)] - : uniqueBufferIds, + bufferIds: group.bufferIds, + activeBufferId: group.activeBufferId, + mruBufferIds: group.mruBufferIds, }; } @@ -51,10 +48,6 @@ export function createPaneSplit( }; } -function dedupe(items: T[]): T[] { - return Array.from(new Set(items)); -} - function normalizeSplitSizes(sizes: [number, number]): [number, number] { const first = Number.isFinite(sizes[0]) ? sizes[0] : DEFAULT_SPLIT_RATIO[0]; const second = Number.isFinite(sizes[1]) ? sizes[1] : DEFAULT_SPLIT_RATIO[1]; @@ -103,66 +96,86 @@ function updatePaneNode( return root; } -function normalizeGroup(group: PaneGroup): PaneGroup { - const bufferIdSet = new Set(group.bufferIds); - const bufferIds = Array.from(bufferIdSet); - const activeBufferId = - group.activeBufferId && bufferIdSet.has(group.activeBufferId) - ? group.activeBufferId - : (bufferIds[0] ?? null); - - const seenMruBufferIds = new Set(); - const mruBufferIds: string[] = []; - const appendMruBufferId = (bufferId: string) => { - if (!bufferIdSet.has(bufferId) || seenMruBufferIds.has(bufferId)) return; - seenMruBufferIds.add(bufferId); - mruBufferIds.push(bufferId); - }; +function mapPaneGroups(root: PaneNode, update: (group: PaneGroup) => PaneGroup): PaneNode { + if (root.type === "group") { + return update(root); + } - if (activeBufferId) { - appendMruBufferId(activeBufferId); + const first = mapPaneGroups(root.children[0], update); + const second = mapPaneGroups(root.children[1], update); + if (first === root.children[0] && second === root.children[1]) { + return root; } - for (const bufferId of group.mruBufferIds ?? []) { - appendMruBufferId(bufferId); + return { ...root, children: [first, second] }; +} + +/** + * Runs one Core action over every group of `roots` that `involves` selects, in a single Core + * call, and writes the returned groups back. Groups Core removed are reported for the caller to + * prune from its layout (see {@link removePaneGroups}); they are left in the trees until then. + */ +function reduceGroupsInTrees( + roots: PaneNode[], + involves: (group: PaneGroup) => boolean, + action: WorkbenchAction, +): { roots: PaneNode[]; removedGroupIds: string[] } { + const selected = new Map(); + for (const root of roots) { + for (const group of getAllPaneGroups(root)) { + if (involves(group)) selected.set(group.id, group); + } } - for (const bufferId of bufferIds) { - appendMruBufferId(bufferId); + if (selected.size === 0) { + return { roots, removedGroupIds: [] }; } - const seenPinnedBufferIds = new Set(); - const pinnedBufferIds: string[] = []; - for (const bufferId of group.pinnedBufferIds ?? []) { - if (bufferIdSet.has(bufferId) && !seenPinnedBufferIds.has(bufferId)) { - seenPinnedBufferIds.add(bufferId); - pinnedBufferIds.push(bufferId); - } + const { groups, removedGroupIds } = reduceWorkbenchGroups([...selected.values()], action); + const next = new Map(groups.map((group) => [group.id, group])); + return { + roots: roots.map((root) => mapPaneGroups(root, (group) => next.get(group.id) ?? group)), + removedGroupIds, + }; +} + +/** Closes the layout nodes of groups Core removed; null means the tree has no group left. */ +export function removePaneGroups(root: PaneNode, paneIds: string[]): PaneNode | null { + let next: PaneNode | null = root; + for (const paneId of paneIds) { + if (!next) break; + next = closePane(next, paneId); } + return next; +} - const previewBufferId = - group.previewBufferId && bufferIdSet.has(group.previewBufferId) ? group.previewBufferId : null; +function reduceGroupInTree(root: PaneNode, paneId: string, action: WorkbenchAction): PaneNode { + return reduceGroupsInTrees([root], (group) => group.id === paneId, action).roots[0]; +} - return { - ...group, - bufferIds, - activeBufferId, - mruBufferIds, - previewBufferId, - pinnedBufferIds, - }; +/** Normalizes the groups of several trees with one Core call (split ratios are normalized here). */ +export function normalizePaneTrees(roots: PaneNode[]): PaneNode[] { + const { roots: normalized } = reduceGroupsInTrees(roots, () => true, { kind: "normalize" }); + return normalized.map(normalizeSplitSizesInTree); } -export function normalizePaneTree(root: PaneNode): PaneNode { +function normalizeSplitSizesInTree(root: PaneNode): PaneNode { if (root.type === "group") { - return normalizeGroup(root); + return root; } return { ...root, sizes: normalizeSplitSizes(root.sizes), - children: [normalizePaneTree(root.children[0]), normalizePaneTree(root.children[1])], + children: [ + normalizeSplitSizesInTree(root.children[0]), + normalizeSplitSizesInTree(root.children[1]), + ], }; } +export function normalizePaneTree(root: PaneNode): PaneNode { + return normalizePaneTrees([root])[0]; +} + export function flattenPaneSplit( split: PaneSplit, parentSize: number = 100, @@ -393,71 +406,85 @@ export function addBufferToPane( bufferId: string, setActive: boolean = true, ): PaneNode { - return updatePaneNode(root, paneId, (node) => { - if (node.type !== "group") { - return node; - } - - const nextActiveBufferId = setActive ? bufferId : node.activeBufferId; - if (node.bufferIds.includes(bufferId)) { - return normalizeGroup({ - ...node, - activeBufferId: nextActiveBufferId, - mruBufferIds: setActive - ? [bufferId, ...(node.mruBufferIds ?? node.bufferIds).filter((id) => id !== bufferId)] - : node.mruBufferIds, - }); - } - return normalizeGroup({ - ...node, - bufferIds: [...node.bufferIds, bufferId], - activeBufferId: nextActiveBufferId, - mruBufferIds: setActive - ? [bufferId, ...(node.mruBufferIds ?? node.bufferIds)] - : node.mruBufferIds, - }); + return reduceGroupInTree(root, paneId, { + kind: "open", + group: paneId, + member: bufferId, + activate: setActive, }); } -export function removeBufferFromPane(root: PaneNode, paneId: string, bufferId: string): PaneNode { - return updatePaneNode(root, paneId, (node) => { - if (node.type !== "group") { - return node; - } - - const newBufferIds = node.bufferIds.filter((id) => id !== bufferId); - let newActiveBufferId = node.activeBufferId; - - if (node.activeBufferId === bufferId) { - const currentIndex = node.bufferIds.indexOf(bufferId); - if (newBufferIds.length > 0) { - const newIndex = Math.min(currentIndex, newBufferIds.length - 1); - newActiveBufferId = newBufferIds[newIndex]; - } else { - newActiveBufferId = null; - } - } - - return normalizeGroup({ - ...node, - bufferIds: newBufferIds, - activeBufferId: newActiveBufferId, - mruBufferIds: (node.mruBufferIds ?? node.bufferIds).filter((id) => id !== bufferId), - pinnedBufferIds: node.pinnedBufferIds?.filter((id) => id !== bufferId), - previewBufferId: node.previewBufferId === bufferId ? null : node.previewBufferId, - }); +/** + * Removes a tab from a group. With `pruneEmptyPane` the group is closed when it becomes empty; + * null means that was the tree's only group and the caller supplies a fresh root. + */ +export function removeBufferFromPane( + root: PaneNode, + paneId: string, + bufferId: string, + pruneEmptyPane: boolean = false, +): PaneNode | null { + const reduced = reduceGroupsInTrees([root], (group) => group.id === paneId, { + kind: "close", + group: paneId, + member: bufferId, + emptyGroups: pruneEmptyPane ? "remove" : "keep", + selectionFallback: "adjacent", }); + return removePaneGroups(reduced.roots[0], reduced.removedGroupIds); } +/** + * Moves a tab between two groups, which may live in different trees (`fromRoot`, `toRoot`; the + * same object when they share one). Core does the whole transition in one call. A null root means + * that tree lost its last group. + */ export function moveBufferBetweenPanes( - root: PaneNode, + fromRoot: PaneNode, + toRoot: PaneNode, bufferId: string, fromPaneId: string, toPaneId: string, + pruneEmptySource: boolean = true, +): { fromRoot: PaneNode | null; toRoot: PaneNode | null } { + const sameTree = fromRoot === toRoot; + const reduced = reduceGroupsInTrees( + sameTree ? [fromRoot] : [fromRoot, toRoot], + (group) => group.id === fromPaneId || group.id === toPaneId, + { + kind: "move", + member: bufferId, + from: fromPaneId, + to: toPaneId, + activate: true, + emptyGroups: pruneEmptySource ? "remove" : "keep", + selectionFallback: "adjacent", + }, + ); + const next = reduced.roots.map((root) => removePaneGroups(root, reduced.removedGroupIds)); + return { fromRoot: next[0], toRoot: sameTree ? next[0] : next[1] }; +} + +/** + * Folds the tabs of a group that was just closed into another group of `root` and optionally + * shows the tab the closed group was showing. + */ +export function mergeClosedPaneInto( + root: PaneNode, + closedPane: PaneGroup, + intoPaneId: string, + activateSelected: boolean, ): PaneNode { - let result = removeBufferFromPane(root, fromPaneId, bufferId); - result = addBufferToPane(result, toPaneId, bufferId, true); - return result; + const target = findPaneGroup(root, intoPaneId); + if (!target) return root; + const { groups } = reduceWorkbenchGroups([closedPane, target], { + kind: "mergeGroup", + from: closedPane.id, + into: intoPaneId, + activateSelected, + }); + const merged = groups.find((group) => group.id === intoPaneId); + return merged ? mapPaneGroups(root, (group) => (group.id === intoPaneId ? merged : group)) : root; } export function setActivePaneBuffer( @@ -465,23 +492,7 @@ export function setActivePaneBuffer( paneId: string, bufferId: string | null, ): PaneNode { - return updatePaneNode(root, paneId, (node) => { - if (node.type !== "group") { - return node; - } - - if (bufferId && !node.bufferIds.includes(bufferId)) { - return normalizeGroup(node); - } - - return normalizeGroup({ - ...node, - activeBufferId: bufferId, - mruBufferIds: bufferId - ? [bufferId, ...(node.mruBufferIds ?? node.bufferIds).filter((id) => id !== bufferId)] - : node.mruBufferIds, - }); - }); + return reduceGroupInTree(root, paneId, { kind: "activate", group: paneId, member: bufferId }); } export function setPanePreviewBuffer( @@ -489,48 +500,15 @@ export function setPanePreviewBuffer( paneId: string, bufferId: string | null, ): PaneNode { - return updatePaneNode(root, paneId, (node) => { - if (node.type !== "group") { - return node; - } - - if (bufferId && !node.bufferIds.includes(bufferId)) { - return normalizeGroup(node); - } - - return normalizeGroup({ - ...node, - previewBufferId: bufferId, - pinnedBufferIds: bufferId - ? node.pinnedBufferIds?.filter((pinnedBufferId) => pinnedBufferId !== bufferId) - : node.pinnedBufferIds, - }); - }); + return reduceGroupInTree(root, paneId, { kind: "setPreview", group: paneId, member: bufferId }); } -export function clearPanePreviewBufferEverywhere(root: PaneNode, bufferId: string): PaneNode { - if (root.type === "group") { - if (root.previewBufferId !== bufferId) { - return root; - } - - return normalizeGroup({ - ...root, - previewBufferId: null, - }); - } - - const first = clearPanePreviewBufferEverywhere(root.children[0], bufferId); - const second = clearPanePreviewBufferEverywhere(root.children[1], bufferId); - - if (first === root.children[0] && second === root.children[1]) { - return root; - } - - return { - ...root, - children: [first, second], - }; +/** Clears a preview of `bufferId` in every group of every tree, with one Core call. */ +export function clearPanePreviewBufferEverywhere(roots: PaneNode[], bufferId: string): PaneNode[] { + return reduceGroupsInTrees(roots, (group) => group.previewBufferId === bufferId, { + kind: "clearPreviewEverywhere", + member: bufferId, + }).roots; } export function setPaneBufferPinned( @@ -539,72 +517,30 @@ export function setPaneBufferPinned( bufferId: string, pinned: boolean, ): PaneNode { - return updatePaneNode(root, paneId, (node) => { - if (node.type !== "group") { - return node; - } - - if (!node.bufferIds.includes(bufferId)) { - return normalizeGroup(node); - } - - const currentPinnedBufferIds = node.pinnedBufferIds ?? []; - const pinnedBufferIds = pinned - ? [...currentPinnedBufferIds, bufferId] - : currentPinnedBufferIds.filter((pinnedBufferId) => pinnedBufferId !== bufferId); - - return normalizeGroup({ - ...node, - pinnedBufferIds, - previewBufferId: pinned && node.previewBufferId === bufferId ? null : node.previewBufferId, - }); + return reduceGroupInTree(root, paneId, { + kind: "setPinned", + group: paneId, + member: bufferId, + pinned, }); } +/** Pins or unpins `bufferId` in every group that lists it, across trees, with one Core call. */ export function setPaneBufferPinnedEverywhere( - root: PaneNode, + roots: PaneNode[], bufferId: string, pinned: boolean, -): PaneNode { - if (root.type === "group") { - if (!root.bufferIds.includes(bufferId)) { - return root; - } - - const currentPinnedBufferIds = root.pinnedBufferIds ?? []; - const pinnedBufferIds = pinned - ? [...currentPinnedBufferIds, bufferId] - : currentPinnedBufferIds.filter((pinnedBufferId) => pinnedBufferId !== bufferId); - - return normalizeGroup({ - ...root, - pinnedBufferIds, - previewBufferId: pinned && root.previewBufferId === bufferId ? null : root.previewBufferId, - }); - } - - const first = setPaneBufferPinnedEverywhere(root.children[0], bufferId, pinned); - const second = setPaneBufferPinnedEverywhere(root.children[1], bufferId, pinned); - - if (first === root.children[0] && second === root.children[1]) { - return root; - } - - return { - ...root, - children: [first, second], - }; +): PaneNode[] { + return reduceGroupsInTrees(roots, (group) => group.bufferIds.includes(bufferId), { + kind: "setPinnedEverywhere", + member: bufferId, + pinned, + }).roots; } export function setPaneLocked(root: PaneNode, paneId: string, locked: boolean): PaneNode { - return updatePaneNode(root, paneId, (node) => - node.type === "group" - ? normalizeGroup({ - ...node, - locked, - }) - : node, - ); + const normalized = reduceGroupInTree(root, paneId, { kind: "normalize" }); + return mapPaneGroups(normalized, (group) => (group.id === paneId ? { ...group, locked } : group)); } export function reorderPaneBuffers( @@ -613,29 +549,11 @@ export function reorderPaneBuffers( startIndex: number, endIndex: number, ): PaneNode { - return updatePaneNode(root, paneId, (node) => { - if (node.type !== "group") { - return node; - } - - if ( - startIndex < 0 || - endIndex < 0 || - startIndex >= node.bufferIds.length || - endIndex >= node.bufferIds.length || - startIndex === endIndex - ) { - return node; - } - - const nextBufferIds = [...node.bufferIds]; - const [movedBufferId] = nextBufferIds.splice(startIndex, 1); - nextBufferIds.splice(endIndex, 0, movedBufferId); - - return { - ...node, - bufferIds: nextBufferIds, - }; + return reduceGroupInTree(root, paneId, { + kind: "reorder", + group: paneId, + fromIndex: startIndex, + toIndex: endIndex, }); } diff --git a/windows/tauri/src/features/panes/utils/workbench-state.test.ts b/windows/tauri/src/features/panes/utils/workbench-state.test.ts new file mode 100644 index 0000000000..e080137da2 --- /dev/null +++ b/windows/tauri/src/features/panes/utils/workbench-state.test.ts @@ -0,0 +1,90 @@ +import { beforeAll, describe, expect, test } from "bun:test"; +import fixture from "../../../../../../shared/fixtures/editor/workbench-state-v1.json"; +import { executeCoreRuleSync } from "@/core/core-rules"; +import { loadCoreRulesForTests } from "@/test-utils/core-rules"; +import type { PaneGroup } from "../types/pane.types"; +import { reduceWorkbenchGroups, WORKBENCH_STATE_REDUCE_COMMAND } from "./workbench-state"; + +// The same fixture drives the Rust tests in lithe-core-rules and lithe-core and the macOS +// adapter test, so every host sees one definition of group membership. Here it runs through the +// real wasm build that the app ships. +beforeAll(loadCoreRulesForTests); + +describe("workbench state rules (shared fixture through wasm)", () => { + for (const entry of fixture.cases) { + test(entry.name, () => { + const result = executeCoreRuleSync(WORKBENCH_STATE_REDUCE_COMMAND, { + state: entry.state, + action: entry.action, + }); + expect(result).toEqual({ + state: entry.expected.state, + removedGroupIds: entry.expected.removedGroupIds, + }); + }); + } + + test("malformed requests fail with the Core error code", () => { + expect(() => + executeCoreRuleSync(WORKBENCH_STATE_REDUCE_COMMAND, { action: { kind: "bogus" } }), + ).toThrow(expect.objectContaining({ code: "invalid_request" })); + }); +}); + +describe("pane group adapter", () => { + const group = (overrides: Partial): PaneGroup => ({ + id: "g1", + type: "group", + bufferIds: [], + activeBufferId: null, + ...overrides, + }); + + test("keeps fields Core does not own and falls back to tab order for a missing MRU", () => { + const { groups } = reduceWorkbenchGroups( + [group({ bufferIds: ["a", "b"], activeBufferId: "a", locked: true })], + { kind: "open", group: "g1", member: "c", activate: false }, + ); + expect(groups).toEqual([ + { + id: "g1", + type: "group", + bufferIds: ["a", "b", "c"], + activeBufferId: "a", + mruBufferIds: ["a", "b", "c"], + pinnedBufferIds: [], + previewBufferId: null, + locked: true, + }, + ]); + }); + + test("returns the same object when Core changes nothing", () => { + const original = group({ + bufferIds: ["a", "b"], + activeBufferId: "a", + mruBufferIds: ["a", "b"], + pinnedBufferIds: [], + previewBufferId: null, + }); + const { groups } = reduceWorkbenchGroups([original], { + kind: "reorder", + group: "g1", + fromIndex: 0, + toIndex: 9, + }); + expect(groups[0]).toBe(original); + }); + + test("reports removed groups and drops them from the result", () => { + const { groups, removedGroupIds } = reduceWorkbenchGroups( + [ + group({ id: "g1", bufferIds: ["a"], activeBufferId: "a" }), + group({ id: "g2", bufferIds: ["b"], activeBufferId: "b" }), + ], + { kind: "close", group: "g1", member: "a", emptyGroups: "remove" }, + ); + expect(removedGroupIds).toEqual(["g1"]); + expect(groups.map((entry) => entry.id)).toEqual(["g2"]); + }); +}); diff --git a/windows/tauri/src/features/panes/utils/workbench-state.ts b/windows/tauri/src/features/panes/utils/workbench-state.ts new file mode 100644 index 0000000000..cf7c6f7364 --- /dev/null +++ b/windows/tauri/src/features/panes/utils/workbench-state.ts @@ -0,0 +1,126 @@ +/** + * Thin adapter between pane groups and Core's `editor.workbenchState.reduce`. + * + * Membership and selection rules (de-duplication, which tab is selected after an open, close or + * move, MRU order, pins, previews, and which groups become empty) live in + * rust/lithe-core-rules and run here through the synchronous wasm bridge, because pane state is + * updated inside zustand `set()` calls that cannot await. This file only converts a `PaneGroup` + * to the canonical group and back; it never decides anything about membership itself. + * + * One call to {@link reduceWorkbenchGroups} is one Core round trip. Callers send every group an + * action involves in that single call. Split directions, ratios and dragging stay in + * `pane-tree.ts` and never reach Core. + * + * Note: .agents/notes/implemented/architecture/2026-11-05-core-rules-wasm-sync-bridge.md + */ +import isEqual from "fast-deep-equal"; +import { executeCoreRuleSync } from "@/core/core-rules"; +import type { PaneGroup } from "../types/pane.types"; + +export const WORKBENCH_STATE_REDUCE_COMMAND = "editor.workbenchState.reduce"; + +/** What Core does with a group that an action left empty. */ +export type EmptyGroups = "keep" | "remove" | "removeKeepingFirst"; + +/** How Core picks the next selection when the selected tab leaves a group. */ +export type SelectionFallback = "adjacent" | "first"; + +export type WorkbenchAction = + | { kind: "normalize"; emptyGroups?: EmptyGroups; unplaced?: string[] } + | { kind: "open"; group: string; member: string; activate?: boolean } + | { + kind: "close"; + group: string; + member: string; + emptyGroups?: EmptyGroups; + selectionFallback?: SelectionFallback; + } + | { kind: "activate"; group: string; member: string | null } + | { + kind: "move"; + member: string; + from: string; + to: string; + before?: string; + after?: string; + activate?: boolean; + emptyGroups?: EmptyGroups; + selectionFallback?: SelectionFallback; + } + | { kind: "reorder"; group: string; fromIndex: number; toIndex: number } + | { kind: "setPinned"; group: string; member: string; pinned: boolean } + | { kind: "setPinnedEverywhere"; member: string; pinned: boolean } + | { kind: "setPreview"; group: string; member: string | null } + | { kind: "clearPreviewEverywhere"; member: string } + | { kind: "mergeGroup"; from: string; into: string; activateSelected?: boolean }; + +interface CoreGroup { + id: string; + members: string[]; + selected: string | null; + mru: string[]; + pinned: string[]; + preview: string | null; +} + +interface CoreReduction { + state: { groups: CoreGroup[] }; + removedGroupIds: string[]; +} + +export interface WorkbenchReduction { + /** Groups Core returned, in the order they were sent, minus the removed ones. */ + groups: PaneGroup[]; + /** Groups Core removed because an action emptied them; the caller prunes its layout tree. */ + removedGroupIds: string[]; +} + +function toCoreGroup(group: PaneGroup): CoreGroup { + return { + id: group.id, + members: [...group.bufferIds], + selected: group.activeBufferId, + // A group that never recorded recency is ordered by its tabs. + mru: [...(group.mruBufferIds ?? group.bufferIds)], + pinned: [...(group.pinnedBufferIds ?? [])], + preview: group.previewBufferId ?? null, + }; +} + +function fromCoreGroup(previous: PaneGroup, core: CoreGroup): PaneGroup { + const unchanged = + isEqual(previous.bufferIds, core.members) && + previous.activeBufferId === core.selected && + isEqual(previous.mruBufferIds, core.mru) && + isEqual(previous.pinnedBufferIds, core.pinned) && + (previous.previewBufferId ?? null) === core.preview; + if (unchanged) return previous; + return { + ...previous, + bufferIds: core.members, + activeBufferId: core.selected, + mruBufferIds: core.mru, + previewBufferId: core.preview, + pinnedBufferIds: core.pinned, + }; +} + +/** + * Applies one action to `groups` with a single synchronous Core call. Groups keep their + * non-membership fields (`locked`, ids) because only the canonical fields cross the boundary. + */ +export function reduceWorkbenchGroups( + groups: PaneGroup[], + action: WorkbenchAction, +): WorkbenchReduction { + if (groups.length === 0) return { groups: [], removedGroupIds: [] }; + const result = executeCoreRuleSync(WORKBENCH_STATE_REDUCE_COMMAND, { + state: { groups: groups.map(toCoreGroup) }, + action, + }); + const previousById = new Map(groups.map((group) => [group.id, group])); + return { + groups: result.state.groups.map((core) => fromCoreGroup(previousById.get(core.id)!, core)), + removedGroupIds: result.removedGroupIds, + }; +} diff --git a/windows/tauri/src/features/window/stores/workspace-pane-session.test.ts b/windows/tauri/src/features/window/stores/workspace-pane-session.test.ts new file mode 100644 index 0000000000..bf6278e8e3 --- /dev/null +++ b/windows/tauri/src/features/window/stores/workspace-pane-session.test.ts @@ -0,0 +1,49 @@ +import { beforeAll, describe, expect, test } from "bun:test"; +import type { PaneContent } from "@/features/panes/types/pane-content.types"; +import type { PaneGroup } from "@/features/panes/types/pane.types"; +import { normalizePaneTrees } from "@/features/panes/utils/pane-tree"; +import { loadCoreRulesForTests } from "@/test-utils/core-rules"; +import { buildPaneLayoutFromSession } from "./workspace-pane-session"; + +// Pane groups are repaired by Core when the layout is restored (see pane.store restoreLayout), so +// the session builder only has to hand over the buffers in order. +beforeAll(loadCoreRulesForTests); + +function terminal(id: string, isActive = false): PaneContent { + return { id, type: "terminal", path: `terminal:${id}`, isActive } as unknown as PaneContent; +} + +describe("buildPaneLayoutFromSession", () => { + test("puts buffers missing from the saved layout in the root pane with the active one selected", () => { + const layout = buildPaneLayoutFromSession(null, [ + terminal("a"), + terminal("b", true), + terminal("c"), + ]); + const [root] = normalizePaneTrees([layout.root]); + expect(root).toMatchObject({ + bufferIds: ["b", "a", "c"], + activeBufferId: "b", + mruBufferIds: ["b", "a", "c"], + } satisfies Partial); + }); + + test("drops saved paths that no longer resolve and keeps the saved selection", () => { + const layout = buildPaneLayoutFromSession( + { + root: { + id: "root", + type: "group", + bufferPaths: ["terminal:a", "terminal:gone", "terminal:b"], + activeBufferPath: "terminal:b", + }, + bottomRoot: { id: "bottom", type: "group", bufferPaths: [] }, + activePaneId: "root", + fullscreenPaneId: null, + } as unknown as Parameters[0], + [terminal("a"), terminal("b")], + ); + const [root] = normalizePaneTrees([layout.root]); + expect(root).toMatchObject({ bufferIds: ["a", "b"], activeBufferId: "b" }); + }); +}); diff --git a/windows/tauri/src/features/window/stores/workspace-pane-session.ts b/windows/tauri/src/features/window/stores/workspace-pane-session.ts index 1d096fcf45..d26218c8b3 100644 --- a/windows/tauri/src/features/window/stores/workspace-pane-session.ts +++ b/windows/tauri/src/features/window/stores/workspace-pane-session.ts @@ -134,18 +134,18 @@ const hydratePaneNode = ( }; }; -const addBuffersToPaneNode = ( - node: PaneNode, - paneId: string, - bufferIds: string[], - setActiveWhenEmpty: boolean, -): PaneNode => { +/** + * Appends buffers to one pane in the order given. De-duplication, the selection of an empty pane + * and the MRU order are not decided here: the layout goes through `restoreLayout`, which has Core + * normalize every group. + */ +const addBuffersToPaneNode = (node: PaneNode, paneId: string, bufferIds: string[]): PaneNode => { if (node.type === "split") { return { ...node, children: [ - addBuffersToPaneNode(node.children[0], paneId, bufferIds, setActiveWhenEmpty), - addBuffersToPaneNode(node.children[1], paneId, bufferIds, setActiveWhenEmpty), + addBuffersToPaneNode(node.children[0], paneId, bufferIds), + addBuffersToPaneNode(node.children[1], paneId, bufferIds), ], }; } @@ -154,21 +154,7 @@ const addBuffersToPaneNode = ( return node; } - const nextBufferIds = unique([...node.bufferIds, ...bufferIds]); - const shouldSetActive = setActiveWhenEmpty && !node.activeBufferId && node.bufferIds.length === 0; - const activeBufferId = shouldSetActive ? (bufferIds[0] ?? null) : node.activeBufferId; - const nextBufferIdSet = new Set(nextBufferIds); - - return { - ...node, - bufferIds: nextBufferIds, - activeBufferId, - mruBufferIds: unique([ - ...(activeBufferId ? [activeBufferId] : []), - ...(node.mruBufferIds ?? []), - ...nextBufferIds, - ]).filter((bufferId) => nextBufferIdSet.has(bufferId)), - }; + return { ...node, bufferIds: [...node.bufferIds, ...bufferIds] }; }; const attachMissingBuffersToLayout = ( @@ -202,7 +188,7 @@ const attachMissingBuffersToLayout = ( return { ...layout, - root: addBuffersToPaneNode(layout.root, targetPaneId, orderedMissingBufferIds, true), + root: addBuffersToPaneNode(layout.root, targetPaneId, orderedMissingBufferIds), }; }; From 1e1d40ff4fb8c726c648b5196317a42435c7ccb4 Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Thu, 5 Nov 2026 19:25:03 -0800 Subject: [PATCH 5/6] =?UTF-8?q?WS-04=20=E9=9A=90=E8=97=8F=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=E8=A7=84=E5=88=99=E7=BB=9F=E4=B8=80=E8=BF=9B=20lithe-?= =?UTF-8?q?core-rules=20(#1662)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 workspace.classifyVisibility(按一批路径一次判断)和 workspace.visibilityDefaults,默认列表、glob 匹配、目录/文件区别只在 rust/lithe-core-rules/src/visibility.rs 一份;lithe-core 的 files.rs 复用同一份,删除第二份 - macOS:LitheCoreContracts 只定义 FileVisibilityEngine 协议,App 内 RustFileVisibilityEngine 经 FFI 实现;删除 Swift 默认列表和匹配算法;插件目录列表按目录批量分类 - Windows:文件树按目录批量调用 wasm 并按“目录 + 规则版本”缓存;删除 ignore 判断和 default-settings.ts 的默认列表(新装默认为空,已保存值不变) - 共享 fixture visibility-v1.json 同时驱动 Rust、macOS(真实 FFI)和 Windows(真实 wasm)测试 - 更新功能矩阵、契约文档和同步桥 Note Co-Authored-By: Claude Sonnet 5.5 --- .../2026-11-05-core-rules-wasm-sync-bridge.md | 9 + .../Core/Rust/RustFileVisibilityEngine.swift | 100 +++ .../Lithe/Models/Settings/AppSettings.swift | 5 +- .../MacPluginWorkspaceFileService.swift | 18 +- .../FileWatching/MacDirectoryWatcher.swift | 3 +- .../Remote/RemoteDirectoryObservation.swift | 3 +- .../Workspace/FileVisibilityEngine.swift | 111 ++++ .../Workspace/FileVisibilityRules.swift | 258 +++----- .../LitheCoreVerifier/CoreVerification.swift | 38 +- .../LitheTests/BuiltInRuleListTests.swift | 8 +- .../FileVisibilityFixtureTests.swift | 59 ++ rust/lithe-core-rules/src/dispatch.rs | 6 +- rust/lithe-core-rules/src/lib.rs | 1 + rust/lithe-core-rules/src/visibility.rs | 394 ++++++++++++ .../tests/visibility_fixture.rs | 61 ++ .../src/diagnostics/request_trace.rs | 3 + rust/lithe-core/src/project/files.rs | 203 +----- rust/lithe-core/src/protocol/command.rs | 7 + rust/lithe-core/src/runtime/dispatcher.rs | 12 + rust/lithe-core/src/tests/protocol.rs | 34 + shared/contracts/rust-core-api.md | 13 + shared/fixtures/workspace/visibility-v1.json | 589 ++++++++++++++++++ .../features/file-explorer.json | 19 +- .../components/file-explorer-reveal.test.tsx | 6 +- .../file-explorer-selection.test.tsx | 6 +- .../components/file-explorer-tree.tsx | 67 +- .../lib/file-tree-visibility.test.ts | 120 ++++ .../file-explorer/lib/file-tree-visibility.ts | 94 +++ .../lib/visible-file-tree-rows.ts | 12 +- .../file-search/lib/file-search-api.test.ts | 10 +- .../settings/config/default-settings.ts | 30 +- .../settings/lib/settings-migrations.test.ts | 12 +- .../settings/lib/settings-migrations.ts | 26 +- .../lib/settings-normalization.test.ts | 7 +- 34 files changed, 1818 insertions(+), 526 deletions(-) create mode 100644 macos/Sources/Lithe/Core/Rust/RustFileVisibilityEngine.swift create mode 100644 macos/Sources/LitheCoreContracts/Workspace/FileVisibilityEngine.swift create mode 100644 macos/Tests/LitheTests/FileVisibilityFixtureTests.swift create mode 100644 rust/lithe-core-rules/src/visibility.rs create mode 100644 rust/lithe-core-rules/tests/visibility_fixture.rs create mode 100644 shared/fixtures/workspace/visibility-v1.json create mode 100644 windows/tauri/src/features/file-explorer/lib/file-tree-visibility.test.ts create mode 100644 windows/tauri/src/features/file-explorer/lib/file-tree-visibility.ts diff --git a/.agents/notes/implemented/architecture/2026-11-05-core-rules-wasm-sync-bridge.md b/.agents/notes/implemented/architecture/2026-11-05-core-rules-wasm-sync-bridge.md index faa995d49d..084bcc8e54 100644 --- a/.agents/notes/implemented/architecture/2026-11-05-core-rules-wasm-sync-bridge.md +++ b/.agents/notes/implemented/architecture/2026-11-05-core-rules-wasm-sync-bridge.md @@ -37,6 +37,15 @@ Issue #1662 要求把两端重复的确定性逻辑下沉到 Core。Windows 前 Windows 把每个 `PaneGroup` 转成规范组,两端旧规则已删除;布局树、拆分方向和比例仍在各自平台,只按 `removedGroupIds` 修剪。 两端在“选中项离开分组”时的既有行为不同(macOS 回落到第一个标签,Windows 选中相邻标签),用请求里的 `selectionFallback` (`first` / `adjacent`)保留,不改用户可见行为。fixture 为 `shared/fixtures/editor/workbench-state-v1.json`。 +- **WS-04 隐藏路径规则**(已迁移规则):内置默认列表、glob 匹配和“目录名/文件”的区别只在 + `rust/lithe-core-rules/src/visibility.rs`。Core 的 `files.rs` 直接复用这份代码,不再有第二份;macOS 经 + `workspace.classifyVisibility`(按一批路径一次调用)和 `workspace.visibilityDefaults` 同步 FFI 调用,Windows 同样经 wasm。 + `LitheCoreContracts` 不链接 Rust bridge,所以只定义 `FileVisibilityEngine` 协议,由 App 里的 + `RustFileVisibilityEngine` 实现,按 ObjC 类名在首次使用时发现,使 App 和链接了 App 的测试宿主无需关心安装顺序。 + 用户只保存自己的条目,内置规则由 Core 补上;`!` 条目关闭内置规则。结果里的 `watchedDespiteHiding` 表示 + `.lithe` 配置目录“树里隐藏、事件仍要监听”。Windows 文件树按目录批量分类,结果按“目录 + 规则版本”缓存。 + `workspace.classifyVisibility` 和 `workspace.visibilityDefaults` 不进请求追踪。 + 共享 fixture:`shared/fixtures/workspace/visibility-v1.json`。 ## 边界 diff --git a/macos/Sources/Lithe/Core/Rust/RustFileVisibilityEngine.swift b/macos/Sources/Lithe/Core/Rust/RustFileVisibilityEngine.swift new file mode 100644 index 0000000000..cfd214f15a --- /dev/null +++ b/macos/Sources/Lithe/Core/Rust/RustFileVisibilityEngine.swift @@ -0,0 +1,100 @@ +import Foundation +import LitheCoreContracts + +/// Thin adapter over Core's `workspace.classifyVisibility` and `workspace.visibilityDefaults`. +/// The built-in lists, the glob matcher and the file-versus-folder rules live in +/// rust/lithe-core-rules; Windows runs the same Rust source as wasm. This type only encodes the +/// request and decodes the answer. Each call is one synchronous FFI round trip, and callers send +/// every entry of a listing together instead of calling once per file. +/// +/// `LitheCoreContracts` does not link the Rust bridge, so it finds this class by its Objective-C +/// name (see `FileVisibilityEngines`). Keep the name in sync with that lookup. +@objc(LitheRustFileVisibilityEngine) +final class RustFileVisibilityEngine: NSObject, FileVisibilityEngine, @unchecked Sendable { + private struct EntryPayload: Encodable { + let path: String + let isDirectory: Bool? + + private enum CodingKeys: String, CodingKey { case path, isDirectory } + + // A nil kind must reach Core as an absent field; Core reads absence as "unknown". + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(path, forKey: .path) + try container.encodeIfPresent(isDirectory, forKey: .isDirectory) + } + } + + private struct ClassifyRequest: Encodable { + let hiddenDirectoryNames: [String] + let hiddenFilePatterns: [String] + let entries: [EntryPayload] + } + + private struct ClassifyResponse: Decodable { + let hidden: [Bool] + let watchedDespiteHiding: [Bool] + } + + private struct DefaultsResponse: Decodable { + let hiddenDirectoryNames: [String] + let hiddenFilePatterns: [String] + let builtInPatterns: [String] + let configurationDirectory: String + let configurationPattern: String + } + + private struct EmptyPayload: Encodable {} + + private let core: RustCoreBridge + + override init() { + core = RustCoreBridge() + super.init() + } + + func defaults() -> FileVisibilityDefaults { + let result: Result = core.executeResult( + command: "workspace.visibilityDefaults", payload: EmptyPayload() + ) + switch result { + case let .success(response): + return FileVisibilityDefaults( + hiddenDirectoryNames: response.hiddenDirectoryNames, + hiddenFilePatterns: response.hiddenFilePatterns, + builtInPatterns: response.builtInPatterns, + configurationDirectory: response.configurationDirectory, + configurationPattern: response.configurationPattern + ) + case .failure: + // A valid request cannot fail unless the linked Core is broken, and there is no + // second implementation to fall back to. + fatalError("workspace.visibilityDefaults failed") + } + } + + func classify( + _ queries: [FileVisibilityQuery], + hiddenDirectoryNames: [String], + hiddenFilePatterns: [String] + ) -> [FileVisibilityVerdict] { + guard !queries.isEmpty else { return [] } + let result: Result = core.executeResult( + command: "workspace.classifyVisibility", + payload: ClassifyRequest( + hiddenDirectoryNames: hiddenDirectoryNames, + hiddenFilePatterns: hiddenFilePatterns, + entries: queries.map { EntryPayload(path: $0.relativePath, isDirectory: $0.isDirectory) } + ) + ) + switch result { + case let .success(response): + return zip(response.hidden, response.watchedDespiteHiding).map { + FileVisibilityVerdict(hidden: $0, watchedDespiteHiding: $1) + } + case .failure: + assertionFailure("workspace.classifyVisibility failed") + return queries.map { _ in FileVisibilityVerdict(hidden: false, watchedDespiteHiding: false) } + } + } +} diff --git a/macos/Sources/Lithe/Models/Settings/AppSettings.swift b/macos/Sources/Lithe/Models/Settings/AppSettings.swift index e1b20f7a65..c5b61b91be 100644 --- a/macos/Sources/Lithe/Models/Settings/AppSettings.swift +++ b/macos/Sources/Lithe/Models/Settings/AppSettings.swift @@ -1123,15 +1123,14 @@ final class AppSettings: ObservableObject { /// them. The legacy keys are left untouched and stop being read once the /// new key is saved. static func migratedHiddenPathPatterns(from defaults: any KeyValueStore) -> [String] { - let builtIn = FileVisibilityRules.default let directories = (defaults.stringArray(forKey: Key.legacyHiddenDirectories) ?? []) .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty && !builtIn.isHiddenDirectoryName($0) } + .filter { !$0.isEmpty && !FileVisibilityRules.isBuiltInDirectoryName($0) } .map { "**/\($0)/" } let files = (defaults.stringArray(forKey: Key.legacyHiddenFilePatterns) ?? []) .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { pattern in - !pattern.isEmpty && !builtIn.hiddenFilePatterns.contains { + !pattern.isEmpty && !FileVisibilityRules.builtInHiddenFilePatterns.contains { $0.caseInsensitiveCompare(pattern) == .orderedSame } } diff --git a/macos/Sources/Lithe/Platform/MacOS/FileSystem/MacPluginWorkspaceFileService.swift b/macos/Sources/Lithe/Platform/MacOS/FileSystem/MacPluginWorkspaceFileService.swift index 20f8e26c88..f2914109b5 100644 --- a/macos/Sources/Lithe/Platform/MacOS/FileSystem/MacPluginWorkspaceFileService.swift +++ b/macos/Sources/Lithe/Platform/MacOS/FileSystem/MacPluginWorkspaceFileService.swift @@ -371,6 +371,7 @@ final class MacPluginWorkspaceFileService: PluginWorkspaceFileAccess, @unchecked var seen: Set = [] var entries: [ListedEntry] = [] + var candidates: [ListedEntry] = [] try readNames(of: directory, maximum: budgets.maximumDirectoryScanEntries, cancellation: cancellation) { rawName, type in let name = rawName.precomposedStringWithCanonicalMapping guard seen.insert(name).inserted else { return } @@ -387,12 +388,17 @@ final class MacPluginWorkspaceFileService: PluginWorkspaceFileAccess, @unchecked // Same omissions as the Project view: links, then hidden rules. guard kind != .symbolicLink else { return } let isDirectory = kind == .directory - guard !root.visibilityRules.isHidden( - rootURL.appendingPathComponent(relativePath(name)), - relativeTo: rootURL, - isDirectory: isDirectory - ) else { return } - entries.append(ListedEntry(key: SortKey(isDirectory: isDirectory, name: name), kind: kind)) + candidates.append(ListedEntry(key: SortKey(isDirectory: isDirectory, name: name), kind: kind)) + } + // One Core call classifies the whole directory instead of one call per entry. + let hidden = root.visibilityRules.hiddenFlags( + for: candidates.map { + (url: rootURL.appendingPathComponent(relativePath($0.key.name)), isDirectory: $0.key.isDirectory) + }, + relativeTo: rootURL + ) + for (candidate, isHidden) in zip(candidates, hidden) where !isHidden { + entries.append(candidate) } try cancellation.check() // The cursor key is ordered with the current entries, after any entry diff --git a/macos/Sources/Lithe/Platform/MacOS/FileWatching/MacDirectoryWatcher.swift b/macos/Sources/Lithe/Platform/MacOS/FileWatching/MacDirectoryWatcher.swift index 7c0cd6eedf..6544d00222 100644 --- a/macos/Sources/Lithe/Platform/MacOS/FileWatching/MacDirectoryWatcher.swift +++ b/macos/Sources/Lithe/Platform/MacOS/FileWatching/MacDirectoryWatcher.swift @@ -192,8 +192,7 @@ final class MacDirectoryWatcher: DirectoryChangeSource, @unchecked Sendable { // `.lithe` may be hidden from the tree, yet its JSON files still // reload project services, so its events are not filtered out. if configuration.containsWorkspacePath(url), - !visibilityRules.isHiddenPath(url, relativeTo: configuration.workspaceRoot) - || visibilityRules.isWatchedDespiteHiding(url, relativeTo: configuration.workspaceRoot) { + visibilityRules.deliversEvents(at: url, relativeTo: configuration.workspaceRoot) { workspacePaths.insert(url.path) } } diff --git a/macos/Sources/Lithe/Services/Remote/RemoteDirectoryObservation.swift b/macos/Sources/Lithe/Services/Remote/RemoteDirectoryObservation.swift index 089efb1844..052a6df039 100644 --- a/macos/Sources/Lithe/Services/Remote/RemoteDirectoryObservation.swift +++ b/macos/Sources/Lithe/Services/Remote/RemoteDirectoryObservation.swift @@ -134,8 +134,7 @@ final class RemoteDirectoryObservation: DirectoryChangeSource, @unchecked Sendab if configuration.containsWorkspacePath(url) { onlyIndex = false batch.gitStateMayHaveChanged = true - if !visibilityRules.isHiddenPath(url, relativeTo: configuration.workspaceRoot) - || visibilityRules.isWatchedDespiteHiding(url, relativeTo: configuration.workspaceRoot) { + if visibilityRules.deliversEvents(at: url, relativeTo: configuration.workspaceRoot) { workspacePaths.insert(url.path) } } diff --git a/macos/Sources/LitheCoreContracts/Workspace/FileVisibilityEngine.swift b/macos/Sources/LitheCoreContracts/Workspace/FileVisibilityEngine.swift new file mode 100644 index 0000000000..a0ffd3b94a --- /dev/null +++ b/macos/Sources/LitheCoreContracts/Workspace/FileVisibilityEngine.swift @@ -0,0 +1,111 @@ +import Foundation +import os + +/// The built-in hidden-path lists, exactly as Rust Core defines them. +package struct FileVisibilityDefaults: Sendable, Equatable { + package let hiddenDirectoryNames: [String] + package let hiddenFilePatterns: [String] + /// The built-ins written as Hidden paths patterns, for display. + package let builtInPatterns: [String] + /// The project configuration folder, hidden from the tree but still watched. + package let configurationDirectory: String + package let configurationPattern: String + + package init( + hiddenDirectoryNames: [String], + hiddenFilePatterns: [String], + builtInPatterns: [String], + configurationDirectory: String, + configurationPattern: String + ) { + self.hiddenDirectoryNames = hiddenDirectoryNames + self.hiddenFilePatterns = hiddenFilePatterns + self.builtInPatterns = builtInPatterns + self.configurationDirectory = configurationDirectory + self.configurationPattern = configurationPattern + } +} + +/// One workspace-relative path to classify. `isDirectory` is nil when the caller does not know. +package struct FileVisibilityQuery: Sendable, Equatable { + package let relativePath: String + package let isDirectory: Bool? + + package init(relativePath: String, isDirectory: Bool?) { + self.relativePath = relativePath + self.isDirectory = isDirectory + } +} + +/// Core's answer for one path. +package struct FileVisibilityVerdict: Sendable, Equatable { + /// The tree, search and Local History leave the path out. + package let hidden: Bool + /// The path is hidden, yet its file events must still be delivered. + package let watchedDespiteHiding: Bool + + package init(hidden: Bool, watchedDespiteHiding: Bool) { + self.hidden = hidden + self.watchedDespiteHiding = watchedDespiteHiding + } +} + +/// Evaluates hidden-path rules. The only implementation calls Rust Core's +/// `workspace.classifyVisibility` and `workspace.visibilityDefaults`; this module does not link +/// the Rust bridge, so the app injects the implementation. +/// +/// Note: .agents/notes/implemented/architecture/2026-11-05-core-rules-wasm-sync-bridge.md +package protocol FileVisibilityEngine: Sendable { + func defaults() -> FileVisibilityDefaults + /// Classifies every query against one rule set. `hiddenDirectoryNames` and + /// `hiddenFilePatterns` are the user's entries; Core adds the built-ins. + func classify( + _ queries: [FileVisibilityQuery], + hiddenDirectoryNames: [String], + hiddenFilePatterns: [String] + ) -> [FileVisibilityVerdict] +} + +/// Holds the installed engine. +/// +/// The app target registers a Rust-backed engine as the Objective-C class +/// `LitheRustFileVisibilityEngine`, which is found by name the first time rules are evaluated. +/// Discovery by name means app code and every test host that links the app resolve it without an +/// ordering requirement. `install(_:)` replaces it, for example with a test double. +package enum FileVisibilityEngines { + private struct State { + var engine: (any FileVisibilityEngine)? + var defaults: FileVisibilityDefaults? + } + + private static let state = OSAllocatedUnfairLock(initialState: State()) + + package static func install(_ engine: any FileVisibilityEngine) { + state.withLock { $0 = State(engine: engine, defaults: nil) } + } + + package static var current: any FileVisibilityEngine { + if let engine = state.withLock({ $0.engine }) { return engine } + guard let type = NSClassFromString("LitheRustFileVisibilityEngine") as? NSObject.Type, + let discovered = type.init() as? any FileVisibilityEngine else { + // There is no second implementation to fall back to. + fatalError("No FileVisibilityEngine is installed; the app must link Rust Core.") + } + return state.withLock { state in + if let engine = state.engine { return engine } + state.engine = discovered + return discovered + } + } + + /// The built-in lists, fetched from Core once per installed engine. + package static var defaults: FileVisibilityDefaults { + if let cached = state.withLock({ $0.defaults }) { return cached } + let fetched = current.defaults() + return state.withLock { state in + if let cached = state.defaults { return cached } + state.defaults = fetched + return fetched + } + } +} diff --git a/macos/Sources/LitheCoreContracts/Workspace/FileVisibilityRules.swift b/macos/Sources/LitheCoreContracts/Workspace/FileVisibilityRules.swift index a5046fab99..e73ef4759c 100644 --- a/macos/Sources/LitheCoreContracts/Workspace/FileVisibilityRules.swift +++ b/macos/Sources/LitheCoreContracts/Workspace/FileVisibilityRules.swift @@ -1,53 +1,41 @@ import Foundation +/// The user's hidden-path entries plus the questions hosts ask about them. +/// +/// The built-in lists, the glob matcher and the file-versus-folder rules live in Rust Core +/// (`lithe-core-rules`, shared with Windows). This type keeps only what the user wrote and +/// forwards every question to `FileVisibilityEngines`; it has no list and no matcher of its own. package struct FileVisibilityRules: Hashable, Sendable { - /// VCS metadata (`.git`, `.svn`) is hidden like IDEA's default ignore - /// list; providers watch their own metadata directories. - package static let builtInHiddenDirectories = [ - ".git", ".svn", ".worktree", ".worktrees", ".build", ".swiftpm", "node_modules", ".venv", "venv", - "__pycache__", "target", "build", "DerivedData", ".gradle", ".next", "dist", "coverage", - "design-qa-artifacts", ".artifacts" - ] - /// Names and globs omitted from the project tree, search index, watchers, - /// and Local History. LSP-generated artifacts are not built-in; users can - /// add them once via the Hidden paths recommended-rules actions. - package static let builtInHiddenFilePatterns = [ - ".DS_Store", - "*.pyc", - hiddenConfigurationPattern, - ".lithe/run/local.json", - ] - /// The project configuration folder is hidden like IDEA's `.idea`; its - /// files are edited through Settings and the run configuration editor. - package static let hiddenConfigurationPattern = ".lithe/**" - - /// The built-in rules written as Hidden paths patterns, for display. Folder - /// names become `**/name/`, the pattern that hides them at any depth. - package static var builtInPatterns: [String] { - builtInHiddenDirectories.map { "**/\($0)/" } + builtInHiddenFilePatterns - } - + /// VCS metadata (`.git`, `.svn`) is hidden like IDEA's default ignore list; providers watch + /// their own metadata directories. + package static var builtInHiddenDirectories: [String] { FileVisibilityEngines.defaults.hiddenDirectoryNames } + /// Names and globs omitted from the project tree, search index, watchers, and Local History. + /// LSP-generated artifacts are not built-in; users can add them once via the Hidden paths + /// recommended-rules actions. + package static var builtInHiddenFilePatterns: [String] { FileVisibilityEngines.defaults.hiddenFilePatterns } + /// The project configuration folder is hidden like IDEA's `.idea`; its files are edited + /// through Settings and the run configuration editor. + package static var hiddenConfigurationPattern: String { FileVisibilityEngines.defaults.configurationPattern } + /// The built-in rules written as Hidden paths patterns, for display. + package static var builtInPatterns: [String] { FileVisibilityEngines.defaults.builtInPatterns } + + /// The project configuration folder at the workspace root. It is the only folder a + /// hidden-path rule may remove from the tree while its file events stay watched, because + /// edits to its JSON files reload project services. + package static var watchedConfigurationDirectory: String { FileVisibilityEngines.defaults.configurationDirectory } + + /// The user's folder names, as stored. Core adds the built-ins when it evaluates them. package var hiddenDirectoryNames: [String] + /// The user's patterns, as stored. A `!` entry turns a built-in rule off; it stays in the + /// list so Core, which adds the built-ins, drops the same ones. package var hiddenFilePatterns: [String] - package static let `default` = FileVisibilityRules( - hiddenDirectoryNames: builtInHiddenDirectories, - hiddenFilePatterns: builtInHiddenFilePatterns - ) + /// Only the built-in rules. + package static let `default` = FileVisibilityRules(hiddenDirectoryNames: [], hiddenFilePatterns: []) - /// A `!` entry in `hiddenFilePatterns` turns a rule off, built-in or not. - /// It stays in `hiddenFilePatterns` so Rust Core, which adds the built-ins - /// again, drops the same ones; it is never matched as a pattern. package init(hiddenDirectoryNames: [String], hiddenFilePatterns: [String]) { - let disabled = Self.disabledEntries(hiddenFilePatterns) - self.hiddenDirectoryNames = Self.normalizedEntries( - Self.builtInHiddenDirectories + hiddenDirectoryNames - ).filter { !Self.isDisabledDirectory($0, disabled: disabled) } - self.hiddenFilePatterns = Self.normalizedEntries( - Self.builtInHiddenFilePatterns + hiddenFilePatterns - ).filter { pattern in - !disabled.contains { $0.caseInsensitiveCompare(pattern) == .orderedSame } - } + self.hiddenDirectoryNames = hiddenDirectoryNames + self.hiddenFilePatterns = hiddenFilePatterns } /// Whether `pattern` is one of `builtInPatterns`, ignoring case. @@ -55,7 +43,13 @@ package struct FileVisibilityRules: Hashable, Sendable { builtInPatterns.contains { $0.caseInsensitiveCompare(pattern) == .orderedSame } } - /// The entries after a leading `!`. + /// Whether `name` is one of the built-in folder names, ignoring case. + package static func isBuiltInDirectoryName(_ name: String) -> Bool { + builtInHiddenDirectories.contains { $0.caseInsensitiveCompare(name) == .orderedSame } + } + + /// The entries after a leading `!`. Settings text syntax; Core applies the same rule when it + /// evaluates the list. package static func disabledEntries(_ patterns: [String]) -> [String] { patterns.compactMap { pattern in let trimmed = pattern.trimmingCharacters(in: .whitespacesAndNewlines) @@ -64,167 +58,67 @@ package struct FileVisibilityRules: Hashable, Sendable { } } - /// A folder is turned off by its displayed form `**/name/` or its bare name. - private static func isDisabledDirectory(_ name: String, disabled: [String]) -> Bool { - disabled.contains { entry in - entry.caseInsensitiveCompare(name) == .orderedSame - || entry.caseInsensitiveCompare("**/\(name)/") == .orderedSame - } - } - package func isHidden( _ url: URL, relativeTo rootURL: URL, isDirectory: Bool? = nil ) -> Bool { - let relativePath = relativePath(for: url, root: rootURL) - guard !relativePath.isEmpty else { return false } - let components = relativePath.split(separator: "/").map(String.init) - guard let lastComponent = components.last else { return false } - - let directoryComponents: [String] - switch isDirectory { - case true: - directoryComponents = components - case false: - directoryComponents = Array(components.dropLast()) - case nil: - directoryComponents = components - } - if directoryComponents.contains(where: isHiddenDirectoryName) { - return true - } - if isDirectory == true, isHiddenDirectoryName(lastComponent) { - return true - } - - let directoryCount = isDirectory == false ? max(0, components.count - 1) : components.count - let patterns = hiddenFilePatterns.filter { !$0.hasPrefix("!") } - let folderGlobs = patterns.compactMap(Self.folderGlob) - if !folderGlobs.isEmpty { - // Each ancestor prefix is joined once and shared by every folder - // rule, instead of being rebuilt per rule. - let prefixes = stride(from: 1, through: directoryCount, by: 1).map { - components.prefix($0).joined(separator: "/") - } - let hidesFolder = folderGlobs.contains { glob in - prefixes.contains { Self.pathGlobMatches(glob, value: $0) } - } - if hidesFolder { return true } - } - guard isDirectory != true else { return false } - return patterns.contains { pattern in - Self.pathGlobMatches(pattern, value: lastComponent) || - Self.pathGlobMatches(pattern, value: relativePath) - } + verdict(for: url, relativeTo: rootURL, isDirectory: isDirectory)?.hidden ?? false } package func isHiddenPath(_ url: URL, relativeTo rootURL: URL) -> Bool { isHidden(url, relativeTo: rootURL, isDirectory: nil) } - /// The project configuration folder at the workspace root. It is the only - /// folder a hidden-path rule may remove from the tree while its file events - /// stay watched, because edits to its JSON files reload project services. - package static let watchedConfigurationDirectory = ".lithe" - - /// Whether file events at `url` must still be delivered although the rules - /// hide it. Only the built-in noise below the configuration folder, such as - /// `.lithe/run/local.json`, stays dropped. - package func isWatchedDespiteHiding(_ url: URL, relativeTo rootURL: URL) -> Bool { - let relativePath = relativePath(for: url, root: rootURL) - guard relativePath.split(separator: "/").first.map(String.init) == Self.watchedConfigurationDirectory - else { return false } - return !Self.configurationNoise.isHiddenPath(url, relativeTo: rootURL) + /// Classifies a whole listing with one Core call. Prefer this over `isHidden` in loops. + package func hiddenFlags( + for entries: [(url: URL, isDirectory: Bool?)], + relativeTo rootURL: URL + ) -> [Bool] { + let queries = entries.map { + (relative: Self.relativePath(for: $0.url, root: rootURL), isDirectory: $0.isDirectory) + } + let asked = queries.enumerated().filter { !$0.element.relative.isEmpty } + guard !asked.isEmpty else { return Array(repeating: false, count: entries.count) } + let verdicts = FileVisibilityEngines.current.classify( + asked.map { FileVisibilityQuery(relativePath: $0.element.relative, isDirectory: $0.element.isDirectory) }, + hiddenDirectoryNames: hiddenDirectoryNames, + hiddenFilePatterns: hiddenFilePatterns + ) + var flags = Array(repeating: false, count: entries.count) + for (position, item) in asked.enumerated() where position < verdicts.count { + flags[item.offset] = verdicts[position].hidden + } + return flags } - /// The built-in rules without the one hiding the whole configuration folder. - private static let configurationNoise = FileVisibilityRules( - hiddenDirectoryNames: [], hiddenFilePatterns: ["!" + hiddenConfigurationPattern] - ) - - package func isHiddenDirectoryName(_ name: String) -> Bool { - hiddenDirectoryNames.contains { $0.caseInsensitiveCompare(name) == .orderedSame } + /// Whether file events at `url` must still be delivered although the rules hide it. Only the + /// built-in noise below the configuration folder, such as `.lithe/run/local.json`, stays dropped. + package func isWatchedDespiteHiding(_ url: URL, relativeTo rootURL: URL) -> Bool { + verdict(for: url, relativeTo: rootURL, isDirectory: nil)?.watchedDespiteHiding ?? false } - /// The folder glob of an entry that ends in `/`, `/*` or `/**`. - /// - /// Such an entry names a folder from the project root (or at any depth when - /// it starts with `**/`), so the folder and everything below it is hidden - /// instead of leaving an empty folder behind. Other entries keep their - /// file-only meaning, as pinned by `shared/fixtures/search/exclusions.json`. - private static func folderGlob(_ pattern: String) -> String? { - for suffix in ["/**", "/*", "/"] where pattern.hasSuffix(suffix) { - let glob = String(pattern.dropLast(suffix.count)) - return glob.isEmpty ? nil : glob - } - return nil + /// Whether a file event at `url` belongs to visible project inputs: the path is not hidden, or + /// it is hidden but still watched. One Core call answers both parts. + package func deliversEvents(at url: URL, relativeTo rootURL: URL) -> Bool { + guard let verdict = verdict(for: url, relativeTo: rootURL, isDirectory: nil) else { return true } + return !verdict.hidden || verdict.watchedDespiteHiding } - private static func normalizedEntries(_ values: [String]) -> [String] { - var result: [String] = [] - for value in values { - let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines) - guard !normalized.isEmpty, - !result.contains(where: { $0.caseInsensitiveCompare(normalized) == .orderedSame }) else { - continue - } - result.append(normalized) - } - return result + private func verdict(for url: URL, relativeTo rootURL: URL, isDirectory: Bool?) -> FileVisibilityVerdict? { + let relativePath = Self.relativePath(for: url, root: rootURL) + guard !relativePath.isEmpty else { return nil } + return FileVisibilityEngines.current.classify( + [FileVisibilityQuery(relativePath: relativePath, isDirectory: isDirectory)], + hiddenDirectoryNames: hiddenDirectoryNames, + hiddenFilePatterns: hiddenFilePatterns + ).first } - private func relativePath(for url: URL, root: URL) -> String { + private static func relativePath(for url: URL, root: URL) -> String { let rootPath = root.standardizedFileURL.path let path = url.standardizedFileURL.path guard path.hasPrefix(rootPath + "/") else { return url.lastPathComponent } return String(path.dropFirst(rootPath.count + 1)) } - - /// Matches a hidden-path glob, letting a leading `**/` stand for zero or - /// more folders, so `**/dist/` covers a root-level `dist` as well as - /// `a/b/dist`. `*` already spans `/`, which covers the non-root case; the - /// root case needs the second attempt because the glob would otherwise - /// demand a literal `/`. - private static func pathGlobMatches(_ pattern: String, value: String) -> Bool { - if globMatches(pattern, value: value) { return true } - guard pattern.hasPrefix("**/") else { return false } - return globMatches(String(pattern.dropFirst(3)), value: value) - } - - private static func globMatches(_ pattern: String, value: String) -> Bool { - let patternCharacters = Array(pattern.lowercased()) - let valueCharacters = Array(value.lowercased()) - var patternIndex = 0 - var valueIndex = 0 - var starIndex: Int? - var starMatchIndex = 0 - - while valueIndex < valueCharacters.count { - if patternIndex < patternCharacters.count { - let character = patternCharacters[patternIndex] - if character == valueCharacters[valueIndex] || character == "?" { - patternIndex += 1 - valueIndex += 1 - continue - } - } - if patternIndex < patternCharacters.count, patternCharacters[patternIndex] == "*" { - starIndex = patternIndex - starMatchIndex = valueIndex - patternIndex += 1 - } else if let starIndex { - patternIndex = starIndex + 1 - starMatchIndex += 1 - valueIndex = starMatchIndex - } else { - return false - } - } - - while patternIndex < patternCharacters.count, patternCharacters[patternIndex] == "*" { - patternIndex += 1 - } - return patternIndex == patternCharacters.count - } } diff --git a/macos/Tests/LitheCoreVerifier/CoreVerification.swift b/macos/Tests/LitheCoreVerifier/CoreVerification.swift index ce14e23b2b..c5bd71108b 100644 --- a/macos/Tests/LitheCoreVerifier/CoreVerification.swift +++ b/macos/Tests/LitheCoreVerifier/CoreVerification.swift @@ -9,10 +9,9 @@ struct CoreVerification { static func main() async { verifySharedContractFixtures() verifyDiffParser() - verifyVisibilityRules() verifyWhitespaceModes() verifySearchOptions() - print("Core verification passed: shared fixtures, diff, visibility, graph, search options, and whitespace modes") + print("Core verification passed: shared fixtures, diff, search options, and whitespace modes") } private struct SearchFixture: Decodable { @@ -77,17 +76,11 @@ struct CoreVerification { return } - let fixtureRoot = FileManager.default.temporaryDirectory - .appendingPathComponent("lithe-shared-search-fixture", isDirectory: true) - let visibilityRules = FileVisibilityRules.default + // Hidden-path rules live in Rust Core, which this verifier does not link; they are + // verified by shared/fixtures/workspace/visibility-v1.json. The only hidden file in this + // fixture is the linked-worktree copy, so the verifier leaves out that folder itself. let files = searchFixture.files - .filter { file in - !visibilityRules.isHidden( - fixtureRoot.appendingPathComponent(file.path), - relativeTo: fixtureRoot, - isDirectory: false - ) - } + .filter { !$0.path.hasPrefix(".worktree/") } .sorted { $0.path < $1.path } for fixtureCase in searchFixture.cases { var actual: [SearchFixture.Match] = [] @@ -191,27 +184,6 @@ struct CoreVerification { ) } - private static func verifyVisibilityRules() { - let root = URL(fileURLWithPath: "/tmp/lithe-test-project") - let rules = FileVisibilityRules.default - require( - rules.isHidden( - root.appendingPathComponent(".build/debug/Lithe"), - relativeTo: root, - isDirectory: false - ), - "build artifacts should be hidden" - ) - require( - !rules.isHidden( - root.appendingPathComponent("src/main.swift"), - relativeTo: root, - isDirectory: false - ), - "source files should remain visible" - ) - } - private static func verifyWhitespaceModes() { require(GitDiffWhitespaceMode.allCases.count == 2, "expected two whitespace modes") require(GitDiffWhitespaceMode.doNotIgnore.title == "Do not ignore", "default whitespace label changed") diff --git a/macos/Tests/LitheTests/BuiltInRuleListTests.swift b/macos/Tests/LitheTests/BuiltInRuleListTests.swift index 85ec826077..3d491b92c5 100644 --- a/macos/Tests/LitheTests/BuiltInRuleListTests.swift +++ b/macos/Tests/LitheTests/BuiltInRuleListTests.swift @@ -1,5 +1,7 @@ +import Foundation import LitheCoreContracts import Testing +@testable import Lithe /// Built-in rules shown as editable lines, where deleting a line turns the rule off. struct BuiltInRuleListTests { @@ -15,8 +17,10 @@ struct BuiltInRuleListTests { @Test func disabledEntriesTurnOffBuiltInHiddenPaths() { let rules = FileVisibilityRules(hiddenDirectoryNames: [], hiddenFilePatterns: ["!**/.git/"]) - #expect(!rules.hiddenDirectoryNames.contains(".git")) + let root = URL(fileURLWithPath: "/tmp/lithe-built-in-rule-list") + let git = root.appendingPathComponent(".git") + #expect(!rules.isHidden(git, relativeTo: root, isDirectory: true)) #expect(rules.hiddenFilePatterns.contains("!**/.git/")) - #expect(!FileVisibilityRules.default.hiddenDirectoryNames.isEmpty) + #expect(FileVisibilityRules.default.isHidden(git, relativeTo: root, isDirectory: true)) } } diff --git a/macos/Tests/LitheTests/FileVisibilityFixtureTests.swift b/macos/Tests/LitheTests/FileVisibilityFixtureTests.swift new file mode 100644 index 0000000000..3930a77a9f --- /dev/null +++ b/macos/Tests/LitheTests/FileVisibilityFixtureTests.swift @@ -0,0 +1,59 @@ +import Foundation +import LitheCoreContracts +import Testing +@testable import Lithe + +/// shared/fixtures/workspace/visibility-v1.json is the contract for hidden paths on every host: +/// Rust Core and lithe-core-rules, macOS (here, through the real FFI) and Windows (through wasm). +struct FileVisibilityFixtureTests { + private static func fixture() throws -> [String: Any] { + let root = URL(fileURLWithPath: #filePath).deletingLastPathComponent() + .deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() + let data = try Data(contentsOf: root.appendingPathComponent("shared/fixtures/workspace/visibility-v1.json")) + return try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any]) + } + + @Test + func builtInListsComeFromCore() throws { + let defaults = try #require(try Self.fixture()["defaults"] as? [String: Any]) + #expect(FileVisibilityRules.builtInHiddenDirectories == defaults["hiddenDirectoryNames"] as? [String]) + #expect(FileVisibilityRules.builtInHiddenFilePatterns == defaults["hiddenFilePatterns"] as? [String]) + #expect(FileVisibilityRules.builtInPatterns == defaults["builtInPatterns"] as? [String]) + #expect(FileVisibilityRules.watchedConfigurationDirectory == defaults["configurationDirectory"] as? String) + } + + @Test + func everyCaseMatchesTheSharedFixture() throws { + let root = URL(fileURLWithPath: "/fixture") + for item in try #require(Self.fixture()["cases"] as? [[String: Any]]) { + let name = item["name"] as? String ?? "" + let rules = FileVisibilityRules( + hiddenDirectoryNames: item["hiddenDirectoryNames"] as? [String] ?? [], + hiddenFilePatterns: item["hiddenFilePatterns"] as? [String] ?? [] + ) + let entries = try #require(item["entries"] as? [[String: Any]]) + // Entries with an empty path are not a file-system location; the adapter never asks. + let located = entries.filter { !(($0["path"] as? String) ?? "").isEmpty } + let batch = rules.hiddenFlags( + for: located.map { (url: root.appendingPathComponent($0["path"] as? String ?? ""), isDirectory: $0["isDirectory"] as? Bool) }, + relativeTo: root + ) + for (index, entry) in located.enumerated() { + let path = entry["path"] as? String ?? "" + let url = root.appendingPathComponent(path) + let kind = entry["isDirectory"] as? Bool + let expected = entry["hidden"] as? Bool + #expect(rules.isHidden(url, relativeTo: root, isDirectory: kind) == expected, "\(name): \(path)") + #expect(batch[index] == expected, "\(name): \(path) (batch)") + let watched = entry["watchedDespiteHiding"] as? Bool ?? false + if expected == true { + #expect(rules.isWatchedDespiteHiding(url, relativeTo: root) == watched, "\(name): \(path)") + } + // Event filtering never knows the kind, so compare only where the kind cannot matter. + if kind == nil || path.hasPrefix(".lithe") { + #expect(rules.deliversEvents(at: url, relativeTo: root) == (expected == false || watched), "\(name): \(path)") + } + } + } + } +} diff --git a/rust/lithe-core-rules/src/dispatch.rs b/rust/lithe-core-rules/src/dispatch.rs index 99a58d0b5c..998e140648 100644 --- a/rust/lithe-core-rules/src/dispatch.rs +++ b/rust/lithe-core-rules/src/dispatch.rs @@ -1,6 +1,6 @@ //! Command routing and the JSON envelope shared with `lithe-core`. -use crate::{editor, keymap, RuleError, RuleErrorCode}; +use crate::{editor, keymap, visibility, RuleError, RuleErrorCode}; use serde::Deserialize; use serde_json::{json, Value}; @@ -8,6 +8,8 @@ use serde_json::{json, Value}; pub const COMMANDS: &[&str] = &[ keymap::MODIFIER_GESTURE_STEP, editor::REDUCE_WORKBENCH_STATE, + visibility::CLASSIFY_VISIBILITY, + visibility::VISIBILITY_DEFAULTS, ]; /// Reports whether `command` is owned by this crate. @@ -20,6 +22,8 @@ pub fn execute(command: &str, payload: Value) -> Result { match command { keymap::MODIFIER_GESTURE_STEP => keymap::modifier_gesture_step_json(payload), editor::REDUCE_WORKBENCH_STATE => editor::reduce_json(payload), + visibility::CLASSIFY_VISIBILITY => visibility::classify_visibility_json(payload), + visibility::VISIBILITY_DEFAULTS => visibility::visibility_defaults_json(payload), _ => Err(RuleError::new( RuleErrorCode::NotSupported, "Unknown rule command", diff --git a/rust/lithe-core-rules/src/lib.rs b/rust/lithe-core-rules/src/lib.rs index e1f6ba99fe..78c9dc5f05 100644 --- a/rust/lithe-core-rules/src/lib.rs +++ b/rust/lithe-core-rules/src/lib.rs @@ -17,6 +17,7 @@ mod dispatch; pub mod editor; mod error; pub mod keymap; +pub mod visibility; #[cfg(target_arch = "wasm32")] mod wasm; diff --git a/rust/lithe-core-rules/src/visibility.rs b/rust/lithe-core-rules/src/visibility.rs new file mode 100644 index 0000000000..586659d9c0 --- /dev/null +++ b/rust/lithe-core-rules/src/visibility.rs @@ -0,0 +1,394 @@ +//! Hidden-path rules: the one definition of which workspace paths are hidden. +//! +//! This module owns three things that used to exist once per host: the built-in default lists, +//! the glob matcher, and the way a directory differs from a file. `lithe-core` uses +//! [`VisibilityRules`] directly for snapshots, search and history scans. macOS and the Windows +//! frontend reach the same code through two commands, so no host keeps a list or a matcher: +//! +//! - `workspace.visibilityDefaults` returns the built-in lists for display. +//! - `workspace.classifyVisibility` classifies a whole batch of paths against one rule set. Hosts +//! send every child of a directory in one call instead of one call per file. +//! +//! Two answers exist for every path. `hidden` means the tree, search and history all leave it out. +//! `watchedDespiteHiding` marks the project configuration folder: it is hidden from the tree, but +//! edits to its JSON files must still reach project services, so its events are not dropped. + +use crate::{RuleError, RuleErrorCode}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// Command name of the batch classifier in the Core command namespace. +pub const CLASSIFY_VISIBILITY: &str = "workspace.classifyVisibility"; +/// Command name of the defaults query in the Core command namespace. +pub const VISIBILITY_DEFAULTS: &str = "workspace.visibilityDefaults"; + +/// Folder names hidden at any depth unless a `!` entry turns them off. Shared by the tree, search, +/// local history, watchers and Git discovery. +pub const BUILT_IN_HIDDEN_DIRECTORIES: &[&str] = &[ + ".git", + ".svn", + ".worktree", + ".worktrees", + ".build", + ".swiftpm", + "node_modules", + // Python virtual environments and bytecode caches are dependency or generated + // output like `node_modules`. They hold tens of thousands of files that no + // snapshot consumer wants. Environments are matched by their conventional + // names only, so a differently named one still has to be hidden by the user. + ".venv", + "venv", + "__pycache__", + "target", + "build", + "DerivedData", + ".gradle", + ".next", + "dist", + "coverage", + "design-qa-artifacts", + ".artifacts", +]; + +/// The project configuration folder is hidden like IDEA's `.idea`; its files are edited through +/// Settings and the run configuration editor. +pub const CONFIGURATION_PATTERN: &str = ".lithe/**"; + +/// The project configuration folder at the workspace root. Its events stay watched even though +/// the tree hides it, because edits to its JSON files reload project services. +pub const CONFIGURATION_DIRECTORY: &str = ".lithe"; + +/// File names and globs hidden everywhere unless a `!` entry turns them off. +pub const BUILT_IN_HIDDEN_FILE_PATTERNS: &[&str] = &[ + ".DS_Store", + "*.pyc", + CONFIGURATION_PATTERN, + ".lithe/run/local.json", +]; + +/// The built-in rules written as Hidden paths patterns, for display. Folder names become +/// `**/name/`, the pattern that hides them at any depth. +pub fn built_in_patterns() -> Vec { + BUILT_IN_HIDDEN_DIRECTORIES + .iter() + .map(|name| format!("**/{name}/")) + .chain( + BUILT_IN_HIDDEN_FILE_PATTERNS + .iter() + .map(|p| (*p).to_string()), + ) + .collect() +} + +/// Effective built-in plus caller-supplied rules for excluding workspace paths. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VisibilityRules { + /// Folder names hidden at any depth, compared case-insensitively. + pub hidden_directories: Vec, + /// Name globs, path globs and folder globs, matched case-insensitively. + pub hidden_file_patterns: Vec, +} + +impl VisibilityRules { + /// Adds the built-in rules except those turned off by a `!` entry. + /// + /// Callers may already send built-in names; a turned-off entry is dropped from their lists + /// too, so the caller's copy cannot re-hide it. User entries keep their stored values; only + /// the effective list is normalized. + pub fn new(hidden_directories: Vec, hidden_files: Vec) -> Self { + let disabled = disabled_built_ins(&hidden_files); + let mut directories = BUILT_IN_HIDDEN_DIRECTORIES + .iter() + .map(|value| (*value).to_string()) + .collect::>(); + directories.extend(hidden_directories); + directories.retain(|name| !is_disabled_directory(name, &disabled)); + let mut files = BUILT_IN_HIDDEN_FILE_PATTERNS + .iter() + .map(|value| (*value).to_string()) + .collect::>(); + files.extend(hidden_files); + files.retain(|pattern| { + let pattern = pattern.trim(); + !pattern.starts_with('!') + && !disabled + .iter() + .any(|entry| entry.eq_ignore_ascii_case(pattern)) + }); + Self { + hidden_directories: normalize(directories), + hidden_file_patterns: normalize(files), + } + } + + /// Whether `name` is one of the effective hidden folder names. + pub fn is_hidden_directory_name(&self, name: &str) -> bool { + self.hidden_directories + .iter() + .any(|hidden| hidden.eq_ignore_ascii_case(name)) + } + + /// Whether a workspace-relative path is hidden. `path` uses `/` separators. + pub fn is_hidden(&self, path: &str, is_directory: bool) -> bool { + self.is_hidden_of_kind(path, Some(is_directory)) + } + + /// Like [`Self::is_hidden`] for a path whose kind is not known (`None`): every component is + /// treated as a possible folder, and file globs still apply to the last component. + pub fn is_hidden_of_kind(&self, path: &str, is_directory: Option) -> bool { + is_path_hidden( + &self.hidden_directories, + &self.hidden_file_patterns, + path, + is_directory, + ) + } + + /// Whether file events at `path` must still be delivered although the rules hide it. Only + /// the project configuration folder qualifies, and only for files outside the built-in noise + /// below it, such as `.lithe/run/local.json`. + pub fn is_watched_despite_hiding(&self, path: &str) -> bool { + let first = path.split('/').find(|value| !value.is_empty()); + if first != Some(CONFIGURATION_DIRECTORY) { + return false; + } + let noise = Self::new(Vec::new(), vec![format!("!{CONFIGURATION_PATTERN}")]); + !noise.is_hidden_of_kind(path, None) + } +} + +/// Matches one workspace-relative path against already effective rule lists (see +/// [`VisibilityRules::new`]). Borrowing the lists lets scans test every entry without copying. +pub fn is_path_hidden( + hidden_directories: &[String], + hidden_file_patterns: &[String], + path: &str, + is_directory: Option, +) -> bool { + let components = path + .split('/') + .filter(|value| !value.is_empty()) + .collect::>(); + let Some(last) = components.last().copied() else { + return false; + }; + // A folder name hides folders and everything below them. It also hides a file with that + // exact name, because a linked worktree's `.git` is a plain file and must stay out too. + let directory_count = components.len() - usize::from(is_directory == Some(false)); + if components.iter().any(|component| { + hidden_directories + .iter() + .any(|hidden| hidden.eq_ignore_ascii_case(component)) + }) { + return true; + } + let mut folder_globs = hidden_file_patterns + .iter() + .filter_map(|pattern| folder_glob(pattern)) + .peekable(); + if folder_globs.peek().is_some() { + // Each ancestor prefix is joined once and shared by every folder + // rule, instead of being rebuilt per rule. + let prefixes = (1..=directory_count) + .map(|count| components[..count].join("/")) + .collect::>(); + if folder_globs.any(|glob| { + prefixes + .iter() + .any(|prefix| path_glob_matches(glob, prefix)) + }) { + return true; + } + } + if is_directory == Some(true) { + return false; + } + hidden_file_patterns + .iter() + .any(|pattern| path_glob_matches(pattern, last) || path_glob_matches(pattern, path)) +} + +/// The entries after a leading `!`. Such an entry turns a built-in rule off instead of hiding +/// anything; it is never matched as a pattern itself. +pub fn disabled_built_ins(hidden_file_patterns: &[String]) -> Vec { + hidden_file_patterns + .iter() + .filter_map(|pattern| pattern.trim().strip_prefix('!')) + .map(|pattern| pattern.trim().to_string()) + .collect() +} + +/// A built-in folder is turned off by its displayed form `**/name/` or its bare name. +pub fn is_disabled_directory(name: &str, disabled: &[String]) -> bool { + disabled.iter().any(|entry| { + entry.eq_ignore_ascii_case(name) + || entry + .strip_prefix("**/") + .and_then(|rest| rest.strip_suffix('/')) + .is_some_and(|rest| rest.eq_ignore_ascii_case(name)) + }) +} + +/// Trims entries, drops empty ones and removes case-insensitive duplicates, keeping the first. +fn normalize(values: Vec) -> Vec { + let mut result = Vec::new(); + for value in values { + let normalized = value.trim().to_string(); + if normalized.is_empty() + || result + .iter() + .any(|existing: &String| existing.eq_ignore_ascii_case(&normalized)) + { + continue; + } + result.push(normalized); + } + result +} + +/// Matches a hidden-path glob, letting a leading `**/` stand for zero or more +/// folders, so `**/dist/` covers a root-level `dist` as well as `a/b/dist`. +/// +/// `*` already spans `/`, which covers the non-root case; the root case needs +/// the second attempt because the glob would otherwise demand a literal `/`. +fn path_glob_matches(pattern: &str, value: &str) -> bool { + glob_matches(pattern, value) + || pattern + .strip_prefix("**/") + .is_some_and(|rest| glob_matches(rest, value)) +} + +/// The folder glob of a hidden-path entry that ends in `/`, `/*` or `/**`. +/// +/// Such an entry names a folder from the project root (or at any depth when it +/// starts with `**/`), so the folder and everything below it is hidden instead +/// of leaving an empty folder behind. Other entries keep their file-only +/// meaning, as pinned by `shared/fixtures/search/exclusions.json`. +fn folder_glob(pattern: &str) -> Option<&str> { + let glob = pattern + .strip_suffix("/**") + .or_else(|| pattern.strip_suffix("/*")) + .or_else(|| pattern.strip_suffix('/'))?; + (!glob.is_empty()).then_some(glob) +} + +/// Case-insensitive glob where `*` spans any characters, including `/`, and `?` is one character. +pub fn glob_matches(pattern: &str, value: &str) -> bool { + let pattern = pattern.to_lowercase().chars().collect::>(); + let value = value.to_lowercase().chars().collect::>(); + let mut pattern_index = 0; + let mut value_index = 0; + let mut star_index = None; + let mut star_match = 0; + while value_index < value.len() { + if pattern_index < pattern.len() + && (pattern[pattern_index] == value[value_index] || pattern[pattern_index] == '?') + { + pattern_index += 1; + value_index += 1; + } else if pattern_index < pattern.len() && pattern[pattern_index] == '*' { + star_index = Some(pattern_index); + star_match = value_index; + pattern_index += 1; + } else if let Some(star) = star_index { + pattern_index = star + 1; + star_match += 1; + value_index = star_match; + } else { + return false; + } + } + while pattern_index < pattern.len() && pattern[pattern_index] == '*' { + pattern_index += 1; + } + pattern_index == pattern.len() +} + +/// One path to classify. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ClassifyEntry { + /// Path relative to the workspace root, with `/` separators. + path: String, + /// `true` for a folder, `false` for a file, absent when the host does not know. + #[serde(default)] + is_directory: Option, +} + +/// Payload of `workspace.classifyVisibility`. The rule lists are the user's own entries; the +/// built-in rules are added here. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ClassifyRequest { + #[serde(default)] + hidden_directory_names: Vec, + #[serde(default)] + hidden_file_patterns: Vec, + entries: Vec, +} + +/// Result of `workspace.classifyVisibility`; both vectors line up with the request entries. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct ClassifyResponse { + /// Whether the tree, search and history leave the entry out. + hidden: Vec, + /// Whether file events must still be delivered although the entry is hidden. + watched_despite_hiding: Vec, +} + +/// Result of `workspace.visibilityDefaults`. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct DefaultsResponse { + hidden_directory_names: Vec, + hidden_file_patterns: Vec, + built_in_patterns: Vec, + configuration_directory: &'static str, + configuration_pattern: &'static str, +} + +fn invalid(error: serde_json::Error) -> RuleError { + RuleError::new(RuleErrorCode::InvalidRequest, "Invalid visibility request") + .with_details(error.to_string()) +} + +/// Classifies every entry of the batch against one rule set. +pub fn classify_visibility_json(payload: Value) -> Result { + let request: ClassifyRequest = serde_json::from_value(payload).map_err(invalid)?; + let rules = VisibilityRules::new(request.hidden_directory_names, request.hidden_file_patterns); + let hidden = request + .entries + .iter() + .map(|entry| rules.is_hidden_of_kind(&entry.path, entry.is_directory)) + .collect::>(); + let watched_despite_hiding = request + .entries + .iter() + .zip(&hidden) + .map(|(entry, hidden)| *hidden && rules.is_watched_despite_hiding(&entry.path)) + .collect::>(); + serde_json::to_value(ClassifyResponse { + hidden, + watched_despite_hiding, + }) + .map_err(invalid) +} + +/// Returns the built-in lists so hosts can display them without keeping a copy. +pub fn visibility_defaults_json(_payload: Value) -> Result { + serde_json::to_value(DefaultsResponse { + hidden_directory_names: BUILT_IN_HIDDEN_DIRECTORIES + .iter() + .map(|name| (*name).to_string()) + .collect(), + hidden_file_patterns: BUILT_IN_HIDDEN_FILE_PATTERNS + .iter() + .map(|name| (*name).to_string()) + .collect(), + built_in_patterns: built_in_patterns(), + configuration_directory: CONFIGURATION_DIRECTORY, + configuration_pattern: CONFIGURATION_PATTERN, + }) + .map_err(invalid) +} diff --git a/rust/lithe-core-rules/tests/visibility_fixture.rs b/rust/lithe-core-rules/tests/visibility_fixture.rs new file mode 100644 index 0000000000..bb902b597b --- /dev/null +++ b/rust/lithe-core-rules/tests/visibility_fixture.rs @@ -0,0 +1,61 @@ +//! Drives the shared hidden-path fixture through the JSON envelope, exactly like the hosts do. + +use lithe_core_rules::execute_json; +use serde_json::{json, Value}; + +const FIXTURE: &str = include_str!("../../../shared/fixtures/workspace/visibility-v1.json"); + +fn call(command: &str, payload: Value) -> Value { + let request = json!({"id": "fixture", "command": command, "payload": payload}); + let response: Value = serde_json::from_str(&execute_json(&request.to_string())).unwrap(); + assert_eq!(response["ok"], true, "{response}"); + response["data"].clone() +} + +#[test] +fn defaults_match_the_fixture() { + let fixture: Value = serde_json::from_str(FIXTURE).unwrap(); + assert_eq!( + call("workspace.visibilityDefaults", json!({})), + fixture["defaults"] + ); +} + +#[test] +fn every_case_is_classified_in_one_batch() { + let fixture: Value = serde_json::from_str(FIXTURE).unwrap(); + for case in fixture["cases"].as_array().unwrap() { + let name = case["name"].as_str().unwrap(); + let entries = case["entries"].as_array().unwrap(); + let data = call( + "workspace.classifyVisibility", + json!({ + "hiddenDirectoryNames": case["hiddenDirectoryNames"], + "hiddenFilePatterns": case["hiddenFilePatterns"], + "entries": entries + .iter() + .map(|entry| json!({"path": entry["path"], "isDirectory": entry["isDirectory"]})) + .collect::>(), + }), + ); + for (index, entry) in entries.iter().enumerate() { + let label = format!("{name}: {}", entry["path"]); + assert_eq!(data["hidden"][index], entry["hidden"], "{label}"); + let watched = entry + .get("watchedDespiteHiding") + .cloned() + .unwrap_or(json!(false)); + assert_eq!(data["watchedDespiteHiding"][index], watched, "{label}"); + } + } +} + +#[test] +fn malformed_requests_use_core_error_codes() { + let response: Value = serde_json::from_str(&execute_json( + r#"{"id":"a","command":"workspace.classifyVisibility","payload":{"entries":[{"path":1}]}}"#, + )) + .unwrap(); + assert_eq!(response["ok"], false); + assert_eq!(response["error"]["code"], "invalid_request"); +} diff --git a/rust/lithe-core/src/diagnostics/request_trace.rs b/rust/lithe-core/src/diagnostics/request_trace.rs index a13cf3bf90..62cd2dc385 100644 --- a/rust/lithe-core/src/diagnostics/request_trace.rs +++ b/rust/lithe-core/src/diagnostics/request_trace.rs @@ -174,6 +174,8 @@ pub(crate) fn is_hot(method: &str) -> bool { "document.classifyText" | "document.lifecycle" | "markdown.render" + | "workspace.classifyVisibility" + | "workspace.visibilityDefaults" | "lsp.syncDocument" | "lsp.closeDocument" | "lsp.request" @@ -293,6 +295,7 @@ mod tests { "document.lifecycle", "workspace.search", "workspace.filterChanges", + "workspace.classifyVisibility", "git.classifyMetadataChanges", "debug.transport.handle", ] { diff --git a/rust/lithe-core/src/project/files.rs b/rust/lithe-core/src/project/files.rs index 99e0b03ca9..819a409964 100644 --- a/rust/lithe-core/src/project/files.rs +++ b/rust/lithe-core/src/project/files.rs @@ -16,31 +16,11 @@ use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; /// Default workspace exclusions, also used by Git discovery for generated directories. -pub(crate) const BUILT_IN_HIDDEN_DIRECTORIES: &[&str] = &[ - ".git", - ".svn", - ".worktree", - ".worktrees", - ".build", - ".swiftpm", - "node_modules", - // Python virtual environments and bytecode caches are dependency or generated - // output like `node_modules`. They hold tens of thousands of files that no - // snapshot consumer wants. Environments are matched by their conventional - // names only, so a differently named one still has to be hidden by the user. - ".venv", - "venv", - "__pycache__", - "target", - "build", - "DerivedData", - ".gradle", - ".next", - "dist", - "coverage", - "design-qa-artifacts", - ".artifacts", -]; +/// The list lives in `lithe-core-rules`, the only definition every host shares. +pub(crate) use lithe_core_rules::visibility::BUILT_IN_HIDDEN_DIRECTORIES; +use lithe_core_rules::visibility::{ + self as shared_visibility, disabled_built_ins, glob_matches, is_disabled_directory, +}; /// Whether an ordinary workspace event belongs to visible project inputs. /// Callers first relativize to the most specific explicitly watched root; native /// event-loss flags always bypass this filter and request recovery. @@ -88,29 +68,6 @@ pub fn filter_workspace_changes(request: WorkspaceChangeFilterRequest) -> Vec Vec { - hidden_file_patterns - .iter() - .filter_map(|pattern| pattern.trim().strip_prefix('!')) - .map(|pattern| pattern.trim().to_string()) - .collect() -} - -/// A built-in folder is turned off by its displayed form `**/name/` or its bare name. -fn is_disabled_directory(name: &str, disabled: &[String]) -> bool { - disabled.iter().any(|entry| { - entry.eq_ignore_ascii_case(name) - || entry - .strip_prefix("**/") - .and_then(|rest| rest.strip_suffix('/')) - .is_some_and(|rest| rest.eq_ignore_ascii_case(name)) - }) -} - -/// File names omitted from workspace snapshots, search, and related scans. -const BUILT_IN_HIDDEN_FILE_PATTERNS: &[&str] = &[".DS_Store", "*.pyc"]; const MAX_FILE_SIZE: u64 = 2 * 1024 * 1024; const MAX_OPEN_FILE_SIZE: u64 = 32 * 1024 * 1024; @@ -1289,7 +1246,11 @@ pub fn write_file(request: FileWriteRequest) -> Result, pub(crate) hidden_file_patterns: Vec, @@ -1299,32 +1260,11 @@ pub(crate) struct VisibilityRules { impl VisibilityRules { /// Adds the built-in rules except those turned off by a `!` entry. - /// - /// Callers may already send built-in names; a turned-off entry is dropped - /// from their lists too, so the caller's copy cannot re-hide it. pub(crate) fn new(hidden_directories: Vec, hidden_files: Vec) -> Self { - let disabled = disabled_built_ins(&hidden_files); - let mut directories = BUILT_IN_HIDDEN_DIRECTORIES - .iter() - .map(|value| (*value).to_string()) - .collect::>(); - directories.extend(hidden_directories); - directories.retain(|name| !is_disabled_directory(name, &disabled)); - let mut files = BUILT_IN_HIDDEN_FILE_PATTERNS - .iter() - .map(|value| (*value).to_string()) - .collect::>(); - files.extend(hidden_files); - files.retain(|pattern| { - let pattern = pattern.trim(); - !pattern.starts_with('!') - && !disabled - .iter() - .any(|entry| entry.eq_ignore_ascii_case(pattern)) - }); + let rules = shared_visibility::VisibilityRules::new(hidden_directories, hidden_files); Self { - hidden_directories: normalize(directories), - hidden_file_patterns: normalize(files), + hidden_directories: rules.hidden_directories, + hidden_file_patterns: rules.hidden_file_patterns, delegated_roots: Vec::new(), } } @@ -1352,73 +1292,15 @@ impl VisibilityRules { } pub(crate) fn is_hidden(&self, path: &str, is_directory: bool) -> bool { - let components = path - .split('/') - .filter(|value| !value.is_empty()) - .collect::>(); - if components.iter().any(|component| { - self.hidden_directories - .iter() - .any(|hidden| hidden.eq_ignore_ascii_case(component)) - }) { - return true; - } - let directory_count = components.len().saturating_sub(usize::from(!is_directory)); - let mut folder_globs = self - .hidden_file_patterns - .iter() - .filter_map(|pattern| folder_glob(pattern)) - .peekable(); - if folder_globs.peek().is_some() { - // Each ancestor prefix is joined once and shared by every folder - // rule, instead of being rebuilt per rule. - let prefixes = (1..=directory_count) - .map(|count| components[..count].join("/")) - .collect::>(); - if folder_globs.any(|glob| { - prefixes - .iter() - .any(|prefix| path_glob_matches(glob, prefix)) - }) { - return true; - } - } - if is_directory { - return false; - } - let last = components.last().copied().unwrap_or_default(); - self.hidden_file_patterns - .iter() - .any(|pattern| path_glob_matches(pattern, last) || path_glob_matches(pattern, path)) + shared_visibility::is_path_hidden( + &self.hidden_directories, + &self.hidden_file_patterns, + path, + Some(is_directory), + ) } } -/// Matches a hidden-path glob, letting a leading `**/` stand for zero or more -/// folders, so `**/dist/` covers a root-level `dist` as well as `a/b/dist`. -/// -/// `*` already spans `/`, which covers the non-root case; the root case needs -/// the second attempt because the glob would otherwise demand a literal `/`. -fn path_glob_matches(pattern: &str, value: &str) -> bool { - glob_matches(pattern, value) - || pattern - .strip_prefix("**/") - .is_some_and(|rest| glob_matches(rest, value)) -} - -/// The folder glob of a hidden-path entry that ends in `/`, `/*` or `/**`. -/// -/// Such an entry names a folder from the project root (or at any depth when it -/// starts with `**/`), so the folder and everything below it is hidden instead -/// of leaving an empty folder behind. Other entries keep their file-only -/// meaning, as pinned by `shared/fixtures/search/exclusions.json`. -fn folder_glob(pattern: &str) -> Option<&str> { - let glob = pattern - .strip_suffix("/**") - .or_else(|| pattern.strip_suffix("/*")) - .or_else(|| pattern.strip_suffix('/'))?; - (!glob.is_empty()).then_some(glob) -} - fn scan_node( path: &Path, root: &Path, @@ -1831,22 +1713,6 @@ pub(crate) fn relative_path(path: &Path, root: &Path) -> String { .to_string() } -fn normalize(values: Vec) -> Vec { - let mut result = Vec::new(); - for value in values { - let normalized = value.trim().to_string(); - if normalized.is_empty() - || result - .iter() - .any(|existing: &String| existing.eq_ignore_ascii_case(&normalized)) - { - continue; - } - result.push(normalized); - } - result -} - /// Matches IDEA's Preserve Case behavior for literal replacement text. /// /// All-uppercase matches uppercase the replacement, initial-uppercase matches @@ -1964,37 +1830,6 @@ fn file_mask_allows(masks: &[String], path: &str) -> bool { masks.iter().any(|mask| glob_matches(mask, name)) } -fn glob_matches(pattern: &str, value: &str) -> bool { - let pattern = pattern.to_lowercase().chars().collect::>(); - let value = value.to_lowercase().chars().collect::>(); - let mut pattern_index = 0; - let mut value_index = 0; - let mut star_index = None; - let mut star_match = 0; - while value_index < value.len() { - if pattern_index < pattern.len() - && (pattern[pattern_index] == value[value_index] || pattern[pattern_index] == '?') - { - pattern_index += 1; - value_index += 1; - } else if pattern_index < pattern.len() && pattern[pattern_index] == '*' { - star_index = Some(pattern_index); - star_match = value_index; - pattern_index += 1; - } else if let Some(star) = star_index { - pattern_index = star + 1; - star_match += 1; - value_index = star_match; - } else { - return false; - } - } - while pattern_index < pattern.len() && pattern[pattern_index] == '*' { - pattern_index += 1; - } - pattern_index == pattern.len() -} - /// Literal or regular-expression search semantics compiled for repeated matches. struct Matcher { plain_query: String, diff --git a/rust/lithe-core/src/protocol/command.rs b/rust/lithe-core/src/protocol/command.rs index cb617fa2c9..e4b7810722 100644 --- a/rust/lithe-core/src/protocol/command.rs +++ b/rust/lithe-core/src/protocol/command.rs @@ -413,6 +413,11 @@ pub enum CoreCommand { /// (`editor.workbenchState.reduce`). The rule lives in `lithe-core-rules` so the Windows frontend /// can run it synchronously inside store updates. EditorWorkbenchStateReduce, + /// Classifies a batch of workspace-relative paths as hidden or visible (`workspace.classifyVisibility`). + /// The rule lives in `lithe-core-rules` so the Windows frontend can run it synchronously. + WorkspaceClassifyVisibility, + /// Returns the built-in hidden-path lists (`workspace.visibilityDefaults`), also from `lithe-core-rules`. + WorkspaceVisibilityDefaults, /// Resolves terminal link candidates into file or external targets (`terminal.parseLinkTarget`). TerminalParseLinkTarget, /// Creates one transport-neutral Debug Adapter Protocol session (`debug.createSession`). @@ -1039,6 +1044,8 @@ impl CoreCommand { "keymap.analyzeConflicts" => Some(Self::KeymapAnalyzeConflicts), "keymap.modifierGestureStep" => Some(Self::KeymapModifierGestureStep), "editor.workbenchState.reduce" => Some(Self::EditorWorkbenchStateReduce), + "workspace.classifyVisibility" => Some(Self::WorkspaceClassifyVisibility), + "workspace.visibilityDefaults" => Some(Self::WorkspaceVisibilityDefaults), "terminal.parseLinkTarget" => Some(Self::TerminalParseLinkTarget), "debug.createSession" => Some(Self::DebugCreateSession), "debug.launch" => Some(Self::DebugLaunch), diff --git a/rust/lithe-core/src/runtime/dispatcher.rs b/rust/lithe-core/src/runtime/dispatcher.rs index 43b82cdcb9..6f6e579ea5 100644 --- a/rust/lithe-core/src/runtime/dispatcher.rs +++ b/rust/lithe-core/src/runtime/dispatcher.rs @@ -1548,6 +1548,18 @@ fn execute_parsed(mut parsed: CoreRequest) -> CoreResponse { Err(error) => CoreResponse::failure(id, error.into()), } } + CoreCommand::WorkspaceClassifyVisibility => { + match lithe_core_rules::execute("workspace.classifyVisibility", parsed.payload) { + Ok(data) => CoreResponse::success(id, data), + Err(error) => CoreResponse::failure(id, error.into()), + } + } + CoreCommand::WorkspaceVisibilityDefaults => { + match lithe_core_rules::execute("workspace.visibilityDefaults", parsed.payload) { + Ok(data) => CoreResponse::success(id, data), + Err(error) => CoreResponse::failure(id, error.into()), + } + } CoreCommand::TerminalParseLinkTarget => { let result = serde_json::from_value::(parsed.payload) diff --git a/rust/lithe-core/src/tests/protocol.rs b/rust/lithe-core/src/tests/protocol.rs index 689cefc38a..3d3bb7fde5 100644 --- a/rust/lithe-core/src/tests/protocol.rs +++ b/rust/lithe-core/src/tests/protocol.rs @@ -472,6 +472,40 @@ fn workbench_state_fixture_runs_through_core_dispatch() { assert_eq!(rejected["error"]["code"], "invalid_request"); } +#[test] +fn visibility_fixture_crosses_the_core_json_boundary() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../shared/fixtures/workspace/visibility-v1.json" + )) + .unwrap(); + let defaults = call_fixture_command("workspace.visibilityDefaults", serde_json::json!({})); + assert_eq!(defaults["data"], fixture["defaults"]); + for case in fixture["cases"].as_array().unwrap() { + let entries = case["entries"].as_array().unwrap(); + let result = call_fixture_command( + "workspace.classifyVisibility", + serde_json::json!({ + "hiddenDirectoryNames": case["hiddenDirectoryNames"], + "hiddenFilePatterns": case["hiddenFilePatterns"], + "entries": entries + .iter() + .map(|entry| serde_json::json!({ + "path": entry["path"], "isDirectory": entry["isDirectory"], + })) + .collect::>(), + }), + ); + assert_eq!(result["ok"], true, "{}: {result}", case["name"]); + for (index, entry) in entries.iter().enumerate() { + assert_eq!( + result["data"]["hidden"][index], entry["hidden"], + "{}: {}", + case["name"], entry["path"] + ); + } + } +} + #[test] fn every_rules_crate_command_is_routed_by_core() { for command in lithe_core_rules::COMMANDS { diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index 9caf4b61e5..8256aac7db 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -182,6 +182,19 @@ hosts prune their own layout tree; split directions, ratios, pixels and dragging through the wasm build inside zustand `set()`, one call per user action. Both hosts run every case of `shared/fixtures/editor/workbench-state-v1.json` through it. +`workspace.classifyVisibility` accepts `{hiddenDirectoryNames?, hiddenFilePatterns?, entries: [{path, isDirectory?}]}` +and returns `{hidden: boolean[], watchedDespiteHiding: boolean[]}` with one answer per entry, in request order. `path` +is workspace-relative with `/` separators; `isDirectory` is `true` for a folder, `false` for a file and omitted when the +host does not know (every component may then be a folder). The two lists are the user's own entries: Core adds the +built-in folders and patterns, and a `!entry` turns a built-in off. Matching ignores case. A folder name hides the folder, +everything below it and a file with that exact name (a linked worktree's `.git` file). `hidden` means the tree, search and +Local History leave the path out; `watchedDespiteHiding` marks the hidden project configuration folder (`.lithe`) whose file +events must still be delivered, except the built-in noise below it such as `.lithe/run/local.json`. Hosts send all children +of a directory in one call. `workspace.visibilityDefaults` takes `{}` and returns the built-in lists +(`hiddenDirectoryNames`, `hiddenFilePatterns`, `builtInPatterns`, `configurationDirectory`, `configurationPattern`) so hosts +display them without keeping a copy. Both live in `rust/lithe-core-rules` like `keymap.modifierGestureStep`, run in wasm on +Windows, and are tested against `shared/fixtures/workspace/visibility-v1.json`. + ## Terminal link targets `terminal.parseLinkTarget` accepts `{texts: string[], baseDirectory?, homeDirectory?, providerSchemes?: string[]}` diff --git a/shared/fixtures/workspace/visibility-v1.json b/shared/fixtures/workspace/visibility-v1.json new file mode 100644 index 0000000000..a126609525 --- /dev/null +++ b/shared/fixtures/workspace/visibility-v1.json @@ -0,0 +1,589 @@ +{ + "description": "Hidden-path rules shared by Rust Core, macOS and Windows. isDirectory is true for a folder, false for a file and null when the host does not know. hidden means the tree, search and history leave the path out; watchedDespiteHiding marks hidden paths whose file events must still be delivered. Rule lists are the user's own entries; built-ins are added by Core.", + "defaults": { + "hiddenDirectoryNames": [ + ".git", + ".svn", + ".worktree", + ".worktrees", + ".build", + ".swiftpm", + "node_modules", + ".venv", + "venv", + "__pycache__", + "target", + "build", + "DerivedData", + ".gradle", + ".next", + "dist", + "coverage", + "design-qa-artifacts", + ".artifacts" + ], + "hiddenFilePatterns": [ + ".DS_Store", + "*.pyc", + ".lithe/**", + ".lithe/run/local.json" + ], + "builtInPatterns": [ + "**/.git/", + "**/.svn/", + "**/.worktree/", + "**/.worktrees/", + "**/.build/", + "**/.swiftpm/", + "**/node_modules/", + "**/.venv/", + "**/venv/", + "**/__pycache__/", + "**/target/", + "**/build/", + "**/DerivedData/", + "**/.gradle/", + "**/.next/", + "**/dist/", + "**/coverage/", + "**/design-qa-artifacts/", + "**/.artifacts/", + ".DS_Store", + "*.pyc", + ".lithe/**", + ".lithe/run/local.json" + ], + "configurationDirectory": ".lithe", + "configurationPattern": ".lithe/**" + }, + "cases": [ + { + "name": "built-in folders are hidden at any depth, as folders and as ancestors", + "hiddenDirectoryNames": [], + "hiddenFilePatterns": [], + "entries": [ + { + "path": ".git", + "isDirectory": true, + "hidden": true + }, + { + "path": "node_modules", + "isDirectory": true, + "hidden": true + }, + { + "path": "target", + "isDirectory": true, + "hidden": true + }, + { + "path": "a/b/node_modules", + "isDirectory": true, + "hidden": true + }, + { + "path": "node_modules/left-pad/index.js", + "isDirectory": false, + "hidden": true + }, + { + "path": "packages/app/target/classes/A.class", + "isDirectory": false, + "hidden": true + }, + { + "path": "src", + "isDirectory": true, + "hidden": false + }, + { + "path": "src/main/App.java", + "isDirectory": false, + "hidden": false + }, + { + "path": "README.md", + "isDirectory": false, + "hidden": false + } + ] + }, + { + "name": "a file named like a built-in folder is hidden too, such as a linked worktree's .git", + "hiddenDirectoryNames": [], + "hiddenFilePatterns": [], + "entries": [ + { + "path": "build", + "isDirectory": false, + "hidden": true + }, + { + "path": "scripts/target", + "isDirectory": false, + "hidden": true + }, + { + "path": "build", + "isDirectory": true, + "hidden": true + }, + { + "path": "src/dist", + "isDirectory": true, + "hidden": true + }, + { + "path": "build/out.txt", + "isDirectory": false, + "hidden": true + }, + { + "path": "build.gradle", + "isDirectory": false, + "hidden": false + }, + { + "path": "app/.git", + "isDirectory": false, + "hidden": true + }, + { + "path": "builds", + "isDirectory": false, + "hidden": false + } + ] + }, + { + "name": "built-in file patterns", + "hiddenDirectoryNames": [], + "hiddenFilePatterns": [], + "entries": [ + { + "path": ".DS_Store", + "isDirectory": false, + "hidden": true + }, + { + "path": "src/.DS_Store", + "isDirectory": false, + "hidden": true + }, + { + "path": "mod/__init__.pyc", + "isDirectory": false, + "hidden": true + }, + { + "path": "a/b/c.pyc", + "isDirectory": false, + "hidden": true + }, + { + "path": "main.py", + "isDirectory": false, + "hidden": false + }, + { + "path": ".DS_Store", + "isDirectory": true, + "hidden": false + } + ] + }, + { + "name": "matching ignores case on every platform", + "hiddenDirectoryNames": [], + "hiddenFilePatterns": [ + "*.LOG" + ], + "entries": [ + { + "path": "Node_Modules/x.js", + "isDirectory": false, + "hidden": true + }, + { + "path": "SRC/TARGET", + "isDirectory": true, + "hidden": true + }, + { + "path": "App/.GIT/config", + "isDirectory": false, + "hidden": true + }, + { + "path": "run.log", + "isDirectory": false, + "hidden": true + }, + { + "path": "Run.Log", + "isDirectory": false, + "hidden": true + }, + { + "path": "src/.ds_store", + "isDirectory": false, + "hidden": true + } + ] + }, + { + "name": "user folder names and globs add to the built-ins", + "hiddenDirectoryNames": [ + "generated", + "Out" + ], + "hiddenFilePatterns": [ + "*.generated.swift", + "docs/**", + "cache/", + "**/tmp/", + "notes", + "?.tmp" + ], + "entries": [ + { + "path": "generated", + "isDirectory": true, + "hidden": true + }, + { + "path": "src/generated/A.swift", + "isDirectory": false, + "hidden": true + }, + { + "path": "OUT", + "isDirectory": true, + "hidden": true + }, + { + "path": "Foo.generated.swift", + "isDirectory": false, + "hidden": true + }, + { + "path": "src/Foo.generated.swift", + "isDirectory": false, + "hidden": true + }, + { + "path": "docs", + "isDirectory": true, + "hidden": true + }, + { + "path": "docs/guide/a.md", + "isDirectory": false, + "hidden": true + }, + { + "path": "src/docs", + "isDirectory": true, + "hidden": false + }, + { + "path": "cache", + "isDirectory": true, + "hidden": true + }, + { + "path": "cache/x/y.txt", + "isDirectory": false, + "hidden": true + }, + { + "path": "src/cache", + "isDirectory": true, + "hidden": false + }, + { + "path": "tmp", + "isDirectory": true, + "hidden": true + }, + { + "path": "a/b/tmp", + "isDirectory": true, + "hidden": true + }, + { + "path": "a/b/tmp/f", + "isDirectory": false, + "hidden": true + }, + { + "path": "notes", + "isDirectory": false, + "hidden": true + }, + { + "path": "src/notes", + "isDirectory": false, + "hidden": true + }, + { + "path": "notes", + "isDirectory": true, + "hidden": false + }, + { + "path": "a.tmp", + "isDirectory": false, + "hidden": true + }, + { + "path": "ab.tmp", + "isDirectory": false, + "hidden": false + }, + { + "path": "src/Main.swift", + "isDirectory": false, + "hidden": false + } + ] + }, + { + "name": "a bang entry turns a built-in rule off, by name or by displayed form", + "hiddenDirectoryNames": [ + "build" + ], + "hiddenFilePatterns": [ + "!node_modules", + "!**/target/", + "!*.pyc" + ], + "entries": [ + { + "path": "node_modules/x.js", + "isDirectory": false, + "hidden": false + }, + { + "path": "target", + "isDirectory": true, + "hidden": false + }, + { + "path": "a/target/x", + "isDirectory": false, + "hidden": false + }, + { + "path": "mod/a.pyc", + "isDirectory": false, + "hidden": false + }, + { + "path": "dist", + "isDirectory": true, + "hidden": true + }, + { + "path": "build", + "isDirectory": true, + "hidden": true + }, + { + "path": ".git", + "isDirectory": true, + "hidden": true + }, + { + "path": ".DS_Store", + "isDirectory": false, + "hidden": true + } + ] + }, + { + "name": "an unknown kind treats every component as a possible folder", + "hiddenDirectoryNames": [], + "hiddenFilePatterns": [ + "notes", + "cache/" + ], + "entries": [ + { + "path": "build", + "isDirectory": null, + "hidden": true + }, + { + "path": "src/build", + "isDirectory": null, + "hidden": true + }, + { + "path": "notes", + "isDirectory": null, + "hidden": true + }, + { + "path": "cache", + "isDirectory": null, + "hidden": true + }, + { + "path": "src/Main.swift", + "isDirectory": null, + "hidden": false + }, + { + "path": "", + "isDirectory": null, + "hidden": false + } + ] + }, + { + "name": "the project configuration folder is hidden but its events stay watched", + "hiddenDirectoryNames": [], + "hiddenFilePatterns": [], + "entries": [ + { + "path": ".lithe", + "isDirectory": true, + "hidden": true, + "watchedDespiteHiding": true + }, + { + "path": ".lithe/project.json", + "isDirectory": false, + "hidden": true, + "watchedDespiteHiding": true + }, + { + "path": ".lithe/run/configs.json", + "isDirectory": false, + "hidden": true, + "watchedDespiteHiding": true + }, + { + "path": ".lithe/run/local.json", + "isDirectory": false, + "hidden": true + }, + { + "path": "src/.lithe/project.json", + "isDirectory": false, + "hidden": false + }, + { + "path": "src/.lithe", + "isDirectory": true, + "hidden": false + } + ] + }, + { + "name": "turning the configuration pattern off shows the folder but keeps the local run noise hidden", + "hiddenDirectoryNames": [], + "hiddenFilePatterns": [ + "!.lithe/**" + ], + "entries": [ + { + "path": ".lithe", + "isDirectory": true, + "hidden": false + }, + { + "path": ".lithe/project.json", + "isDirectory": false, + "hidden": false + }, + { + "path": ".lithe/run/local.json", + "isDirectory": false, + "hidden": true + } + ] + }, + { + "name": "surrounding whitespace and duplicate entries do not change the answer", + "hiddenDirectoryNames": [ + " generated ", + "GENERATED", + "" + ], + "hiddenFilePatterns": [ + " *.log ", + "*.LOG", + " " + ], + "entries": [ + { + "path": "generated", + "isDirectory": true, + "hidden": true + }, + { + "path": "a.log", + "isDirectory": false, + "hidden": true + }, + { + "path": "a.txt", + "isDirectory": false, + "hidden": false + } + ] + }, + { + "name": "batch: every child of one folder is classified by one request", + "hiddenDirectoryNames": [], + "hiddenFilePatterns": [ + "*.log" + ], + "entries": [ + { + "path": "app/.git", + "isDirectory": true, + "hidden": true + }, + { + "path": "app/.DS_Store", + "isDirectory": false, + "hidden": true + }, + { + "path": "app/node_modules", + "isDirectory": true, + "hidden": true + }, + { + "path": "app/target", + "isDirectory": true, + "hidden": true + }, + { + "path": "app/src", + "isDirectory": true, + "hidden": false + }, + { + "path": "app/pom.xml", + "isDirectory": false, + "hidden": false + }, + { + "path": "app/debug.log", + "isDirectory": false, + "hidden": true + }, + { + "path": "app/.env", + "isDirectory": false, + "hidden": false + } + ] + } + ] +} diff --git a/shared/platform-feature-matrix/features/file-explorer.json b/shared/platform-feature-matrix/features/file-explorer.json index 1433829a8e..c8935702e1 100644 --- a/shared/platform-feature-matrix/features/file-explorer.json +++ b/shared/platform-feature-matrix/features/file-explorer.json @@ -31,7 +31,13 @@ "shared/fixtures/workspace/change-filter-v1.json", "macos/Tests/LitheTests/FileTypeIconKindTests.swift", "macos/Resources/IDEAIcons/fileTypes/custom.svg", - "macos/Resources/IDEAIcons/fileTypes/i18n.svg" + "macos/Resources/IDEAIcons/fileTypes/i18n.svg", + "rust/lithe-core-rules/src/visibility.rs", + "macos/Sources/LitheCoreContracts/Workspace/FileVisibilityRules.swift", + "macos/Sources/LitheCoreContracts/Workspace/FileVisibilityEngine.swift", + "macos/Sources/Lithe/Core/Rust/RustFileVisibilityEngine.swift", + "macos/Tests/LitheTests/FileVisibilityFixtureTests.swift", + "shared/fixtures/workspace/visibility-v1.json" ], "implementationStatus": "implemented", "verificationStatus": "pending" @@ -57,12 +63,17 @@ "rust/lithe-core/src/project/files.rs", "shared/fixtures/workspace/change-filter-v1.json", "windows/tauri/src/extensions/bundled/icon-themes/idea/extension.json", - "windows/tauri/src/extensions/bundled-extension-manifests.test.ts" + "windows/tauri/src/extensions/bundled-extension-manifests.test.ts", + "rust/lithe-core-rules/src/visibility.rs", + "windows/tauri/src/features/file-explorer/lib/file-tree-visibility.ts", + "windows/tauri/src/features/file-explorer/lib/file-tree-visibility.test.ts", + "windows/tauri/src/features/settings/lib/hidden-path-rules.ts", + "shared/fixtures/workspace/visibility-v1.json" ], "implementationStatus": "implemented", "verificationStatus": "pending" }, "owner": "Workspace", - "verification": "浏览目录、展开/折叠、打开资源并在文件变更后刷新树。 macOS 文件树、标签页与面包屑共用 TOML 文件图标:亮色使用 IDEA New UI 官方浅底蓝色 T,深色使用深底蓝色 T;检查明暗主题切换后选择对应 SVG,保留原始 16pt 几何与颜色。 点击目录名称后上下键按可见行移动选择,右键展开目录并再次右键进入首个子项,左键折叠或返回显示父项;双击目录名称展开/折叠,单击与修饰键多选保持独立。验证压缩 Java 包、列表首尾、Shift 上下范围选择与自动滚动;方向键不送到编辑器,点击编辑器或转移到搜索输入框后恢复其按键处理。 Windows Project 文件树滚轮与 Git Log 共用 Community Windows 默认 200ms 惯性过渡;核对连续同向、反向、Shift 横向滚动、顶底边界、键盘定位与滑块拖动取消惯性、卸载帧清理和减少动态效果偏好;虚拟树保持可见区呈现,真实 WebView2 实机验收 pending。 macOS 项目文件和目录选中行共用 LitheTheme.Tree 的主题聚焦色与灰色失焦色;点击编辑器、其他工具窗口后失焦,键盘转移到搜索输入框尚未输入时失焦,重新点击树恢复,整个窗口失焦时保持灰色。深浅主题、非默认主题、多选、上下键和右键菜单均需核对;组件像素和焦点回归不能替代完整应用验收。 Windows 在默认及舒适密度下比较 Project 与 Dependencies 树正文,再修改界面字号确认同步;深浅主题检查刷新图标为循环箭头,点击仍刷新目录。 Windows 打开深层文件并折叠祖先,点击定位当前文件,检查展开后滚动到目标行;滚动离开后重复点击同一文件仍定位;编辑器切换页签不得让树自动滚动。延迟目录加载完成后再滚动,旧请求完成不改变新位置;树内搜索和 Dependencies 视图下定位应回到完整 Project 树。 Windows 搜索首次命中和 Enter 切换后复制/剪切应使用当前命中;同时选择父目录、后代与其他文件后右键后代,复制/删除仍处理整个选择且父子只操作一次。原生移动等待期间关闭 Project 或切换工作区,成功项同步原工作区并停止后续项。受控 WSL/远程/POSIX 路径用例保留大小写不同的文件与目录,Windows 本地/普通 UNC 同名目标继续拒绝覆盖;这些用例不代表已开放 WSL/远程功能,真实 WebView2 工作流仍 pending。 Windows 编辑器保持 README 打开时点击目录名称,通过真实 FileExplorerPane、侧栏与缓冲区 store 验证路径同步往返后目录仍被选中,复制/剪切、键盘焦点和 Shift 范围锚点仍属于目录;实际切换编辑文件时单选跟随,明确多选保留。 Windows 批量拖动分别在目录预检、原生移动和原工作区同步等待期间切换 A→B→A,确认旧批次不再派发剩余项,已完成项仍同步原工作区;普通重渲染继续当前批次,旧移动失败不清除新拖动或显示过期错误。 macOS 打开含 .claude/worktrees 等嵌套 Git linked worktree 与 .artifacts 的仓库:项目树、文件列表与搜索索引不包含这些目录,子模块和位于主检出之外的工作树仍显示;嵌套工作树内的文件变化不触发刷新。外部新增/删除普通文件时树原地增删、不重新扫描整个工作区,新目录、压缩包结构变化时回退完整快照;定位当前文件按路径逐级查找,侧栏渲染不再遍历全部文件。 macOS 项目树按需列目录:快照只返回文件清单,目录展开时才列出下一层;检查首次打开大仓库只显示根层,展开、折叠后再展开、源码根下压缩 Java 包与全量构建一致;外部改动未展开目录下的文件不改变树,展开后可见;重新扫描后已展开目录保持展开并显示最新内容;定位当前文件会逐级加载祖先后滚动到目标行;新增或删除文件后先展开目录再处理监听事件,确认搜索等文件库存仍同步;压缩包重列期间发生其它文件增删,完成后库存和树都保留最新结果。 macOS 项目树同级排序对齐 IDEA 项目视图:目录先于文件,名称按自然顺序(数字按数值、忽略大小写、'-' 与 '_' 相邻);打开含 docs/releases/v0.1.4.md、v0.1.12.md、v0.1.14.md 的仓库,确认 v0.1.4 在 v0.1.12 之前,外部新建 v0.1.5.md 后原地插入到二者之间,与重新扫描的顺序一致。 外部构建产生超过 512 条 target/build 事件且混有源码变化时,只转发有效事件,不触发输出洪峰导致的整项目重扫;显式文件/生成源码监听、项目根名为 build、跨排除边界重命名均保留,系统丢失事件始终触发恢复重扫。 Windows 同时粘贴同名文件不覆盖彼此;剪切期间再次复制相同或不同内容,旧粘贴完成不得清除新剪贴板。 Windows 文件树单击文件只选中并高亮,不打开也不新建预览页签;双击或按 Enter 才打开,单击目录同样只选中。 Windows 文件树选中文件、目录或多选后按 Delete(无修饰键)总是弹出删除确认,即使“删除前确认”设置关闭;取消不删除,确认后按选择集合逐项删除,工作区根目录以及 Shift/Ctrl/Alt+Delete 不触发。 项目树、标签页和面包屑的 IDEA 文件图标:两端 .jsonl/.ndjson 用 JSON 图标,.wbmp 用图片图标,.doc/.docx/.ppt/.pptx 用 IDEA Native 类型的 custom 图标,.strings 用 i18n 图标;Windows IDEA Icons 主题另补齐 macOS 已识别的 Kotlin、Rust、Go、Python、Ruby、Scala、PHP 等扩展名和 .classpath、.gitattributes、docker-compose 等文件名,.kts 用 Kotlin 图标而 .gradle.kts 仍用 Gradle 图标;检查明暗主题均显示对应 SVG,实机 pending。 Windows 编辑器打开文件的路径与项目树根目录的分隔符或盘符大小写不同(例如经快速打开得到的正斜杠路径对反斜杠根目录)时,点击定位当前文件同样展开祖先、选中并滚动到目标行;真实 WebView2 实机验收 pending。", - "notes": "Windows 补齐 Ctrl/Shift 多选、批量复制/剪切/删除、已选集合拖动、Project/Dependencies 切换、定位当前文件、刷新和隐藏。目录名称单击选择,双击或箭头展开;长名称水平滚动。目录标记按工作区持久化,并随移动、重命名和删除迁移;本地 Windows/UNC 按不区分大小写处理,POSIX、WSL 和远程标识保留大小写并隔离工作区。批量移动预检父子重复及重名目标,原生发布拒绝覆盖;真实 WebView2 的拖动与焦点验收 pending。 Windows 树正文统一使用 ui-text-sm(默认 13px),随界面字号设置变化,舒适密度不单独放大 Dependencies 等树行;Project 刷新按钮使用 IDEA refresh.svg。手动定位使用有代次的展开后滚动请求;编辑器切换页签不再自动跟随滚动(autoRevealActiveFileInFileTree 设置已退役);定位清除树内搜索并切回 Project,保持虚拟列表。 搜索导航同步选择与锚点,右键成员判断保留原始选择集合,批量目标再去除父子重复;已完成移动不因面板卸载或工作区切换而漏掉原工作区同步。路径去重与拖动预检按文件系统大小写语义比较,包含 WSL UNC 别名。编辑器活动文件变化时单选同步高亮、复制/剪切目标及范围锚点,明确多选保留;使用同步 Hook 的真实编辑器目标判断切换,选择目录引发的共享侧栏路径往返不改变选择;真实面板与 store 回归覆盖此链路。 批量移动按每次工作区访问识别生命周期,相同根路径的再次访问不能复活旧批次;预检、移动、同步和失败回调共用该守卫。 macOS 快照跳过嵌套在自身仓库中的 linked worktree(.git 文件指向 <主仓库>/.git/worktrees/*)并内置隐藏 .artifacts;项目树由 workspace.listDirectories 按需逐层列出,children 为 nil 表示目录未加载;平铺文件清单 projectFiles 仍完整保留供搜索等功能使用。 macOS 项目树同级排序由 Rust Core 的 visible_children 与 Swift 的 NaturalNameOrder 共同实现 IDEA FileNameComparator(共享 fixture file-name-order-v1.json);快照 files 与搜索索引按 ChildOrder::Traversal 保持原有的小写排序(Windows 快速打开取前 100 项依赖它),只有 workspace.listDirectories 使用自然顺序。Windows 项目文件树仍使用 localeCompare,Git 文件树已使用同源的 compareGitFileNames,Windows 项目树对齐待后续处理。 Windows 文件树支持 Delete 键删除(始终二次确认),macOS 文件树暂无键盘删除,仅右键“移到废纸篓”。 Windows 定位当前文件先用 toRootedPath 把活动路径改写成树根目录的前缀与分隔符写法,再展开祖先并滚动;此前编辑器路径与树节点写法不同会因精确字符串匹配而静默无反应。" + "verification": "浏览目录、展开/折叠、打开资源并在文件变更后刷新树。 macOS 文件树、标签页与面包屑共用 TOML 文件图标:亮色使用 IDEA New UI 官方浅底蓝色 T,深色使用深底蓝色 T;检查明暗主题切换后选择对应 SVG,保留原始 16pt 几何与颜色。 点击目录名称后上下键按可见行移动选择,右键展开目录并再次右键进入首个子项,左键折叠或返回显示父项;双击目录名称展开/折叠,单击与修饰键多选保持独立。验证压缩 Java 包、列表首尾、Shift 上下范围选择与自动滚动;方向键不送到编辑器,点击编辑器或转移到搜索输入框后恢复其按键处理。 Windows Project 文件树滚轮与 Git Log 共用 Community Windows 默认 200ms 惯性过渡;核对连续同向、反向、Shift 横向滚动、顶底边界、键盘定位与滑块拖动取消惯性、卸载帧清理和减少动态效果偏好;虚拟树保持可见区呈现,真实 WebView2 实机验收 pending。 macOS 项目文件和目录选中行共用 LitheTheme.Tree 的主题聚焦色与灰色失焦色;点击编辑器、其他工具窗口后失焦,键盘转移到搜索输入框尚未输入时失焦,重新点击树恢复,整个窗口失焦时保持灰色。深浅主题、非默认主题、多选、上下键和右键菜单均需核对;组件像素和焦点回归不能替代完整应用验收。 Windows 在默认及舒适密度下比较 Project 与 Dependencies 树正文,再修改界面字号确认同步;深浅主题检查刷新图标为循环箭头,点击仍刷新目录。 Windows 打开深层文件并折叠祖先,点击定位当前文件,检查展开后滚动到目标行;滚动离开后重复点击同一文件仍定位;编辑器切换页签不得让树自动滚动。延迟目录加载完成后再滚动,旧请求完成不改变新位置;树内搜索和 Dependencies 视图下定位应回到完整 Project 树。 Windows 搜索首次命中和 Enter 切换后复制/剪切应使用当前命中;同时选择父目录、后代与其他文件后右键后代,复制/删除仍处理整个选择且父子只操作一次。原生移动等待期间关闭 Project 或切换工作区,成功项同步原工作区并停止后续项。受控 WSL/远程/POSIX 路径用例保留大小写不同的文件与目录,Windows 本地/普通 UNC 同名目标继续拒绝覆盖;这些用例不代表已开放 WSL/远程功能,真实 WebView2 工作流仍 pending。 Windows 编辑器保持 README 打开时点击目录名称,通过真实 FileExplorerPane、侧栏与缓冲区 store 验证路径同步往返后目录仍被选中,复制/剪切、键盘焦点和 Shift 范围锚点仍属于目录;实际切换编辑文件时单选跟随,明确多选保留。 Windows 批量拖动分别在目录预检、原生移动和原工作区同步等待期间切换 A→B→A,确认旧批次不再派发剩余项,已完成项仍同步原工作区;普通重渲染继续当前批次,旧移动失败不清除新拖动或显示过期错误。 macOS 打开含 .claude/worktrees 等嵌套 Git linked worktree 与 .artifacts 的仓库:项目树、文件列表与搜索索引不包含这些目录,子模块和位于主检出之外的工作树仍显示;嵌套工作树内的文件变化不触发刷新。外部新增/删除普通文件时树原地增删、不重新扫描整个工作区,新目录、压缩包结构变化时回退完整快照;定位当前文件按路径逐级查找,侧栏渲染不再遍历全部文件。 macOS 项目树按需列目录:快照只返回文件清单,目录展开时才列出下一层;检查首次打开大仓库只显示根层,展开、折叠后再展开、源码根下压缩 Java 包与全量构建一致;外部改动未展开目录下的文件不改变树,展开后可见;重新扫描后已展开目录保持展开并显示最新内容;定位当前文件会逐级加载祖先后滚动到目标行;新增或删除文件后先展开目录再处理监听事件,确认搜索等文件库存仍同步;压缩包重列期间发生其它文件增删,完成后库存和树都保留最新结果。 macOS 项目树同级排序对齐 IDEA 项目视图:目录先于文件,名称按自然顺序(数字按数值、忽略大小写、'-' 与 '_' 相邻);打开含 docs/releases/v0.1.4.md、v0.1.12.md、v0.1.14.md 的仓库,确认 v0.1.4 在 v0.1.12 之前,外部新建 v0.1.5.md 后原地插入到二者之间,与重新扫描的顺序一致。 外部构建产生超过 512 条 target/build 事件且混有源码变化时,只转发有效事件,不触发输出洪峰导致的整项目重扫;显式文件/生成源码监听、项目根名为 build、跨排除边界重命名均保留,系统丢失事件始终触发恢复重扫。 Windows 同时粘贴同名文件不覆盖彼此;剪切期间再次复制相同或不同内容,旧粘贴完成不得清除新剪贴板。 Windows 文件树单击文件只选中并高亮,不打开也不新建预览页签;双击或按 Enter 才打开,单击目录同样只选中。 Windows 文件树选中文件、目录或多选后按 Delete(无修饰键)总是弹出删除确认,即使“删除前确认”设置关闭;取消不删除,确认后按选择集合逐项删除,工作区根目录以及 Shift/Ctrl/Alt+Delete 不触发。 项目树、标签页和面包屑的 IDEA 文件图标:两端 .jsonl/.ndjson 用 JSON 图标,.wbmp 用图片图标,.doc/.docx/.ppt/.pptx 用 IDEA Native 类型的 custom 图标,.strings 用 i18n 图标;Windows IDEA Icons 主题另补齐 macOS 已识别的 Kotlin、Rust、Go、Python、Ruby、Scala、PHP 等扩展名和 .classpath、.gitattributes、docker-compose 等文件名,.kts 用 Kotlin 图标而 .gradle.kts 仍用 Gradle 图标;检查明暗主题均显示对应 SVG,实机 pending。 Windows 编辑器打开文件的路径与项目树根目录的分隔符或盘符大小写不同(例如经快速打开得到的正斜杠路径对反斜杠根目录)时,点击定位当前文件同样展开祖先、选中并滚动到目标行;真实 WebView2 实机验收 pending。 隐藏路径规则只有一份实现:Core 的 workspace.classifyVisibility 与 workspace.visibilityDefaults(rust/lithe-core-rules),macOS 经 FFI、Windows 经 wasm 调用,共享 fixture visibility-v1.json 同时驱动 Rust、macOS 和 Windows 测试。打开含 node_modules、target、.git 的工程,两端文件树都不显示这些目录;在设置里添加自定义目录或 glob、用 ! 关闭内置规则后,树、搜索和本地历史一致;文件树只批量按目录分类,不逐文件调用。", + "notes": "Windows 补齐 Ctrl/Shift 多选、批量复制/剪切/删除、已选集合拖动、Project/Dependencies 切换、定位当前文件、刷新和隐藏。目录名称单击选择,双击或箭头展开;长名称水平滚动。目录标记按工作区持久化,并随移动、重命名和删除迁移;本地 Windows/UNC 按不区分大小写处理,POSIX、WSL 和远程标识保留大小写并隔离工作区。批量移动预检父子重复及重名目标,原生发布拒绝覆盖;真实 WebView2 的拖动与焦点验收 pending。 Windows 树正文统一使用 ui-text-sm(默认 13px),随界面字号设置变化,舒适密度不单独放大 Dependencies 等树行;Project 刷新按钮使用 IDEA refresh.svg。手动定位使用有代次的展开后滚动请求;编辑器切换页签不再自动跟随滚动(autoRevealActiveFileInFileTree 设置已退役);定位清除树内搜索并切回 Project,保持虚拟列表。 搜索导航同步选择与锚点,右键成员判断保留原始选择集合,批量目标再去除父子重复;已完成移动不因面板卸载或工作区切换而漏掉原工作区同步。路径去重与拖动预检按文件系统大小写语义比较,包含 WSL UNC 别名。编辑器活动文件变化时单选同步高亮、复制/剪切目标及范围锚点,明确多选保留;使用同步 Hook 的真实编辑器目标判断切换,选择目录引发的共享侧栏路径往返不改变选择;真实面板与 store 回归覆盖此链路。 批量移动按每次工作区访问识别生命周期,相同根路径的再次访问不能复活旧批次;预检、移动、同步和失败回调共用该守卫。 macOS 快照跳过嵌套在自身仓库中的 linked worktree(.git 文件指向 <主仓库>/.git/worktrees/*)并内置隐藏 .artifacts;项目树由 workspace.listDirectories 按需逐层列出,children 为 nil 表示目录未加载;平铺文件清单 projectFiles 仍完整保留供搜索等功能使用。 macOS 项目树同级排序由 Rust Core 的 visible_children 与 Swift 的 NaturalNameOrder 共同实现 IDEA FileNameComparator(共享 fixture file-name-order-v1.json);快照 files 与搜索索引按 ChildOrder::Traversal 保持原有的小写排序(Windows 快速打开取前 100 项依赖它),只有 workspace.listDirectories 使用自然顺序。Windows 项目文件树仍使用 localeCompare,Git 文件树已使用同源的 compareGitFileNames,Windows 项目树对齐待后续处理。 Windows 文件树支持 Delete 键删除(始终二次确认),macOS 文件树暂无键盘删除,仅右键“移到废纸篓”。 Windows 定位当前文件先用 toRootedPath 把活动路径改写成树根目录的前缀与分隔符写法,再展开祖先并滚动;此前编辑器路径与树节点写法不同会因精确字符串匹配而静默无反应。 内置隐藏列表(.git、node_modules、target、build、dist、.lithe/** 等)和 glob 匹配只在 rust/lithe-core-rules 定义;Windows 设置里只保存用户自己的条目,新装默认值为空(此前内置了 .hg、.idea、*~ 等 Windows 独有条目,已保存的值不变,仍作为用户条目生效)。Windows 文件树按目录批量调用 wasm,结果按目录和规则版本缓存。" } diff --git a/windows/tauri/src/features/file-explorer/components/file-explorer-reveal.test.tsx b/windows/tauri/src/features/file-explorer/components/file-explorer-reveal.test.tsx index 7a260e85c6..1e0d074a64 100644 --- a/windows/tauri/src/features/file-explorer/components/file-explorer-reveal.test.tsx +++ b/windows/tauri/src/features/file-explorer/components/file-explorer-reveal.test.tsx @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, expect, test } from "bun:test"; +import { afterEach, beforeAll, beforeEach, expect, test } from "bun:test"; import { enableMapSet } from "immer"; import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; @@ -12,6 +12,10 @@ import { workspaceRuntimeRegistry as registry } from "@/features/workspace/runti import { LocaleProvider } from "@/i18n/locale-provider"; import { installHappyDom } from "@/test-utils/happy-dom"; import { FileExplorerTree } from "./file-explorer-tree"; +import { loadCoreRulesForTests } from "@/test-utils/core-rules"; + +// The tree asks Core's wasm rules which paths the hidden-path settings hide. +beforeAll(loadCoreRulesForTests); const rootPath = "C:/workspace/reveal"; const directoryPath = `${rootPath}/src`; diff --git a/windows/tauri/src/features/file-explorer/components/file-explorer-selection.test.tsx b/windows/tauri/src/features/file-explorer/components/file-explorer-selection.test.tsx index cd8fa2b906..7f892cc54d 100644 --- a/windows/tauri/src/features/file-explorer/components/file-explorer-selection.test.tsx +++ b/windows/tauri/src/features/file-explorer/components/file-explorer-selection.test.tsx @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, expect, mock, spyOn, test } from "bun:test"; +import { afterEach, beforeAll, beforeEach, expect, mock, spyOn, test } from "bun:test"; import { enableMapSet } from "immer"; import { mockIPC, clearMocks } from "@tauri-apps/api/mocks"; import { act, forwardRef, type ComponentProps } from "react"; @@ -25,6 +25,10 @@ import { keymapRegistry } from "@/features/keymaps/utils/registry"; import { FileExplorerPane } from "./file-explorer-pane"; import * as dialog from "@/ui/dialog"; import * as sidebar from "@/ui/sidebar"; +import { loadCoreRulesForTests } from "@/test-utils/core-rules"; + +// The tree asks Core's wasm rules which paths the hidden-path settings hide. +beforeAll(loadCoreRulesForTests); const originalSidebar = { ...sidebar }; const originalHandleFileOpen = useFileSystemStore.getState().handleFileOpen; diff --git a/windows/tauri/src/features/file-explorer/components/file-explorer-tree.tsx b/windows/tauri/src/features/file-explorer/components/file-explorer-tree.tsx index bc8b2506d0..ffecfadc5b 100644 --- a/windows/tauri/src/features/file-explorer/components/file-explorer-tree.tsx +++ b/windows/tauri/src/features/file-explorer/components/file-explorer-tree.tsx @@ -20,7 +20,6 @@ import { import { isTreeDomEvent } from "../lib/tree-dom-event"; import { useFileOpenTarget } from "@/features/keymaps/hooks/use-file-open-target"; import { isNativeTextInputTarget } from "@/utils/keyboard/text-input-target"; -import ignore from "ignore"; import { EyeIcon as Eye, FadersHorizontalIcon as Preferences, @@ -53,6 +52,8 @@ import { type FileTreeGitStatusDecoration, type FileTreeGitStatusLookup, } from "@/features/file-explorer/lib/file-tree-git-status"; +import { createDirectoryVisibilityClassifier } from "@/features/file-explorer/lib/file-tree-visibility"; +import { hiddenPathRules } from "@/features/settings/lib/hidden-path-rules"; import { collectGitIgnoreFileReferences, createFileTreeGitIgnoreRules, @@ -357,34 +358,33 @@ function FileExplorerTreeComponent({ file: FileEntry; } | null>(null); - const userIgnore = useMemo(() => { - const ig = ignore(); - if (fileTreeSettings.hiddenFilePatterns.length > 0) { - ig.add(fileTreeSettings.hiddenFilePatterns); - } - if (fileTreeSettings.hiddenDirectoryPatterns.length > 0) { - ig.add(fileTreeSettings.hiddenDirectoryPatterns.map((p) => (p.endsWith("/") ? p : `${p}/`))); - } - return ig; - }, [fileTreeSettings.hiddenFilePatterns, fileTreeSettings.hiddenDirectoryPatterns]); - - const getWorkspaceRootForPath = useCallback( (path: string) => directoryMarkRoot(path, workspaceRootPaths), [workspaceRootPaths], ); - const isUserHidden = useCallback( - (fullPath: string, isDir: boolean): boolean => { - const matchedRootPath = getWorkspaceRootForPath(fullPath); - if (!matchedRootPath) return false; + // Core decides what the hidden-path rules hide. A new classifier per rule set is the cache + // version: it keeps one answer per directory child until the rules change. + const userVisibility = useMemo( + () => + createDirectoryVisibilityClassifier( + hiddenPathRules({ + hiddenDirectoryPatterns: fileTreeSettings.hiddenDirectoryPatterns, + hiddenFilePatterns: fileTreeSettings.hiddenFilePatterns, + }), + getWorkspaceRootForPath, + ), + [ + fileTreeSettings.hiddenDirectoryPatterns, + fileTreeSettings.hiddenFilePatterns, + getWorkspaceRootForPath, + ], + ); - let relative = getRelativePath(fullPath, matchedRootPath); - if (!relative || relative.trim() === "") return false; - if (isDir && !relative.endsWith("/")) relative += "/"; - return userIgnore.ignores(relative); - }, - [getWorkspaceRootForPath, userIgnore], + const classifyUserHidden = useCallback( + (directoryPath: string, children: readonly FileEntry[]): boolean[] => + userVisibility.classifyChildren(directoryPath, children), + [userVisibility], ); const gitIgnoreFileReferences = useMemo( @@ -476,7 +476,7 @@ function FileExplorerTreeComponent({ isAlwaysHidden: isAlwaysHiddenFileName, isGitIgnored, isHiddenName: isHiddenFileTreeName, - isUserHidden, + classifyUserHidden, showGitignoredFiles: fileTreeSettings.showGitignoredFilesInFileTree, showHiddenFiles: fileTreeSettings.showHiddenFilesInFileTree, }); @@ -489,7 +489,7 @@ function FileExplorerTreeComponent({ }, [ files, isGitIgnored, - isUserHidden, + classifyUserHidden, fileTreeSettings.showGitignoredFilesInFileTree, fileTreeSettings.showHiddenFilesInFileTree, ]); @@ -908,12 +908,17 @@ function FileExplorerTreeComponent({ currentBatch.map((currentPath) => readDirectory(currentPath)), ); - for (const entries of directoryEntries) { - for (const entry of entries as Array<{ + for (const [directoryIndex, entries] of directoryEntries.entries()) { + const children = (entries as Array<{ path: string; is_dir?: boolean; - }>) { - if (!entry.path) continue; + }>).filter((entry) => entry.path); + // One Core call per directory, not per entry. + const userHidden = userVisibility.classifyChildren( + currentBatch[directoryIndex] ?? "", + children.map((entry) => ({ path: entry.path, isDir: !!entry.is_dir })), + ); + for (const [childIndex, entry] of children.entries()) { const isDir = !!entry.is_dir; const entryName = getPathBaseName(entry.path); @@ -921,7 +926,7 @@ function FileExplorerTreeComponent({ continue; } - if (isUserHidden(entry.path, isDir)) { + if (userHidden[childIndex]) { continue; } @@ -951,7 +956,7 @@ function FileExplorerTreeComponent({ return collected; }, [ - isUserHidden, + userVisibility, isGitIgnored, fileTreeSettings.showGitignoredFilesInFileTree, fileTreeSettings.showHiddenFilesInFileTree, diff --git a/windows/tauri/src/features/file-explorer/lib/file-tree-visibility.test.ts b/windows/tauri/src/features/file-explorer/lib/file-tree-visibility.test.ts new file mode 100644 index 0000000000..d9a5d5d084 --- /dev/null +++ b/windows/tauri/src/features/file-explorer/lib/file-tree-visibility.test.ts @@ -0,0 +1,120 @@ +import { beforeAll, describe, expect, spyOn, test } from "bun:test"; +import fixture from "../../../../../../shared/fixtures/workspace/visibility-v1.json"; +import * as coreRules from "@/core/core-rules"; +import { executeCoreRuleSync } from "@/core/core-rules"; +import { loadCoreRulesForTests } from "@/test-utils/core-rules"; +import { filterFileTreeEntries } from "./visible-file-tree-rows"; +import { + classifyRelativePaths, + createDirectoryVisibilityClassifier, +} from "./file-tree-visibility"; +import type { FileEntry } from "@/features/file-system/types/app.types"; + +// The same fixture drives the Rust tests in lithe-core-rules and lithe-core and the macOS +// FileVisibilityRules tests, so every host hides the same paths. Here it runs through real wasm. +beforeAll(loadCoreRulesForTests); + +describe("hidden-path classification (shared fixture through wasm)", () => { + test("the built-in lists come from Core", () => { + expect(executeCoreRuleSync("workspace.visibilityDefaults", {})).toEqual(fixture.defaults); + }); + + for (const testCase of fixture.cases) { + test(testCase.name, () => { + const hidden = classifyRelativePaths( + { + hiddenDirectoryNames: testCase.hiddenDirectoryNames, + hiddenFilePatterns: testCase.hiddenFilePatterns, + }, + testCase.entries.map((entry) => ({ path: entry.path, isDirectory: entry.isDirectory })), + ); + expect(hidden).toEqual(testCase.entries.map((entry) => entry.hidden)); + }); + } +}); + +describe("directory classifier", () => { + const root = "C:/work/project"; + const rules = { hiddenDirectoryNames: [], hiddenFilePatterns: ["*.log"] }; + const rootFor = (path: string) => (path.startsWith(root) ? root : null); + const children = [ + { path: `${root}/node_modules`, isDir: true }, + { path: `${root}/src`, isDir: true }, + { path: `${root}/debug.log`, isDir: false }, + { path: `${root}/README.md`, isDir: false }, + ]; + + test("classifies every child of a directory with one Core call and caches the answer", () => { + const spy = spyOn(coreRules, "executeCoreRuleSync"); + try { + const classifier = createDirectoryVisibilityClassifier(rules, rootFor); + expect(classifier.classifyChildren(root, children)).toEqual([true, false, true, false]); + expect(spy).toHaveBeenCalledTimes(1); + expect(classifier.classifyChildren(root, children)).toEqual([true, false, true, false]); + expect(spy).toHaveBeenCalledTimes(1); + } finally { + spy.mockRestore(); + } + }); + + test("a new rule set starts with an empty cache", () => { + const spy = spyOn(coreRules, "executeCoreRuleSync"); + try { + createDirectoryVisibilityClassifier(rules, rootFor).classifyChildren(root, children); + createDirectoryVisibilityClassifier({ ...rules, hiddenFilePatterns: [] }, rootFor).classifyChildren( + root, + children, + ); + expect(spy).toHaveBeenCalledTimes(2); + } finally { + spy.mockRestore(); + } + }); + + test("workspace roots and paths outside every root are never hidden", () => { + const classifier = createDirectoryVisibilityClassifier(rules, rootFor); + expect( + classifier.classifyChildren("", [ + { path: root, isDir: true }, + { path: "D:/elsewhere/node_modules", isDir: true }, + ]), + ).toEqual([false, false]); + }); + + test("filtering a tree asks Core once per directory, not once per file", () => { + const file = (name: string, parent: string): FileEntry => ({ name, path: `${parent}/${name}`, isDir: false }); + const src: FileEntry = { + name: "src", + path: `${root}/src`, + isDir: true, + children: [file("a.ts", `${root}/src`), file("b.log", `${root}/src`), file("c.ts", `${root}/src`)], + }; + const tree: FileEntry[] = [ + { + name: "project", + path: root, + isDir: true, + children: [src, file("debug.log", root), { name: "target", path: `${root}/target`, isDir: true, children: [] }], + }, + ]; + const spy = spyOn(coreRules, "executeCoreRuleSync"); + try { + const classifier = createDirectoryVisibilityClassifier(rules, rootFor); + const result = filterFileTreeEntries(tree, { + isAlwaysHidden: () => false, + isGitIgnored: () => false, + isHiddenName: () => false, + classifyUserHidden: classifier.classifyChildren, + showGitignoredFiles: true, + showHiddenFiles: true, + }); + const names = (entries: FileEntry[] | undefined): string[] => + (entries ?? []).flatMap((entry) => [entry.name, ...names(entry.children)]); + expect(names(result)).toEqual(["project", "src", "a.ts", "c.ts"]); + // The workspace root level has one entry; project and src each get one call. + expect(spy.mock.calls.length).toBeLessThanOrEqual(2); + } finally { + spy.mockRestore(); + } + }); +}); diff --git a/windows/tauri/src/features/file-explorer/lib/file-tree-visibility.ts b/windows/tauri/src/features/file-explorer/lib/file-tree-visibility.ts new file mode 100644 index 0000000000..f882f5e166 --- /dev/null +++ b/windows/tauri/src/features/file-explorer/lib/file-tree-visibility.ts @@ -0,0 +1,94 @@ +import { executeCoreRuleSync } from "@/core/core-rules"; +import type { HiddenPathRules } from "@/features/settings/lib/hidden-path-rules"; +import { getRelativePath } from "@/utils/path-helpers"; + +/** + * Hidden-path classification for the file tree, answered by Core's `workspace.classifyVisibility` + * rule (rust/lithe-core-rules, run as wasm) so the tree hides exactly what search, snapshots and + * local history hide. There is no TypeScript matcher and no default list here. + * + * Note: .agents/notes/implemented/architecture/2026-11-05-core-rules-wasm-sync-bridge.md + */ + +interface ClassifyVisibilityResponse { + hidden: boolean[]; +} + +/** A child entry of one directory: just what classification needs. */ +export interface VisibilityChild { + path: string; + isDir: boolean; +} + +/** + * Classifies a batch of workspace-relative paths in one synchronous call. `isDirectory: null` + * means the caller does not know the kind. + */ +export function classifyRelativePaths( + rules: HiddenPathRules, + entries: ReadonlyArray<{ path: string; isDirectory: boolean | null }>, +): boolean[] { + if (entries.length === 0) return []; + return executeCoreRuleSync("workspace.classifyVisibility", { + hiddenDirectoryNames: rules.hiddenDirectoryNames, + hiddenFilePatterns: rules.hiddenFilePatterns, + entries, + }).hidden; +} + +export interface DirectoryVisibilityClassifier { + /** + * Whether each child of `directoryPath` is hidden by the user's rules plus Core's built-ins. + * One Core call covers all children that have no cached answer yet, never one call per file. + */ + classifyChildren(directoryPath: string, children: readonly VisibilityChild[]): boolean[]; +} + +/** + * Creates a classifier bound to one rule set. Answers are cached per directory for the lifetime + * of the instance, so the owner makes a new instance whenever the rules change; that is the + * rules version. `workspaceRootFor` maps a path to the workspace root it belongs to, or null + * when the path is outside every root (nothing is hidden then). + */ +export function createDirectoryVisibilityClassifier( + rules: HiddenPathRules, + workspaceRootFor: (path: string) => string | null, +): DirectoryVisibilityClassifier { + const cache = new Map>(); + + return { + classifyChildren(directoryPath, children) { + let known = cache.get(directoryPath); + if (!known) { + known = new Map(); + cache.set(directoryPath, known); + } + const pending: Array<{ child: VisibilityChild; key: string; relative: string }> = []; + const result = children.map((child) => { + const key = child.isDir ? `${child.path}/` : child.path; + const cached = known.get(key); + if (cached !== undefined) return cached; + const root = workspaceRootFor(child.path); + const relative = root ? getRelativePath(child.path, root).replace(/\\/g, "/").trim() : ""; + // A workspace root itself, or a path outside every root, is never hidden. + if (!relative) { + known.set(key, false); + return false; + } + pending.push({ child, key, relative }); + return null; + }); + if (pending.length > 0) { + const hidden = classifyRelativePaths( + rules, + pending.map(({ child, relative }) => ({ path: relative, isDirectory: child.isDir })), + ); + pending.forEach(({ key }, index) => known.set(key, hidden[index] === true)); + } + return children.map((child, index) => { + if (result[index] !== null) return result[index] as boolean; + return known.get(child.isDir ? `${child.path}/` : child.path) === true; + }); + }, + }; +} diff --git a/windows/tauri/src/features/file-explorer/lib/visible-file-tree-rows.ts b/windows/tauri/src/features/file-explorer/lib/visible-file-tree-rows.ts index 3e3e61542b..156c221680 100644 --- a/windows/tauri/src/features/file-explorer/lib/visible-file-tree-rows.ts +++ b/windows/tauri/src/features/file-explorer/lib/visible-file-tree-rows.ts @@ -42,7 +42,8 @@ export interface FilterFileTreeEntriesOptions { isAlwaysHidden: (name: string) => boolean; isGitIgnored: (path: string, isDir: boolean) => boolean; isHiddenName: (name: string) => boolean; - isUserHidden: (path: string, isDir: boolean) => boolean; + /** Whether each child of `directoryPath` is hidden by the user's hidden-path rules, in one batch. */ + classifyUserHidden: (directoryPath: string, children: readonly FileEntry[]) => boolean[]; showGitignoredFiles: boolean; showHiddenFiles: boolean; } @@ -50,14 +51,17 @@ export interface FilterFileTreeEntriesOptions { export function filterFileTreeEntries( files: FileEntry[], options: FilterFileTreeEntriesOptions, + directoryPath = "", ): FileEntry[] { let changed = false; const filteredItems: FileEntry[] = []; + // One classification per directory: the children are batched, never asked one by one. + const userHidden = options.classifyUserHidden(directoryPath, files); - for (const item of files) { + for (const [index, item] of files.entries()) { const ignored = options.isGitIgnored(item.path, item.isDir); - if (options.isAlwaysHidden(item.name) || options.isUserHidden(item.path, item.isDir)) { + if (options.isAlwaysHidden(item.name) || userHidden[index] === true) { changed = true; continue; } @@ -73,7 +77,7 @@ export function filterFileTreeEntries( } const filteredChildren = item.children - ? filterFileTreeEntries(item.children, options) + ? filterFileTreeEntries(item.children, options, item.path) : undefined; const childrenChanged = filteredChildren !== item.children; const ignoredChanged = item.ignored !== ignored && (ignored || item.ignored !== undefined); diff --git a/windows/tauri/src/features/file-search/lib/file-search-api.test.ts b/windows/tauri/src/features/file-search/lib/file-search-api.test.ts index 8cfdfe44ee..50f1673d47 100644 --- a/windows/tauri/src/features/file-search/lib/file-search-api.test.ts +++ b/windows/tauri/src/features/file-search/lib/file-search-api.test.ts @@ -1,10 +1,6 @@ import { expect, spyOn, test } from "bun:test"; import * as core from "@/core/lithe-core-client"; import * as indexing from "@/features/file-explorer/stores/workspace-indexing"; -import { - DEFAULT_HIDDEN_DIRECTORY_PATTERNS, - DEFAULT_HIDDEN_FILE_PATTERNS, -} from "@/features/settings/config/default-settings"; import { fffListFiles, searchFileNames, searchFilesContent } from "./file-search-api"; import { mergeSearchResults } from "@/features/global-search/utils/content-search-results"; @@ -138,8 +134,8 @@ test("workspace file listing asks Core for flat files without allocating a tree" root, workspaceRoots: [root], includeTree: false, - hiddenDirectoryNames: [...DEFAULT_HIDDEN_DIRECTORY_PATTERNS], - hiddenFilePatterns: [...DEFAULT_HIDDEN_FILE_PATTERNS], + hiddenDirectoryNames: [], + hiddenFilePatterns: [], }); } finally { execute.mockRestore(); configure.mockRestore(); @@ -178,7 +174,7 @@ test("file-name search asks Core's index for files only, with relative context a maxSymbolResults: 0, maxContentResults: 0, context: {openPaths: ["src/a.ts"], recentPaths: ["src/b.ts"]}, - hiddenDirectoryNames: [...DEFAULT_HIDDEN_DIRECTORY_PATTERNS], + hiddenDirectoryNames: [], }); } finally { execute.mockRestore(); configure.mockRestore(); diff --git a/windows/tauri/src/features/settings/config/default-settings.ts b/windows/tauri/src/features/settings/config/default-settings.ts index dc482aa03d..9fc81cfc83 100644 --- a/windows/tauri/src/features/settings/config/default-settings.ts +++ b/windows/tauri/src/features/settings/config/default-settings.ts @@ -21,30 +21,6 @@ const DEFAULT_AI_CUSTOM_MODEL_ID = ""; export const DEFAULT_AI_AUTOCOMPLETE_MODEL_ID = "mistralai/devstral-small"; const DEFAULT_AI_AUTOCOMPLETE_CUSTOM_BASE_URL = ""; -export const DEFAULT_HIDDEN_FILE_PATTERNS = [ - "*.pyc", - "*.pyo", - "*.rbc", - "*.yarb", - "*~", - ".DS_Store", - "vssver.scc", - "vssver2.scc", -] as const; - -export const DEFAULT_HIDDEN_DIRECTORY_PATTERNS = [ - ".git", - ".hg", - ".idea", - ".mypy_cache", - ".pytest_cache", - ".ruff_cache", - ".svn", - "CVS", - "__pycache__", - "_svn", -] as const; - export const defaultSettings: Settings = { aiCommit: DEFAULT_COMMIT_AI, // General @@ -201,8 +177,10 @@ export const defaultSettings: Settings = { confirmBeforeFileDelete: true, showHiddenFilesInFileTree: true, showGitignoredFilesInFileTree: true, - hiddenFilePatterns: [...DEFAULT_HIDDEN_FILE_PATTERNS], - hiddenDirectoryPatterns: [...DEFAULT_HIDDEN_DIRECTORY_PATTERNS], + // Only the user's own entries live in settings. Core owns the built-in hidden paths + // (rust/lithe-core-rules, `workspace.visibilityDefaults`) and adds them to every rule set. + hiddenFilePatterns: [], + hiddenDirectoryPatterns: [], gitChangesFolderView: false, confirmBeforeDiscard: true, autoRefreshGitStatus: true, diff --git a/windows/tauri/src/features/settings/lib/settings-migrations.test.ts b/windows/tauri/src/features/settings/lib/settings-migrations.test.ts index e74620b96d..69101c772a 100644 --- a/windows/tauri/src/features/settings/lib/settings-migrations.test.ts +++ b/windows/tauri/src/features/settings/lib/settings-migrations.test.ts @@ -1,8 +1,4 @@ import { describe, expect, test } from "bun:test"; -import { - DEFAULT_HIDDEN_DIRECTORY_PATTERNS, - DEFAULT_HIDDEN_FILE_PATTERNS, -} from "@/features/settings/config/default-settings"; import { findRetiredSettingsKeys, HIDDEN_PATTERN_DEFAULTS_VERSION_KEY, @@ -13,7 +9,7 @@ import { import { defaultSettings } from "@/features/settings/config/default-settings"; describe("hidden pattern default migration", () => { - test("replaces the former pair of empty defaults", () => { + test("keeps the former pair of empty defaults empty because Core owns the built-ins", () => { const result = migrateHiddenPatternDefaults( new Map([ ["hiddenFilePatterns", []], @@ -21,10 +17,8 @@ describe("hidden pattern default migration", () => { ]), ); - expect(result.entries.get("hiddenFilePatterns")).toEqual([...DEFAULT_HIDDEN_FILE_PATTERNS]); - expect(result.entries.get("hiddenDirectoryPatterns")).toEqual([ - ...DEFAULT_HIDDEN_DIRECTORY_PATTERNS, - ]); + expect(result.entries.get("hiddenFilePatterns")).toEqual([]); + expect(result.entries.get("hiddenDirectoryPatterns")).toEqual([]); expect(result.entries.get(HIDDEN_PATTERN_DEFAULTS_VERSION_KEY)).toBe(1); }); diff --git a/windows/tauri/src/features/settings/lib/settings-migrations.ts b/windows/tauri/src/features/settings/lib/settings-migrations.ts index f5ec13e254..fd4b6304fb 100644 --- a/windows/tauri/src/features/settings/lib/settings-migrations.ts +++ b/windows/tauri/src/features/settings/lib/settings-migrations.ts @@ -1,8 +1,3 @@ -import { - DEFAULT_HIDDEN_DIRECTORY_PATTERNS, - DEFAULT_HIDDEN_FILE_PATTERNS, -} from "@/features/settings/config/default-settings"; - export const HIDDEN_PATTERN_DEFAULTS_VERSION_KEY = "hiddenPatternDefaultsVersion"; const CURRENT_HIDDEN_PATTERN_DEFAULTS_VERSION = 1; @@ -66,8 +61,6 @@ export function findRetiredSettingsKeys(entries: ReadonlyMap): return RETIRED_SETTINGS_KEYS.filter((key) => entries.has(key)); } -const isEmptyArray = (value: unknown): value is [] => Array.isArray(value) && value.length === 0; - export function migrateHiddenPatternDefaults( sourceEntries: Map, ): HiddenPatternDefaultsMigration { @@ -79,23 +72,8 @@ export function migrateHiddenPatternDefaults( return { entries, changes }; } - const hasLegacyEmptyDefaults = - entries.has("hiddenFilePatterns") && - entries.has("hiddenDirectoryPatterns") && - isEmptyArray(entries.get("hiddenFilePatterns")) && - isEmptyArray(entries.get("hiddenDirectoryPatterns")); - - if (hasLegacyEmptyDefaults) { - const hiddenFilePatterns = [...DEFAULT_HIDDEN_FILE_PATTERNS]; - const hiddenDirectoryPatterns = [...DEFAULT_HIDDEN_DIRECTORY_PATTERNS]; - entries.set("hiddenFilePatterns", hiddenFilePatterns); - entries.set("hiddenDirectoryPatterns", hiddenDirectoryPatterns); - changes.push( - ["hiddenFilePatterns", hiddenFilePatterns], - ["hiddenDirectoryPatterns", hiddenDirectoryPatterns], - ); - } - + // Empty lists stay empty. The built-in hidden paths belong to Core, so no default list is copied + // into the user's settings; saved values are never rewritten. entries.set(HIDDEN_PATTERN_DEFAULTS_VERSION_KEY, CURRENT_HIDDEN_PATTERN_DEFAULTS_VERSION); changes.push([HIDDEN_PATTERN_DEFAULTS_VERSION_KEY, CURRENT_HIDDEN_PATTERN_DEFAULTS_VERSION]); diff --git a/windows/tauri/src/features/settings/lib/settings-normalization.test.ts b/windows/tauri/src/features/settings/lib/settings-normalization.test.ts index 9760889099..e928202a8c 100644 --- a/windows/tauri/src/features/settings/lib/settings-normalization.test.ts +++ b/windows/tauri/src/features/settings/lib/settings-normalization.test.ts @@ -65,12 +65,11 @@ describe("default settings", () => { expect(getDefaultSettingsSnapshot().showMinimap).toBe(false); }); - test("starts with IDEA-style hidden file and directory patterns", () => { + test("starts with no user hidden-path entries because Core owns the built-in list", () => { const settings = getDefaultSettingsSnapshot(); - expect(settings.hiddenDirectoryPatterns).toContain(".git"); - expect(settings.hiddenDirectoryPatterns).toContain(".idea"); - expect(settings.hiddenFilePatterns).toContain(".DS_Store"); + expect(settings.hiddenDirectoryPatterns).toEqual([]); + expect(settings.hiddenFilePatterns).toEqual([]); }); }); From cad44bd898b52f1bbfc87f2477f6c913001e9613 Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Thu, 5 Nov 2026 20:23:16 -0800 Subject: [PATCH 6/6] =?UTF-8?q?fix(core-rules):=20#1662=20=E5=AE=BF?= =?UTF-8?q?=E4=B8=BB=E8=AF=AD=E8=A8=80=E6=A3=80=E6=9F=A5=E8=A6=86=E7=9B=96?= =?UTF-8?q?=E6=96=B0=20crate=EF=BC=8C=E4=BF=AE=E6=AD=A3=E7=B1=BB=E5=9E=8B?= =?UTF-8?q?=E5=92=8C=E6=B5=8B=E8=AF=95=E9=9A=94=E7=A6=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 宿主语言检查的扫描范围和 workflow 触发路径加入 rust/lithe-core-rules/src。 - 基线按代码搬迁记录:files.rs 的 toolTables 19 和 1 个 words 随隐藏目录表移到 lithe-core-rules 的 visibility.rs;FileVisibilityRules.swift 的 1 个 words 已删除,总数减 1。 - file-tree-visibility:workspaceRootFor 允许返回 undefined;默认值测试补上类型参数。 - core-rules 增加 resetCoreRulesForTests()。pane.store 的 Core 调用计数测试先重置再加载, 避免同一 Bun 进程里更早的测试文件已经加载了不计数的实例。 Co-Authored-By: Claude Opus 5.5 --- .github/workflows/verify-host-language-specifics.yml | 2 ++ scripts/architecture/language-specifics-baseline.json | 4 ++-- .../architecture/verify-no-host-language-specifics.mjs | 2 ++ windows/tauri/src/core/core-rules.ts | 9 +++++++++ .../file-explorer/lib/file-tree-visibility.test.ts | 4 +++- .../features/file-explorer/lib/file-tree-visibility.ts | 4 ++-- .../tauri/src/features/panes/stores/pane.store.test.ts | 7 ++++++- 7 files changed, 26 insertions(+), 6 deletions(-) diff --git a/.github/workflows/verify-host-language-specifics.yml b/.github/workflows/verify-host-language-specifics.yml index ef545bb3b3..218b1125e6 100644 --- a/.github/workflows/verify-host-language-specifics.yml +++ b/.github/workflows/verify-host-language-specifics.yml @@ -8,6 +8,7 @@ on: types: [opened, synchronize, reopened, ready_for_review] paths: - "rust/lithe-core/src/**" + - "rust/lithe-core-rules/src/**" - "macos/Sources/**" - "frontend/editor/src/**" - "shared/contracts/plugins/v1/**" @@ -21,6 +22,7 @@ on: branches: [preview] paths: - "rust/lithe-core/src/**" + - "rust/lithe-core-rules/src/**" - "macos/Sources/**" - "frontend/editor/src/**" - "shared/contracts/plugins/v1/**" diff --git a/scripts/architecture/language-specifics-baseline.json b/scripts/architecture/language-specifics-baseline.json index 1495b4b746..e1402734ae 100644 --- a/scripts/architecture/language-specifics-baseline.json +++ b/scripts/architecture/language-specifics-baseline.json @@ -170,7 +170,6 @@ "macos/Sources/LitheCoreContracts/Language/LanguageServerHostIntegrationContracts.swift": {"words":5}, "macos/Sources/LitheCoreContracts/Language/LanguageServerRuntimeContracts.swift": {"words":161}, "macos/Sources/LitheCoreContracts/Language/LanguageToolingContracts.swift": {"words":115}, - "macos/Sources/LitheCoreContracts/Workspace/FileVisibilityRules.swift": {"words":1}, "macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift": {"words":47,"nameComparisons":3}, "macos/Sources/LitheExecutionModule/Application/BuildSystemsFeatureModel.swift": {"words":2}, "macos/Sources/LitheExecutionModule/Application/ExecutionFeatureModels.swift": {"words":37}, @@ -187,6 +186,7 @@ "macos/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift": {"words":13}, "macos/Sources/LitheModuleAPI/Logging/LogEvents.generated.swift": {"words":33}, "macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift": {"words":2,"nameComparisons":2}, + "rust/lithe-core-rules/src/visibility.rs": {"words":1,"toolTables":19}, "rust/lithe-core/src/build/integrations/maven.rs": {"words":32}, "rust/lithe-core/src/build/integrations/mod.rs": {"words":11}, "rust/lithe-core/src/build/invocation.rs": {"words":1,"nameComparisons":1}, @@ -253,7 +253,7 @@ "rust/lithe-core/src/plugins/declarative.rs": {"words":13,"nameComparisons":3,"toolTables":7}, "rust/lithe-core/src/plugins/settings.rs": {"words":1}, "rust/lithe-core/src/process/inventory.rs": {"words":4}, - "rust/lithe-core/src/project/files.rs": {"words":7,"toolTables":19}, + "rust/lithe-core/src/project/files.rs": {"words":6}, "rust/lithe-core/src/project/history.rs": {"words":8,"toolTables":30}, "rust/lithe-core/src/project/indexing.rs": {"words":44,"nameComparisons":2}, "rust/lithe-core/src/project/maven.rs": {"words":256}, diff --git a/scripts/architecture/verify-no-host-language-specifics.mjs b/scripts/architecture/verify-no-host-language-specifics.mjs index 78d13654de..b83544b44b 100644 --- a/scripts/architecture/verify-no-host-language-specifics.mjs +++ b/scripts/architecture/verify-no-host-language-specifics.mjs @@ -12,6 +12,8 @@ import { fileURLToPath, pathToFileURL } from "node:url"; export const SCAN_ROOTS = Object.freeze([ "rust/lithe-core/src", + // Synchronous rules shared with the Windows frontend through wasm; still host Core code. + "rust/lithe-core-rules/src", "macos/Sources", "frontend/editor/src", "shared/contracts/plugins/v1", diff --git a/windows/tauri/src/core/core-rules.ts b/windows/tauri/src/core/core-rules.ts index 22b4585fad..7b0b1a5b33 100644 --- a/windows/tauri/src/core/core-rules.ts +++ b/windows/tauri/src/core/core-rules.ts @@ -94,6 +94,15 @@ export function initializeCoreRules(source?: BufferSource | Promise { test("the built-in lists come from Core", () => { - expect(executeCoreRuleSync("workspace.visibilityDefaults", {})).toEqual(fixture.defaults); + expect(executeCoreRuleSync("workspace.visibilityDefaults", {})).toEqual( + fixture.defaults, + ); }); for (const testCase of fixture.cases) { diff --git a/windows/tauri/src/features/file-explorer/lib/file-tree-visibility.ts b/windows/tauri/src/features/file-explorer/lib/file-tree-visibility.ts index f882f5e166..fd7c621b74 100644 --- a/windows/tauri/src/features/file-explorer/lib/file-tree-visibility.ts +++ b/windows/tauri/src/features/file-explorer/lib/file-tree-visibility.ts @@ -47,12 +47,12 @@ export interface DirectoryVisibilityClassifier { /** * Creates a classifier bound to one rule set. Answers are cached per directory for the lifetime * of the instance, so the owner makes a new instance whenever the rules change; that is the - * rules version. `workspaceRootFor` maps a path to the workspace root it belongs to, or null + * rules version. `workspaceRootFor` maps a path to the workspace root it belongs to, or null/undefined * when the path is outside every root (nothing is hidden then). */ export function createDirectoryVisibilityClassifier( rules: HiddenPathRules, - workspaceRootFor: (path: string) => string | null, + workspaceRootFor: (path: string) => string | null | undefined, ): DirectoryVisibilityClassifier { const cache = new Map>(); diff --git a/windows/tauri/src/features/panes/stores/pane.store.test.ts b/windows/tauri/src/features/panes/stores/pane.store.test.ts index 07aed9eb91..293a6a6190 100644 --- a/windows/tauri/src/features/panes/stores/pane.store.test.ts +++ b/windows/tauri/src/features/panes/stores/pane.store.test.ts @@ -1,5 +1,6 @@ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test"; import { BOTTOM_PANE_ID, ROOT_PANE_ID } from "../constants/pane"; +import { resetCoreRulesForTests } from "@/core/core-rules"; import { loadCoreRulesForTests } from "@/test-utils/core-rules"; import { workspaceRuntimeRegistry } from "@/features/workspace/runtime/workspace-runtime-registry"; import type { PaneGroup, PaneNode } from "../types/pane.types"; @@ -29,7 +30,11 @@ WebAssembly.instantiate = (async (bytes: BufferSource, imports?: WebAssembly.Imp const WORKSPACE = "pane-store-test"; -beforeAll(loadCoreRulesForTests); +// An earlier test file in the same Bun process may already hold an unwrapped instance. +beforeAll(async () => { + resetCoreRulesForTests(); + await loadCoreRulesForTests(); +}); afterAll(() => { WebAssembly.instantiate = instantiate; });