From dc27f94b6a6f33e9fe3e1fa49f39dcaf37344e91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=83=A1=E9=A3=9E?= <1835698775@qq.com> Date: Thu, 7 May 2026 16:04:13 +0800 Subject: [PATCH 01/45] =?UTF-8?q?refactor(ipc):=20=E9=87=8D=E6=9E=84=20Jso?= =?UTF-8?q?nRpcClient=20=E5=AE=9E=E7=8E=B0=E4=BB=A5=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E5=A4=9A=E5=B9=B6=E5=8F=91=E8=B0=83=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将 JsonRpcClient 改为内部共享状态,使用 Arc 和异步 Mutex 保护写操作 - 通过 HashMap 和 oneshot 通道实现请求响应的路由和匹配 - 实现 reader task 持续接收并路由响应,支持响应乱序返回 - 增加请求取消安全机制,防止挂起响应泄漏 - 添加显式关闭方法,安全中断 reader task 和子进程 - ExternalDbConnection 客户端改用 Arc 支持并发请求 - 在请求接口中检测连接关闭,故障时自动清理并触发重连 - 优化驱动进程启动,支持通过环境变量传递独立动态 socket 名 - 新增丰富的并发请求和生命周期管理测试覆盖 - 调整 duckdb_driver 测试,支持多连接并发使用同一驱动 - 更新 duckdb_driver 主程序,优先使用环境变量传递 socket 名,增强兼容性 --- crates/db/src/ipc/client.rs | 380 ++++++++++++++++++++++++--- crates/db/src/ipc/connection.rs | 50 +++- crates/db/tests/ipc_concurrency.rs | 251 ++++++++++++++++++ crates/db/tests/ipc_duckdb_driver.rs | 94 +++++-- crates/duckdb_driver/src/main.rs | 7 +- 5 files changed, 704 insertions(+), 78 deletions(-) create mode 100644 crates/db/tests/ipc_concurrency.rs diff --git a/crates/db/src/ipc/client.rs b/crates/db/src/ipc/client.rs index 9db1ad2664..ef87b15ff2 100644 --- a/crates/db/src/ipc/client.rs +++ b/crates/db/src/ipc/client.rs @@ -10,49 +10,101 @@ use ipc::{ }; use serde::de::DeserializeOwned; use serde_json::Value; +use std::collections::HashMap; use std::process::Stdio; +use std::sync::Arc; +use std::sync::Mutex as StdMutex; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::time::Duration; -use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::io::{AsyncBufReadExt, BufReader, ReadHalf, WriteHalf, split}; use tokio::process::{Child, Command}; -use tokio::time::{Instant, error::Elapsed, sleep, timeout}; +use tokio::sync::{Mutex, oneshot}; +use tokio::task::JoinHandle; +use tokio::time::{Instant, sleep, timeout}; use tracing::warn; const REQUEST_TIMEOUT_MS: u64 = 30_000; +/// 通过该环境变量把 client 生成的动态 socket 名透传给 driver 子进程。 +/// +/// driver 启动时优先读这个变量来决定 listen 名,从而支持「同 driver 多实例」 +/// 场景:每个 ExternalDbConnection 都拿到独立的 socket,互不冲突。 +pub const SOCKET_ENV_VAR: &str = "ONETCLI_IPC_SOCKET"; + +/// 客户端「写半 / 路由表 / 关闭标记」共享状态。 +/// +/// - `writer`: tokio::sync::Mutex 串行化「写一帧」操作。 +/// - `pending`: std::sync::Mutex 持锁时间极短(insert/remove HashMap),且允许在 +/// Drop 中同步 lock,这是 cancel-safety 的关键。 +/// - `next_id`: AtomicU64,无锁分配 request id。 +/// - `closed`: AtomicBool,reader task 退出后置位,后续 caller 立即失败。 +struct ClientShared { + writer: Mutex>, + pending: StdMutex>>, + next_id: AtomicU64, + closed: AtomicBool, +} + +/// JSON-RPC over IPC client。 +/// +/// 单 stream 多 caller 并发:writer mutex 串行化写,reader task 把响应按 +/// `request_id` 路由到对应 caller 的 oneshot。caller drop / timeout / 写失败 +/// 均不会泄漏 pending 表条目(由 PendingGuard 的 RAII Drop 保证)。 pub struct JsonRpcClient { - child: Option, - stream: LocalSocketStream, - next_id: u64, + shared: Arc, + reader_task: JoinHandle<()>, + /// 子进程 owner;包在 std Mutex 里以让 `JsonRpcClient: Sync`。 + /// `kill_on_drop=true` 保证 child 被 drop 时进程被 OS 回收。 + child: StdMutex>, } impl JsonRpcClient { pub async fn start(driver: &IpcDriverManifest) -> Result { + // command 为空 → 测试 / 预 listen 模式:server 已绑定 transport.name,直接连。 + // 否则 → 生产模式:每实例生成独立 socket 名,通过 env var 透传给 driver。 + let socket_name = if driver.entry.command.trim().is_empty() { + driver.transport.name.clone() + } else { + make_socket_name(driver) + }; + let mut child = if driver.entry.command.trim().is_empty() { None } else { - Some(spawn_driver_process(driver).await?) - }; - let stream = match connect_local_socket( - &driver.transport.name, - driver.transport.connect_timeout_ms(), - ) - .await - { - Ok(stream) => stream, - Err(error) => { - shutdown_child(&mut child).await; - return Err(error); - } + Some(spawn_driver_process(driver, &socket_name).await?) }; + let stream = + match connect_local_socket(&socket_name, driver.transport.connect_timeout_ms()).await { + Ok(stream) => stream, + Err(error) => { + shutdown_child(&mut child).await; + return Err(error); + } + }; + + let (read_half, write_half) = split(stream); + + let shared = Arc::new(ClientShared { + writer: Mutex::new(write_half), + pending: StdMutex::new(HashMap::new()), + next_id: AtomicU64::new(1), + closed: AtomicBool::new(false), + }); + + let reader_shared = Arc::clone(&shared); + let reader_task = tokio::spawn(async move { + reader_loop(read_half, reader_shared).await; + }); + Ok(Self { - child, - stream, - next_id: 1, + shared, + reader_task, + child: StdMutex::new(child), }) } - pub async fn request(&mut self, method: &str, params: Value) -> Result + pub async fn request(&self, method: &str, params: Value) -> Result where T: DeserializeOwned, { @@ -61,27 +113,144 @@ impl JsonRpcClient { .map_err(|error| DbError::query_with_source("invalid external driver response", error)) } - pub async fn request_value(&mut self, method: &str, params: Value) -> Result { - let id = self.next_id; - self.next_id = self.next_id.saturating_add(1); + pub async fn request_value(&self, method: &str, params: Value) -> Result { + if self.shared.closed.load(Ordering::Acquire) { + return Err(DbError::connection("driver disconnected")); + } + + let id = self.shared.next_id.fetch_add(1, Ordering::Relaxed); + let (tx, rx) = oneshot::channel(); + + // 注册 pending,double-check closed 防止 reader 已 drain。 + { + let mut pending = self.shared.pending.lock().expect("pending mutex poisoned"); + if self.shared.closed.load(Ordering::Acquire) { + return Err(DbError::connection("driver disconnected")); + } + pending.insert(id, tx); + } + + // RAII guard:future cancel / timeout / 写失败时从 pending 拿掉 sender,避免泄漏。 + let mut guard = PendingGuard { + shared: Arc::clone(&self.shared), + id, + armed: true, + }; + + // 写一帧;writer mutex 仅在写期间持锁,写完立刻释放允许下个 caller 写。 let request = IpcRequest::new(id, method, params); + let send_result = { + let mut writer = self.shared.writer.lock().await; + send_msg_async(&mut *writer, &request).await + }; + if let Err(error) = send_result { + return Err(DbError::query_with_source( + "failed to write IPC request", + error, + )); + // guard.drop → remove pending entry + } - send_msg_async(&mut self.stream, &request) - .await - .map_err(|error| DbError::query_with_source("failed to write IPC request", error))?; + // 等回复。 + match timeout(Duration::from_millis(REQUEST_TIMEOUT_MS), rx).await { + Ok(Ok(response)) => { + guard.armed = false; // reader 已 take 走 sender,不需再清理 + validate_response(response, id) + } + Ok(Err(_)) => { + guard.armed = false; // reader 关闭已 drain pending + Err(DbError::connection("driver disconnected")) + } + Err(_) => { + // timeout:guard.drop 清理 sender,reader 后到的 response 静默丢弃 + Err(DbError::query("timed out waiting for IPC response")) + } + } + } - timeout( - Duration::from_millis(REQUEST_TIMEOUT_MS), - recv_msg_async::<_, IpcResponse>(&mut self.stream), - ) - .await - .map_err(request_timeout_error)? - .map_err(|error| DbError::query_with_source("failed to read IPC response", error)) - .and_then(|response| validate_response(response, id)) + /// 显式关闭:abort reader,kill + wait child。 + /// 通常在 ExternalDbConnection::disconnect 末尾调用,确保子进程退出后才返回。 + pub async fn shutdown(&self) { + close_and_drain(&self.shared); + self.reader_task.abort(); + let mut taken = { + let mut guard = self.child.lock().expect("child mutex poisoned"); + guard.take() + }; + shutdown_child(&mut taken).await; } - pub async fn shutdown(&mut self) { - shutdown_child(&mut self.child).await; + /// reader task 是否已经退出(stream EOF / error / abort)。 + /// + /// 一旦置位,所有后续 `request` 调用都会立即得到 disconnected 错误。 + /// ExternalDbConnection 用这个信号触发 client eviction(P0-4)。 + pub fn is_closed(&self) -> bool { + self.shared.closed.load(Ordering::Acquire) + } +} + +impl Drop for JsonRpcClient { + fn drop(&mut self) { + // 兜底:abort reader task,child 由 kill_on_drop=true 自动回收。 + // 不在 Drop 里 await,避免阻塞 runtime。 + self.reader_task.abort(); + } +} + +/// RAII 保护 pending 表条目的 cancel-safety。 +/// +/// 当 caller 的 future 被 cancel / timeout / 写失败时,Drop 自动移除 pending sender, +/// 避免内存泄漏与 reader 找不到对应 caller 时的隐性丢弃。 +struct PendingGuard { + shared: Arc, + id: u64, + armed: bool, +} + +impl Drop for PendingGuard { + fn drop(&mut self) { + if !self.armed { + return; + } + if let Ok(mut pending) = self.shared.pending.lock() { + pending.remove(&self.id); + } + } +} + +fn close_and_drain(shared: &ClientShared) { + shared.closed.store(true, Ordering::Release); + if let Ok(mut pending) = shared.pending.lock() { + pending.clear(); + // oneshot::Sender 被 drop → caller 的 rx 收 RecvError → 报 disconnected + } +} + +async fn reader_loop(mut reader: ReadHalf, shared: Arc) { + /// 无论 reader_loop 怎么退出(EOF / Err / task abort),都标记 closed + drain + /// pending,把所有 caller 唤醒为 disconnected。 + struct CloseGuard { + shared: Arc, + } + impl Drop for CloseGuard { + fn drop(&mut self) { + close_and_drain(&self.shared); + } + } + let _guard = CloseGuard { + shared: Arc::clone(&shared), + }; + + while let Ok(response) = recv_msg_async::<_, IpcResponse>(&mut reader).await { + let sender = match shared.pending.lock() { + Ok(mut pending) => pending.remove(&response.request_id), + Err(_) => break, // pending mutex poisoned — 走 CloseGuard 兜底 + }; + if let Some(sender) = sender { + // caller 已超时 / cancel drop 了 rx 时 send 失败 — 静默忽略 + let _ = sender.send(response); + } + // 找不到 sender:caller 已 timeout / cancel,response 静默丢弃 } } @@ -118,19 +287,39 @@ async fn shutdown_child(child: &mut Option) { } } -fn request_timeout_error(error: Elapsed) -> DbError { - DbError::query_with_source("timed out waiting for IPC response", error) +/// 为 driver 生成本次启动的最终 socket 名。 +/// +/// 使用短前缀避免 macOS `sockaddr_un.sun_path` 容量限制。 +fn make_socket_name(driver: &IpcDriverManifest) -> String { + format!( + "onetcli-{}-{}.sock", + driver.id, + uuid::Uuid::new_v4().simple() + ) } -async fn spawn_driver_process(driver: &IpcDriverManifest) -> Result { +/// 构造 driver 启动 Command,设置 `ONETCLI_IPC_SOCKET` env var 把动态 socket +/// 名透传给子进程。抽出独立函数便于在 Drop / multi-instance 测试中验证 env。 +fn build_driver_command(driver: &IpcDriverManifest, socket_name: &str) -> Command { let mut command = Command::new(&driver.entry.command); command .args(&driver.entry.args) + .env(SOCKET_ENV_VAR, socket_name) .current_dir(driver.command_working_dir()) + // 关键:确保 client 异常 drop 时子进程被回收,不变孤儿。 + // 详见 P0-3 改造 — 仅 `Child::kill().await` 不足以应对 panic / runtime abort 场景。 + .kill_on_drop(true) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::piped()); + command +} +async fn spawn_driver_process( + driver: &IpcDriverManifest, + socket_name: &str, +) -> Result { + let mut command = build_driver_command(driver, socket_name); let mut child = command.spawn().map_err(|error| { DbError::connection_with_source( format!("failed to start external driver '{}'", driver.id), @@ -227,4 +416,113 @@ mod tests { assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("boom")); } + + #[test] + fn make_socket_name_generates_distinct_names_with_manifest_prefix() { + let driver = make_test_manifest("driver.sock"); + let first = make_socket_name(&driver); + let second = make_socket_name(&driver); + + assert_ne!(first, second); + assert!(first.starts_with("onetcli-socket-test-")); + assert!(second.starts_with("onetcli-socket-test-")); + assert!(first.ends_with(".sock")); + assert!(second.ends_with(".sock")); + } + + fn make_test_manifest(socket_name: &str) -> IpcDriverManifest { + IpcDriverManifest { + id: "socket-test".into(), + name: "Socket Test".into(), + description: String::new(), + version: String::new(), + entry: crate::ipc::registry::IpcDriverEntry { + command: "sleep".into(), + args: vec!["30".into()], + working_dir: None, + }, + transport: crate::ipc::registry::IpcDriverTransport::local_socket(socket_name), + dialect: Default::default(), + ui: Default::default(), + manifest_dir: std::path::PathBuf::from("/tmp"), + } + } +} + +#[cfg(all(test, unix))] +mod lifecycle_tests { + use super::*; + use crate::ipc::registry::{IpcDriverEntry, IpcDriverManifest, IpcDriverTransport}; + use std::path::PathBuf; + use std::time::Duration; + + /// 构造一个跑 `sleep 30` 的 manifest,作为「永远不会主动退出」的 driver 占位。 + fn make_sleep_manifest() -> IpcDriverManifest { + IpcDriverManifest { + id: "lifecycle-test".into(), + name: "Lifecycle Test".into(), + description: String::new(), + version: String::new(), + entry: IpcDriverEntry { + command: "sleep".into(), + args: vec!["30".into()], + working_dir: None, + }, + transport: IpcDriverTransport::local_socket("onetcli-lifecycle-test.sock"), + dialect: Default::default(), + ui: Default::default(), + manifest_dir: PathBuf::from("/tmp"), + } + } + + /// 通过 `kill -0 ` 检测 unix 进程是否仍存活。 + fn process_alive(pid: u32) -> bool { + std::process::Command::new("kill") + .args(["-0", &pid.to_string()]) + .output() + .map(|o| o.status.success()) + .unwrap_or(false) + } + + /// 兜底回收:测试失败时也不要把 sleep 进程留给 CI。 + fn force_kill(pid: u32) { + let _ = std::process::Command::new("kill") + .args(["-9", &pid.to_string()]) + .status(); + } + + #[tokio::test] + async fn spawn_driver_process_kills_child_when_handle_drops() { + let manifest = make_sleep_manifest(); + let socket_name = manifest.transport.name.clone(); + let child = spawn_driver_process(&manifest, &socket_name) + .await + .expect("spawn driver child process"); + let pid = child.id().expect("child pid should be available"); + + assert!( + process_alive(pid), + "child should be alive immediately after spawn" + ); + + drop(child); + + // 给 OS 至多 2 秒时间发送信号并清理 zombie。 + let mut reaped = false; + for _ in 0..20 { + if !process_alive(pid) { + reaped = true; + break; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + + if !reaped { + force_kill(pid); + } + assert!( + reaped, + "child pid={pid} should be killed within 2s after Child handle drops" + ); + } } diff --git a/crates/db/src/ipc/connection.rs b/crates/db/src/ipc/connection.rs index 50036917ee..fe9bd61ebc 100644 --- a/crates/db/src/ipc/connection.rs +++ b/crates/db/src/ipc/connection.rs @@ -8,13 +8,17 @@ use crate::ipc::registry::IpcDriverManifest; use crate::{DatabasePlugin, SqlErrorInfo, truncate_str}; use async_trait::async_trait; use one_core::storage::DbConnectionConfig; +use std::sync::Arc; use tokio::sync::{Mutex, mpsc}; use tracing::{debug, error}; pub struct ExternalDbConnection { config: DbConnectionConfig, driver: IpcDriverManifest, - client: Mutex>, + /// `Arc` 让 `request` 能短锁拿 clone 后立刻释放,允许多 caller 并发调用 + /// `JsonRpcClient::request`。`Mutex>` 处理 connect/disconnect 的 + /// owner 切换。 + client: Mutex>>, } impl ExternalDbConnection { @@ -30,9 +34,29 @@ impl ExternalDbConnection { where T: serde::de::DeserializeOwned, { - let mut guard = self.client.lock().await; - let client = guard.as_mut().ok_or(DbError::NotConnected)?; - client.request(method, params).await + // 短锁:仅在拿 Arc clone 时持锁,之后释放,允许多 caller 并发调用 client。 + let client = { + let guard = self.client.lock().await; + guard.as_ref().cloned().ok_or(DbError::NotConnected)? + }; + + let result = client.request(method, params).await; + + // P0-4:transport 已 fatal(reader task 退出),evict 当前 broken client + // 让下次 request 直接得到 NotConnected,触发上层重连逻辑。 + // 用 Arc::ptr_eq 防止误踩 — 别的 caller 可能已经 evict + reconnect。 + if client.is_closed() { + let mut guard = self.client.lock().await; + if let Some(current) = guard.as_ref() { + if Arc::ptr_eq(current, &client) { + *guard = None; + // 旧 Arc 在最后一个 in-flight reference drop 后才真正释放, + // 由 JsonRpcClient::Drop 完成 reader_task abort + kill_on_drop child。 + } + } + } + + result } } @@ -57,21 +81,27 @@ impl DbConnection for ExternalDbConnection { } async fn connect(&mut self) -> Result<(), DbError> { - let mut client = JsonRpcClient::start(&self.driver).await?; + let client = JsonRpcClient::start(&self.driver).await?; + // initialize / connect 走 `&self`,这里直接用 owned client(尚未 Arc), + // 任一步失败就把 client drop 掉 → reader_task abort + child kill_on_drop。 let _: serde_json::Value = client.request("initialize", empty_params()).await?; let _: serde_json::Value = client .request("connect", connection_config_params(&self.config)) .await?; - *self.client.lock().await = Some(client); + *self.client.lock().await = Some(Arc::new(client)); Ok(()) } async fn disconnect(&mut self) -> Result<(), DbError> { - let mut client = self.client.lock().await.take(); - if let Some(client) = client.as_mut() { + let client_arc = self.client.lock().await.take(); + if let Some(client_arc) = client_arc { + // 尽力发出 disconnect RPC(可能因连接已断而失败,允许)。 let _: Result = - client.request("disconnect", empty_params()).await; - client.shutdown().await; + client_arc.request("disconnect", empty_params()).await; + // 显式 abort reader + kill+wait child,确保返回前子进程已退出。 + // 即便仍有 in-flight Arc clone(并发 query 未返回),它们会因 reader 关闭 + // 而立即收到 disconnected 错误,然后 Arc 自然 drop。 + client_arc.shutdown().await; } Ok(()) } diff --git a/crates/db/tests/ipc_concurrency.rs b/crates/db/tests/ipc_concurrency.rs new file mode 100644 index 0000000000..fdce037d3a --- /dev/null +++ b/crates/db/tests/ipc_concurrency.rs @@ -0,0 +1,251 @@ +//! P0-1 并发请求路由的 TDD 测试。 +//! +//! 旧实现:`Mutex>` + `request_value(&mut self)` 把整个 +//! stream 串行化,多 caller 必须排队。当 server 设计为「收齐 N 个再乱序回复」 +//! 的 rendezvous 场景时,串行 client 永远凑不齐 N 个 → 死锁直至 30s 超时。 +//! +//! 新实现拆出 reader task + pending oneshot 路由表后,3 个 caller 应能并发 +//! 把 request 推到 server,server 乱序回复也能精确路由回各自的 caller。 + +use std::{collections::HashMap, path::PathBuf, sync::Arc, time::Duration}; + +use db::{ + DbConnection, SqlResult, + ipc::{ExternalDbConnection, IpcDriverEntry, IpcDriverManifest, IpcDriverTransport}, +}; +use interprocess::local_socket::{ + GenericNamespaced, ListenerOptions, + tokio::{Stream, prelude::*}, +}; +use ipc::{ + IpcRequest, IpcResponse, + framing::{recv_msg_async, send_msg_async}, +}; +use one_core::storage::{DatabaseType, DbConnectionConfig}; +use serde_json::json; +use tokio::sync::oneshot; + +// ───────────────────────── helpers ───────────────────────── + +fn make_manifest(socket_name: String) -> IpcDriverManifest { + IpcDriverManifest { + id: "concurrency-mock".into(), + name: "Concurrency Mock".into(), + description: String::new(), + version: String::new(), + entry: IpcDriverEntry { + command: String::new(), + args: Vec::new(), + working_dir: None, + }, + dialect: Default::default(), + ui: Default::default(), + transport: IpcDriverTransport::local_socket(socket_name), + manifest_dir: PathBuf::new(), + } +} + +fn make_config() -> DbConnectionConfig { + DbConnectionConfig { + id: "concurrency-mock".into(), + name: "Concurrency Mock".into(), + database_type: DatabaseType::External, + host: String::new(), + port: 0, + username: String::new(), + password: String::new(), + database: Some("mockdb".into()), + service_name: None, + sid: None, + workspace_id: None, + extra_params: HashMap::new(), + } +} + +fn unique_socket(tag: &str) -> String { + format!("onetcli-conc-{tag}-{}.sock", uuid::Uuid::new_v4()) +} + +fn make_query_response(id: u64, sql: &str) -> IpcResponse { + IpcResponse::result( + id, + json!({ + "type": "Query", + "sql": sql, + "columns": ["echo"], + "column_meta": [{ + "name": "echo", + "db_type": "VARCHAR", + "field_type": "Text", + "nullable": true + }], + "rows": [[sql]], + "elapsed_ms": 0 + }), + ) +} + +fn extract_query_sql(result: SqlResult) -> String { + match result { + SqlResult::Query(q) => q.sql, + other => panic!("unexpected result variant: {other:?}"), + } +} + +async fn handshake(stream: &mut Stream) -> std::io::Result<()> { + // initialize + connect:client 端会顺序发,这里也顺序回应。 + for _ in 0..2 { + let req: IpcRequest = recv_msg_async(&mut *stream).await?; + send_msg_async( + &mut *stream, + &IpcResponse::result(req.request_id, json!({})), + ) + .await?; + } + Ok(()) +} + +// ───────────────────────── Test:乱序回复路由 ───────────────────────── + +#[tokio::test] +async fn out_of_order_replies_are_routed_to_correct_caller() { + let socket_name = unique_socket("reorder"); + let (ready_tx, ready_rx) = oneshot::channel(); + let server_socket = socket_name.clone(); + let server = tokio::spawn(async move { run_reorder_server(&server_socket, ready_tx).await }); + ready_rx.await.unwrap(); + + let driver = make_manifest(socket_name); + let mut conn = ExternalDbConnection::new(make_config(), driver); + conn.connect().await.expect("connect"); + let conn = Arc::new(conn); + + // 3 个并发 query,等 server 收齐再乱序回复。 + let h1 = { + let c = conn.clone(); + tokio::spawn(async move { c.query("REQ-A").await }) + }; + let h2 = { + let c = conn.clone(); + tokio::spawn(async move { c.query("REQ-B").await }) + }; + let h3 = { + let c = conn.clone(); + tokio::spawn(async move { c.query("REQ-C").await }) + }; + + // 5s outer timeout 防止旧实现死锁拖到 30s。 + let test_run = async { + let r1 = h1.await.unwrap().expect("REQ-A should succeed"); + let r2 = h2.await.unwrap().expect("REQ-B should succeed"); + let r3 = h3.await.unwrap().expect("REQ-C should succeed"); + ( + extract_query_sql(r1), + extract_query_sql(r2), + extract_query_sql(r3), + ) + }; + let (s1, s2, s3) = tokio::time::timeout(Duration::from_secs(5), test_run) + .await + .expect("concurrent queries must complete within 5s — old client serializes through Mutex and deadlocks the rendezvous"); + + // 每个 caller 必须拿到自己发的 sql,而不是别人的回复。 + assert_eq!(s1, "REQ-A"); + assert_eq!(s2, "REQ-B"); + assert_eq!(s3, "REQ-C"); + + let mut conn = Arc::try_unwrap(conn) + .ok() + .expect("no other strong refs after join"); + conn.disconnect().await.expect("disconnect"); + server.await.unwrap().expect("server task ok"); +} + +async fn run_reorder_server( + socket_name: &str, + ready_tx: oneshot::Sender<()>, +) -> std::io::Result<()> { + let name = socket_name.to_ns_name::()?; + let listener = ListenerOptions::new().name(name).create_tokio()?; + let _ = ready_tx.send(()); + let mut conn = listener.accept().await?; + + handshake(&mut conn).await?; + + // 关键:**收齐 3 个 query 后再回**,逼客户端必须并发发送(rendezvous)。 + let q1: IpcRequest = recv_msg_async(&mut conn).await?; + let q2: IpcRequest = recv_msg_async(&mut conn).await?; + let q3: IpcRequest = recv_msg_async(&mut conn).await?; + + let sql_q1 = q1.params["sql"].as_str().unwrap().to_owned(); + let sql_q2 = q2.params["sql"].as_str().unwrap().to_owned(); + let sql_q3 = q3.params["sql"].as_str().unwrap().to_owned(); + + // 关键:乱序回复 — q3 → q1 → q2。 + send_msg_async(&mut conn, &make_query_response(q3.request_id, &sql_q3)).await?; + send_msg_async(&mut conn, &make_query_response(q1.request_id, &sql_q1)).await?; + send_msg_async(&mut conn, &make_query_response(q2.request_id, &sql_q2)).await?; + + // disconnect。 + let dc: IpcRequest = recv_msg_async(&mut conn).await?; + send_msg_async(&mut conn, &IpcResponse::result(dc.request_id, json!({}))).await?; + Ok(()) +} + +// ───────────────────────── Test:fatal 后清理,下次 NotConnected ───────────────────────── + +#[tokio::test] +async fn fatal_transport_error_evicts_client_so_next_query_returns_not_connected() { + let socket_name = unique_socket("evict"); + let (ready_tx, ready_rx) = oneshot::channel(); + let server_socket = socket_name.clone(); + let server = + tokio::spawn( + async move { run_drop_after_handshake_server(&server_socket, ready_tx).await }, + ); + ready_rx.await.unwrap(); + + let driver = make_manifest(socket_name); + let mut conn = ExternalDbConnection::new(make_config(), driver); + conn.connect() + .await + .expect("connect should succeed before driver drops the stream"); + + // 首次 query:driver 已关 stream → reader 检测到 EOF → CloseGuard 标记 closed, + // caller 收到 disconnected 错误(具体类型依赖时序,但必须是错误)。 + let first = tokio::time::timeout(Duration::from_secs(3), conn.query("first")) + .await + .expect("first query must fail within 3s after disconnect"); + assert!( + first.is_err(), + "first query should fail because driver disconnected" + ); + + // 关键 assertion:第二次 query 必须立刻返回 NotConnected, + // 而不是再次走 transport(说明上一次失败已经 evict 了 broken client)。 + let second = conn.query("second").await; + let err = second.expect_err("second query should fail with NotConnected"); + assert!( + matches!(err, db::DbError::NotConnected), + "second query should return NotConnected after fatal transport error; got: {err:?}" + ); + + // server task 自然结束。 + let _ = tokio::time::timeout(Duration::from_secs(1), server).await; +} + +async fn run_drop_after_handshake_server( + socket_name: &str, + ready_tx: oneshot::Sender<()>, +) -> std::io::Result<()> { + let name = socket_name.to_ns_name::()?; + let listener = ListenerOptions::new().name(name).create_tokio()?; + let _ = ready_tx.send(()); + let mut conn = listener.accept().await?; + + handshake(&mut conn).await?; + + // 完成 initialize/connect 后立刻关 stream,模拟 driver 异常退出。 + drop(conn); + Ok(()) +} diff --git a/crates/db/tests/ipc_duckdb_driver.rs b/crates/db/tests/ipc_duckdb_driver.rs index 314fbe21af..5fd140e5bc 100644 --- a/crates/db/tests/ipc_duckdb_driver.rs +++ b/crates/db/tests/ipc_duckdb_driver.rs @@ -26,40 +26,27 @@ fn driver_binary() -> PathBuf { target_dir.join(name) } -#[tokio::test] -async fn duckdb_driver_ipc_full_integration() { - let binary = driver_binary(); - if !binary.exists() { - eprintln!( - "SKIP: duckdb_driver binary not found at {:?}\n\ - Build it first: cargo build -p duckdb_driver", - binary - ); - return; - } - - let temp = tempfile::tempdir().unwrap(); - let socket = format!("onetcli-test-duckdb-{}.sock", uuid::Uuid::new_v4()); - let db_path = temp.path().join("test.db"); - - let driver = IpcDriverManifest { +fn make_driver(binary: &std::path::Path, manifest_dir: &std::path::Path) -> IpcDriverManifest { + IpcDriverManifest { id: "duckdb".into(), name: "DuckDB".into(), description: String::new(), version: String::new(), entry: IpcDriverEntry { command: binary.to_string_lossy().into_owned(), - args: vec![socket.clone()], + args: Vec::new(), working_dir: None, }, dialect: Default::default(), ui: Default::default(), - transport: IpcDriverTransport::local_socket(socket), - manifest_dir: temp.path().to_path_buf(), - }; + transport: IpcDriverTransport::local_socket("duckdb-driver.sock"), + manifest_dir: manifest_dir.to_path_buf(), + } +} - let config = DbConnectionConfig { - id: "duckdb-test".into(), +fn make_config(id: &str, db_path: &std::path::Path) -> DbConnectionConfig { + DbConnectionConfig { + id: id.into(), name: "DuckDB Test".into(), database_type: DatabaseType::External, host: db_path.to_string_lossy().into_owned(), @@ -71,7 +58,32 @@ async fn duckdb_driver_ipc_full_integration() { sid: None, workspace_id: None, extra_params: HashMap::new(), - }; + } +} + +fn skip_if_missing_binary(binary: &std::path::Path) -> bool { + if binary.exists() { + return false; + } + eprintln!( + "SKIP: duckdb_driver binary not found at {:?}\n\ + Build it first: cargo build -p duckdb_driver", + binary + ); + true +} + +#[tokio::test] +async fn duckdb_driver_ipc_full_integration() { + let binary = driver_binary(); + if skip_if_missing_binary(&binary) { + return; + } + + let temp = tempfile::tempdir().unwrap(); + let db_path = temp.path().join("test.db"); + let driver = make_driver(&binary, temp.path()); + let config = make_config("duckdb-test", &db_path); // ---- connect ---- let mut conn = ExternalDbConnection::new(config.clone(), driver); @@ -149,3 +161,37 @@ async fn duckdb_driver_ipc_full_integration() { // ---- disconnect ---- conn.disconnect().await.expect("disconnect"); } + +#[tokio::test] +async fn same_duckdb_driver_can_open_multiple_connections_concurrently() { + let binary = driver_binary(); + if skip_if_missing_binary(&binary) { + return; + } + + let temp = tempfile::tempdir().unwrap(); + let mut handles = Vec::new(); + for idx in 0..3 { + let driver = make_driver(&binary, temp.path()); + let db_path = temp.path().join(format!("multi-{idx}.db")); + let config = make_config(&format!("duckdb-multi-{idx}"), &db_path); + handles.push(tokio::spawn(async move { + let mut conn = ExternalDbConnection::new(config, driver); + conn.connect().await.expect("connect"); + let result = conn.query(&format!("SELECT {idx} AS val")).await.unwrap(); + match result { + SqlResult::Query(q) => { + let expected = idx.to_string(); + assert_eq!(q.columns, vec!["val"]); + assert_eq!(q.rows[0][0].as_deref(), Some(expected.as_str())); + } + other => panic!("expected query result, got {other:?}"), + } + conn.disconnect().await.expect("disconnect"); + })); + } + + for handle in handles { + handle.await.unwrap(); + } +} diff --git a/crates/duckdb_driver/src/main.rs b/crates/duckdb_driver/src/main.rs index e3bb0b4964..3d2836528d 100644 --- a/crates/duckdb_driver/src/main.rs +++ b/crates/duckdb_driver/src/main.rs @@ -6,9 +6,10 @@ async fn main() -> Result<()> { .with_writer(std::io::stderr) .init(); - let socket_name = std::env::args() - .nth(1) - .or_else(|| std::env::var("ONETCLI_DUCKDB_DRIVER_SOCKET").ok()) + let socket_name = std::env::var("ONETCLI_IPC_SOCKET") + .or_else(|_| std::env::var("ONETCLI_DUCKDB_DRIVER_SOCKET")) + .ok() + .or_else(|| std::env::args().nth(1)) .unwrap_or_else(|| "onetcli-duckdb-driver.sock".to_string()); duckdb_driver::server::run(&socket_name).await From 1b9eb21d13931c476796a5560ecf17aa4aad5112 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=83=A1=E9=A3=9E?= <1835698775@qq.com> Date: Fri, 8 May 2026 10:39:11 +0800 Subject: [PATCH 02/45] =?UTF-8?q?refactor(db):=20=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E5=BA=93=E6=8F=92=E4=BB=B6=E7=9A=84=20capabi?= =?UTF-8?q?lities=20=E6=8E=A5=E5=8F=A3=E5=B9=B6=E9=87=8D=E6=9E=84=E7=9B=B8?= =?UTF-8?q?=E5=85=B3=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将各数据库插件中支持特性的接口替换为统一的 capabilities() 方法 - 修改相关代码逻辑改用 capabilities 字段判断数据库特性支持 - 为 IPC 插件新增通用的 optional_metadata 方法简化元数据请求 - 合并多驱动能力信息,提供统一的能力合并函数 merge_capabilities - 调整 IpcDriverManifest 以支持顶层声明 capabilities 字段并优先使用 - 更新测试以验证 capabilities 的正确性和兼容性 - 移除多余的 supports_xxx、uses_schema_as_database 等老接口实现与调用 - 修改 manager 及 plugin 使用新能力接口,保持行为一致 - 优化插件能力相关测试,确保仍支持函数和存储过程能力默认值 - 修正 IPC 客户端对 UnsupportedMethod 错误的映射,提升错误处理一致性 --- crates/db/src/clickhouse/plugin.rs | 30 +- crates/db/src/duckdb/plugin.rs | 23 +- crates/db/src/ipc/client.rs | 14 +- crates/db/src/ipc/plugin.rs | 263 +++++++++++++++--- crates/db/src/ipc/registry.rs | 57 +++- crates/db/src/manager.rs | 20 +- crates/db/src/mssql/plugin.rs | 32 ++- crates/db/src/mysql/plugin.rs | 26 +- crates/db/src/oracle/plugin.rs | 32 ++- crates/db/src/plugin.rs | 69 ++--- crates/db/src/plugin_manifest.rs | 3 + crates/db/src/postgresql/plugin.rs | 34 ++- crates/db/src/sqlite/plugin.rs | 28 +- crates/db/tests/ipc_concurrency.rs | 1 + crates/db/tests/ipc_duckdb_driver.rs | 1 + crates/db/tests/ipc_mock_driver.rs | 1 + .../src/chatdb/db_connection_selector.rs | 14 +- crates/db_view/src/database_objects_tab.rs | 2 +- crates/db_view/src/database_view_plugin.rs | 87 +++--- crates/db_view/src/db_tree_view.rs | 2 +- crates/db_view/src/sql_editor_view.rs | 5 +- crates/db_view/src/table_designer_tab.rs | 6 +- crates/duckdb_driver/src/metadata.rs | 25 +- crates/duckdb_driver/src/server.rs | 13 +- 24 files changed, 538 insertions(+), 250 deletions(-) diff --git a/crates/db/src/clickhouse/plugin.rs b/crates/db/src/clickhouse/plugin.rs index ebb1171240..5d77a3e4c8 100644 --- a/crates/db/src/clickhouse/plugin.rs +++ b/crates/db/src/clickhouse/plugin.rs @@ -18,8 +18,8 @@ use crate::manifest_helpers::{ use crate::plugin::{DatabaseOperationRequest, DatabasePlugin, SqlCompletionInfo}; use crate::plugin_manifest::{ DatabaseActionId, DatabaseActionManifest, DatabaseActionPlacement, DatabaseActionToolbarScope, - DatabaseFormFieldType, DatabaseFormKind, DatabaseFormManifest, DatabaseUiCapabilities, - DatabaseUiManifest, + DatabaseCapabilities, DatabaseFormFieldType, DatabaseFormKind, DatabaseFormManifest, + DatabaseUiCapabilities, DatabaseUiManifest, }; use crate::types::*; @@ -505,6 +505,15 @@ impl DatabasePlugin for ClickHousePlugin { format!("`{}`", identifier.replace("`", "``")) } + fn capabilities(&self) -> DatabaseCapabilities { + DatabaseUiCapabilities { + supports_functions: true, + supports_table_engine: true, + table_engines: self.engines(), + ..DatabaseUiCapabilities::default() + } + } + fn ui_manifest(&self) -> DatabaseUiManifest { CLICKHOUSE_UI_MANIFEST.clone() } @@ -688,10 +697,6 @@ impl DatabasePlugin for ClickHousePlugin { } } - fn supports_sequences(&self) -> bool { - false - } - // === Database/Schema Level Operations === fn sql_dialect(&self) -> Box { @@ -1112,10 +1117,6 @@ impl DatabasePlugin for ClickHousePlugin { // === Function Operations === - fn supports_procedures(&self) -> bool { - false - } - async fn list_procedures( &self, _connection: &dyn DbConnection, @@ -1585,6 +1586,15 @@ mod tests { assert_eq!(plugin.quote_identifier("col`umn"), "`col``umn`"); } + #[test] + fn test_capabilities() { + let capabilities = create_plugin().capabilities(); + assert!(capabilities.supports_functions); + assert!(!capabilities.supports_procedures); + assert!(!capabilities.supports_sequences); + assert_eq!(capabilities.table_engines, clickhouse_engine_names()); + } + #[test] fn test_ui_manifest_smoke() { let manifest = create_plugin().ui_manifest(); diff --git a/crates/db/src/duckdb/plugin.rs b/crates/db/src/duckdb/plugin.rs index c839d7b16e..9b277baa16 100644 --- a/crates/db/src/duckdb/plugin.rs +++ b/crates/db/src/duckdb/plugin.rs @@ -17,7 +17,8 @@ use crate::manifest_helpers::{DatabaseActionDescriptorExt, action, action_with_s use crate::plugin::{DatabaseOperationRequest, DatabasePlugin, SqlCompletionInfo}; use crate::plugin_manifest::{ DatabaseActionId, DatabaseActionManifest, DatabaseActionPlacement, DatabaseActionToolbarScope, - DatabaseFormFieldType, DatabaseFormKind, DatabaseFormManifest, DatabaseUiManifest, + DatabaseCapabilities, DatabaseFormFieldType, DatabaseFormKind, DatabaseFormManifest, + DatabaseUiCapabilities, DatabaseUiManifest, }; use crate::sqlite::SqlitePlugin; use crate::types::*; @@ -753,6 +754,10 @@ impl DatabasePlugin for DuckDbPlugin { DatabaseType::DuckDB } + fn capabilities(&self) -> DatabaseCapabilities { + DatabaseUiCapabilities::default() + } + fn ui_manifest(&self) -> DatabaseUiManifest { DUCKDB_UI_MANIFEST.clone() } @@ -1192,10 +1197,6 @@ impl DatabasePlugin for DuckDbPlugin { }) } - fn supports_functions(&self) -> bool { - false - } - async fn list_functions( &self, connection: &dyn DbConnection, @@ -1212,10 +1213,6 @@ impl DatabasePlugin for DuckDbPlugin { self.sqlite.list_functions_view(connection, database).await } - fn supports_procedures(&self) -> bool { - false - } - async fn list_procedures( &self, connection: &dyn DbConnection, @@ -1466,6 +1463,14 @@ mod tests { DuckDbPlugin::new() } + #[test] + fn test_capabilities() { + let capabilities = create_plugin().capabilities(); + assert!(!capabilities.supports_functions); + assert!(!capabilities.supports_procedures); + assert!(!capabilities.supports_sequences); + } + #[test] fn test_ui_manifest_smoke() { let manifest = create_plugin().ui_manifest(); diff --git a/crates/db/src/ipc/client.rs b/crates/db/src/ipc/client.rs index ef87b15ff2..fda3d5f1c8 100644 --- a/crates/db/src/ipc/client.rs +++ b/crates/db/src/ipc/client.rs @@ -5,7 +5,7 @@ use interprocess::local_socket::{ tokio::{Stream as LocalSocketStream, prelude::*}, }; use ipc::{ - IpcRequest, IpcResponse, + IpcErrorCode, IpcRequest, IpcResponse, framing::{recv_msg_async, send_msg_async}, }; use serde::de::DeserializeOwned; @@ -270,6 +270,9 @@ fn validate_response(response: IpcResponse, expected_id: u64) -> Result( + &self, + connection: &dyn DbConnection, + method: &str, + params: serde_json::Value, + ) -> Result> + where + T: serde::de::DeserializeOwned, + { + match self.metadata(connection, method, params).await { + Ok(value) => Ok(Some(value)), + Err(error) if is_not_supported(&error) => Ok(None), + Err(error) => Err(error), + } + } } impl Default for ExternalDatabasePlugin { @@ -120,29 +136,20 @@ impl DatabasePlugin for ExternalDatabasePlugin { .await { Ok(databases) => Ok(databases), - Err(_) => Ok(names_to_databases(self.list_databases(connection).await?)), + Err(error) if is_not_supported(&error) => { + Ok(names_to_databases(self.list_databases(connection).await?)) + } + Err(error) => Err(error), } } - fn supports_schema(&self) -> bool { - self.registry - .drivers() - .iter() - .any(|driver| driver.dialect.supports_schema) - } - - fn uses_schema_as_database(&self) -> bool { - self.registry - .drivers() - .iter() - .any(|driver| driver.dialect.uses_schema_as_database) - } - - fn supports_sequences(&self) -> bool { - self.registry - .drivers() - .iter() - .any(|driver| driver.dialect.supports_sequences) + fn capabilities(&self) -> DatabaseCapabilities { + merge_capabilities( + self.registry + .drivers() + .iter() + .map(IpcDriverManifest::effective_capabilities), + ) } fn sql_dialect(&self) -> Box { @@ -301,20 +308,89 @@ impl DatabasePlugin for ExternalDatabasePlugin { )) } + async fn list_foreign_keys( + &self, + connection: &dyn DbConnection, + database: &str, + schema: Option, + table: &str, + ) -> Result> { + Ok(self + .optional_metadata( + connection, + "metadata.list_foreign_keys", + table_metadata_params(database, schema, table), + ) + .await? + .unwrap_or_default()) + } + + async fn list_table_triggers( + &self, + connection: &dyn DbConnection, + database: &str, + schema: Option, + table: &str, + ) -> Result> { + Ok(self + .optional_metadata( + connection, + "metadata.list_table_triggers", + table_metadata_params(database, schema, table), + ) + .await? + .unwrap_or_default()) + } + + async fn list_table_checks( + &self, + connection: &dyn DbConnection, + database: &str, + schema: Option, + table: &str, + ) -> Result> { + Ok(self + .optional_metadata( + connection, + "metadata.list_table_checks", + table_metadata_params(database, schema, table), + ) + .await? + .unwrap_or_default()) + } + async fn list_functions( &self, - _connection: &dyn DbConnection, - _database: &str, + connection: &dyn DbConnection, + database: &str, ) -> Result> { - Ok(Vec::new()) + Ok(self + .optional_metadata( + connection, + "metadata.list_functions", + database_metadata_params(database, None), + ) + .await? + .unwrap_or_default()) } async fn list_functions_view( &self, - _connection: &dyn DbConnection, - _database: &str, + connection: &dyn DbConnection, + database: &str, ) -> Result { - Ok(ObjectView::default()) + let rows = self + .list_functions(connection, database) + .await? + .into_iter() + .map(|function| vec![function.name, function.return_type.unwrap_or_default()]) + .collect(); + Ok(object_view( + DbNodeType::Function, + "Functions", + vec!["Name", "Return Type"], + rows, + )) } fn ui_manifest(&self) -> DatabaseUiManifest { @@ -327,51 +403,110 @@ impl DatabasePlugin for ExternalDatabasePlugin { async fn list_procedures( &self, - _connection: &dyn DbConnection, - _database: &str, + connection: &dyn DbConnection, + database: &str, ) -> Result> { - Ok(Vec::new()) + Ok(self + .optional_metadata( + connection, + "metadata.list_procedures", + database_metadata_params(database, None), + ) + .await? + .unwrap_or_default()) } async fn list_procedures_view( &self, - _connection: &dyn DbConnection, - _database: &str, + connection: &dyn DbConnection, + database: &str, ) -> Result { - Ok(ObjectView::default()) + let rows = self + .list_procedures(connection, database) + .await? + .into_iter() + .map(|procedure| vec![procedure.name, procedure.parameters.join(", ")]) + .collect(); + Ok(object_view( + DbNodeType::Procedure, + "Procedures", + vec!["Name", "Parameters"], + rows, + )) } async fn list_triggers( &self, - _connection: &dyn DbConnection, - _database: &str, + connection: &dyn DbConnection, + database: &str, ) -> Result> { - Ok(Vec::new()) + Ok(self + .optional_metadata( + connection, + "metadata.list_triggers", + database_metadata_params(database, None), + ) + .await? + .unwrap_or_default()) } async fn list_triggers_view( &self, - _connection: &dyn DbConnection, - _database: &str, + connection: &dyn DbConnection, + database: &str, ) -> Result { - Ok(ObjectView::default()) + let rows = self + .list_triggers(connection, database) + .await? + .into_iter() + .map(|trigger| vec![trigger.name, trigger.table_name, trigger.event]) + .collect(); + Ok(object_view( + DbNodeType::Trigger, + "Triggers", + vec!["Name", "Table", "Event"], + rows, + )) } async fn list_sequences( &self, - _connection: &dyn DbConnection, - _database: &str, - _schema: Option, + connection: &dyn DbConnection, + database: &str, + schema: Option, ) -> Result> { - Ok(Vec::new()) + Ok(self + .optional_metadata( + connection, + "metadata.list_sequences", + database_metadata_params(database, schema), + ) + .await? + .unwrap_or_default()) } async fn list_sequences_view( &self, - _connection: &dyn DbConnection, - _database: &str, + connection: &dyn DbConnection, + database: &str, ) -> Result { - Ok(ObjectView::default()) + let rows = self + .list_sequences(connection, database, None) + .await? + .into_iter() + .map(|sequence| { + vec![ + sequence.name, + sequence.increment.unwrap_or_default().to_string(), + ] + }) + .collect(); + Ok(object_view( + DbNodeType::Sequence, + "Sequences", + vec!["Name", "Increment"], + rows, + )) } fn build_column_definition(&self, column: &ColumnInfo, include_name: bool) -> String { @@ -516,6 +651,42 @@ fn names_to_databases(names: Vec) -> Vec { .collect() } +fn is_not_supported(error: &anyhow::Error) -> bool { + error + .downcast_ref::() + .is_some_and(|error| matches!(error, DbError::NotSupported(_))) +} + +fn merge_capabilities( + capabilities: impl IntoIterator, +) -> DatabaseCapabilities { + capabilities + .into_iter() + .fold(DatabaseUiCapabilities::default(), |mut merged, current| { + merged.supports_schema |= current.supports_schema; + merged.uses_schema_as_database |= current.uses_schema_as_database; + merged.supports_sequences |= current.supports_sequences; + merged.supports_functions |= current.supports_functions; + merged.supports_procedures |= current.supports_procedures; + merged.supports_triggers |= current.supports_triggers; + merged.supports_table_engine |= current.supports_table_engine; + merged.supports_table_charset |= current.supports_table_charset; + merged.supports_table_collation |= current.supports_table_collation; + merged.supports_auto_increment |= current.supports_auto_increment; + merged.supports_tablespace |= current.supports_tablespace; + merged.supports_unsigned |= current.supports_unsigned; + merged.supports_enum_values |= current.supports_enum_values; + merged.show_charset_in_column_detail |= current.show_charset_in_column_detail; + merged.show_collation_in_column_detail |= current.show_collation_in_column_detail; + for engine in current.table_engines { + if !merged.table_engines.contains(&engine) { + merged.table_engines.push(engine); + } + } + merged + }) +} + fn object_view( db_node_type: DbNodeType, title: impl Into, diff --git a/crates/db/src/ipc/registry.rs b/crates/db/src/ipc/registry.rs index 7ebf800c1c..db08d659b5 100644 --- a/crates/db/src/ipc/registry.rs +++ b/crates/db/src/ipc/registry.rs @@ -1,5 +1,5 @@ use crate::connection::DbError; -use crate::plugin_manifest::DatabaseUiManifest; +use crate::plugin_manifest::{DatabaseCapabilities, DatabaseUiManifest}; use one_core::storage::get_config_dir; use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; @@ -20,6 +20,8 @@ pub struct IpcDriverManifest { #[serde(default)] pub dialect: IpcDriverDialect, #[serde(default)] + pub capabilities: Option, + #[serde(default)] pub ui: IpcDriverUi, #[serde(skip)] pub manifest_dir: PathBuf, @@ -103,6 +105,23 @@ impl IpcDriverManifest { .unwrap_or_else(|| self.manifest_dir.clone()) } + pub fn effective_capabilities(&self) -> DatabaseCapabilities { + let mut capabilities = self + .ui + .form + .as_ref() + .map(|manifest| manifest.capabilities.clone()) + .unwrap_or_else(|| DatabaseCapabilities { + supports_functions: true, + supports_procedures: true, + ..DatabaseCapabilities::default() + }); + capabilities.supports_schema |= self.dialect.supports_schema; + capabilities.supports_sequences |= self.dialect.supports_sequences; + capabilities.uses_schema_as_database |= self.dialect.uses_schema_as_database; + self.capabilities.clone().unwrap_or(capabilities) + } + fn validate(&self) -> Result<(), DbError> { if self.id.trim().is_empty() || self.name.trim().is_empty() { return Err(DbError::connection( @@ -244,4 +263,40 @@ mod tests { assert_eq!(registry.drivers().len(), 1); assert_eq!(registry.find("demo").unwrap().name, "Demo"); } + + #[test] + fn parses_top_level_capabilities() { + let manifest: IpcDriverManifest = serde_json::from_str( + r#"{"id":"demo","name":"Demo","entry":{"command":"python3"},"transport":{"name":"demo.sock"},"dialect":{"supports_schema":false},"capabilities":{"supports_schema":true,"supports_functions":true}}"#, + ) + .unwrap(); + + let capabilities = manifest.effective_capabilities(); + assert!(capabilities.supports_schema); + assert!(capabilities.supports_functions); + } + + #[test] + fn falls_back_to_legacy_dialect_capabilities() { + let manifest: IpcDriverManifest = serde_json::from_str( + r#"{"id":"demo","name":"Demo","entry":{"command":"python3"},"transport":{"name":"demo.sock"},"dialect":{"supports_schema":true,"supports_sequences":true}}"#, + ) + .unwrap(); + + let capabilities = manifest.effective_capabilities(); + assert!(capabilities.supports_schema); + assert!(capabilities.supports_sequences); + assert!(capabilities.supports_functions); + assert!(capabilities.supports_procedures); + } + + #[test] + fn falls_back_to_legacy_ui_form_capabilities() { + let manifest: IpcDriverManifest = serde_json::from_str( + r#"{"id":"demo","name":"Demo","entry":{"command":"python3"},"transport":{"name":"demo.sock"},"ui":{"form":{"schema_version":1,"capabilities":{"supports_triggers":true},"forms":[],"actions":{"actions":[]}}}}"#, + ) + .unwrap(); + + assert!(manifest.effective_capabilities().supports_triggers); + } } diff --git a/crates/db/src/manager.rs b/crates/db/src/manager.rs index 5bb5c093a0..59fd8a2efa 100644 --- a/crates/db/src/manager.rs +++ b/crates/db/src/manager.rs @@ -11,6 +11,7 @@ use crate::mssql::MsSqlPlugin; use crate::mysql::MySqlPlugin; use crate::oracle::OraclePlugin; use crate::plugin::DatabasePlugin; +use crate::plugin_manifest::DatabaseCapabilities; use crate::postgresql::PostgresPlugin; use crate::sqlite::SqlitePlugin; use crate::{ @@ -1630,20 +1631,11 @@ impl GlobalDbState { }) } - /// Check if database type supports schemas - pub fn supports_schema(&self, database_type: &DatabaseType) -> bool { + pub fn capabilities(&self, database_type: &DatabaseType) -> DatabaseCapabilities { self.db_manager .get_plugin(database_type) - .map(|plugin| plugin.supports_schema()) - .unwrap_or(false) - } - - /// Check if database type uses schemas as top-level nodes (like Oracle) - pub fn uses_schema_as_database(&self, database_type: &DatabaseType) -> bool { - self.db_manager - .get_plugin(database_type) - .map(|plugin| plugin.uses_schema_as_database()) - .unwrap_or(false) + .map(|plugin| plugin.capabilities()) + .unwrap_or_default() } /// List schemas in a database (with caching) @@ -2074,7 +2066,7 @@ impl GlobalDbState { let view = match node.node_type { DbNodeType::Connection => { if node.children_loaded { - if plugin.uses_schema_as_database() { + if plugin.capabilities().uses_schema_as_database { plugin.list_schemas_view(&*conn, &database).await.ok() } else { plugin.list_databases_view(&*conn).await.ok() @@ -2084,7 +2076,7 @@ impl GlobalDbState { } } DbNodeType::Database => { - if plugin.supports_schema() { + if plugin.capabilities().supports_schema { plugin.list_schemas_view(&*conn, &database).await.ok() } else { plugin.list_tables_view(&*conn, &database, None).await.ok() diff --git a/crates/db/src/mssql/plugin.rs b/crates/db/src/mssql/plugin.rs index 5f37fc8e1b..e623dd1c0b 100644 --- a/crates/db/src/mssql/plugin.rs +++ b/crates/db/src/mssql/plugin.rs @@ -21,8 +21,8 @@ use crate::mssql::connection::MssqlDbConnection; use crate::plugin::{DatabasePlugin, SqlCompletionInfo}; use crate::plugin_manifest::{ DatabaseActionId, DatabaseActionManifest, DatabaseActionPlacement, DatabaseActionToolbarScope, - DatabaseFormFieldType, DatabaseFormKind, DatabaseFormManifest, DatabaseUiCapabilities, - DatabaseUiManifest, FormSelectOption, ReferenceDataKind, + DatabaseCapabilities, DatabaseFormFieldType, DatabaseFormKind, DatabaseFormManifest, + DatabaseUiCapabilities, DatabaseUiManifest, FormSelectOption, ReferenceDataKind, }; use crate::types::*; @@ -588,6 +588,18 @@ impl DatabasePlugin for MsSqlPlugin { format!("[{}]", identifier.replace("]", "]]")) } + fn capabilities(&self) -> DatabaseCapabilities { + DatabaseUiCapabilities { + supports_schema: true, + supports_sequences: true, + supports_functions: true, + supports_procedures: true, + supports_triggers: true, + supports_table_collation: true, + ..DatabaseUiCapabilities::default() + } + } + fn ui_manifest(&self) -> DatabaseUiManifest { MSSQL_UI_MANIFEST.clone() } @@ -885,14 +897,6 @@ impl DatabasePlugin for MsSqlPlugin { } } - fn supports_schema(&self) -> bool { - true - } - - fn supports_sequences(&self) -> bool { - true - } - fn sql_dialect(&self) -> Box { Box::new(sqlparser::dialect::MsSqlDialect {}) } @@ -2555,15 +2559,15 @@ mod tests { } #[test] - fn test_supports_schema() { + fn test_capabilities_support_schema() { let plugin = create_plugin(); - assert!(plugin.supports_schema()); + assert!(plugin.capabilities().supports_schema); } #[test] - fn test_supports_sequences() { + fn test_capabilities_support_sequences() { let plugin = create_plugin(); - assert!(plugin.supports_sequences()); + assert!(plugin.capabilities().supports_sequences); } #[test] diff --git a/crates/db/src/mysql/plugin.rs b/crates/db/src/mysql/plugin.rs index d1de949f19..c1e961a297 100644 --- a/crates/db/src/mysql/plugin.rs +++ b/crates/db/src/mysql/plugin.rs @@ -15,10 +15,10 @@ use crate::mysql::connection::MysqlDbConnection; use crate::plugin::{DatabasePlugin, SqlCompletionInfo}; use crate::plugin_manifest::{ DatabaseActionDescriptor, DatabaseActionId, DatabaseActionManifest, DatabaseActionPlacement, - DatabaseActionTarget, DatabaseActionToolbarScope, DatabaseFormField, DatabaseFormFieldType, - DatabaseFormKind, DatabaseFormManifest, DatabaseFormTab, DatabaseUiCapabilities, - DatabaseUiManifest, FormDefaultRule, FormSelectOption, FormValueCondition, FormVisibilityRule, - ReferenceDataKind, + DatabaseActionTarget, DatabaseActionToolbarScope, DatabaseCapabilities, DatabaseFormField, + DatabaseFormFieldType, DatabaseFormKind, DatabaseFormManifest, DatabaseFormTab, + DatabaseUiCapabilities, DatabaseUiManifest, FormDefaultRule, FormSelectOption, + FormValueCondition, FormVisibilityRule, ReferenceDataKind, }; use crate::types::*; @@ -1004,6 +1004,24 @@ impl DatabasePlugin for MySqlPlugin { }.with_standard_sql() } + fn capabilities(&self) -> DatabaseCapabilities { + DatabaseUiCapabilities { + supports_functions: true, + supports_procedures: true, + supports_triggers: true, + supports_table_engine: true, + supports_table_charset: true, + supports_table_collation: true, + supports_auto_increment: true, + supports_unsigned: true, + supports_enum_values: true, + show_charset_in_column_detail: true, + show_collation_in_column_detail: true, + table_engines: self.engines(), + ..DatabaseUiCapabilities::default() + } + } + fn ui_manifest(&self) -> DatabaseUiManifest { MYSQL_UI_MANIFEST.clone() } diff --git a/crates/db/src/oracle/plugin.rs b/crates/db/src/oracle/plugin.rs index dee1466219..3e0ae559d5 100644 --- a/crates/db/src/oracle/plugin.rs +++ b/crates/db/src/oracle/plugin.rs @@ -22,8 +22,8 @@ use crate::oracle::connection::OracleDbConnection; use crate::plugin::{DatabasePlugin, SqlCompletionInfo}; use crate::plugin_manifest::{ DatabaseActionId, DatabaseActionManifest, DatabaseActionPlacement, DatabaseActionToolbarScope, - DatabaseFormFieldType, DatabaseFormKind, DatabaseFormManifest, DatabaseUiCapabilities, - DatabaseUiManifest, + DatabaseCapabilities, DatabaseFormFieldType, DatabaseFormKind, DatabaseFormManifest, + DatabaseUiCapabilities, DatabaseUiManifest, }; use crate::types::*; @@ -485,6 +485,18 @@ impl DatabasePlugin for OraclePlugin { format!("\"{}\"", identifier.replace("\"", "\"\"")) } + fn capabilities(&self) -> DatabaseCapabilities { + DatabaseUiCapabilities { + uses_schema_as_database: true, + supports_sequences: true, + supports_functions: true, + supports_procedures: true, + supports_triggers: true, + supports_tablespace: true, + ..DatabaseUiCapabilities::default() + } + } + fn ui_manifest(&self) -> DatabaseUiManifest { ORACLE_UI_MANIFEST.clone() } @@ -493,10 +505,6 @@ impl DatabasePlugin for OraclePlugin { Box::new(sqlparser::dialect::OracleDialect {}) } - fn supports_sequences(&self) -> bool { - true - } - fn supports_rowid(&self) -> bool { true } @@ -581,10 +589,6 @@ impl DatabasePlugin for OraclePlugin { }) } - fn uses_schema_as_database(&self) -> bool { - true - } - async fn list_schemas( &self, connection: &dyn DbConnection, @@ -2146,15 +2150,15 @@ mod tests { } #[test] - fn test_supports_sequences() { + fn test_capabilities_support_sequences() { let plugin = create_plugin(); - assert!(plugin.supports_sequences()); + assert!(plugin.capabilities().supports_sequences); } #[test] - fn test_supports_schema() { + fn test_capabilities_do_not_support_schema() { let plugin = create_plugin(); - assert!(!plugin.supports_schema()); + assert!(!plugin.capabilities().supports_schema); } #[test] diff --git a/crates/db/src/plugin.rs b/crates/db/src/plugin.rs index 7ae220ea83..913cae770d 100644 --- a/crates/db/src/plugin.rs +++ b/crates/db/src/plugin.rs @@ -9,7 +9,8 @@ use crate::import_export::{ }, }; use crate::plugin_manifest::{ - DatabaseUiCapabilities, DatabaseUiManifest, FormSelectOption, ReferenceDataKind, + DatabaseCapabilities, DatabaseUiCapabilities, DatabaseUiManifest, FormSelectOption, + ReferenceDataKind, }; use crate::streaming_parser::StreamingSqlParser; use crate::types::*; @@ -142,22 +143,6 @@ pub trait DatabasePlugin: Send + Sync { connection: &dyn DbConnection, ) -> Result>; - /// Whether this database supports schemas (e.g., PostgreSQL, MSSQL) - fn supports_schema(&self) -> bool { - false - } - - /// Whether this database uses schemas as top-level nodes instead of databases. - /// Oracle uses this because it connects via service_name and then lists schemas (users). - fn uses_schema_as_database(&self) -> bool { - false - } - - /// Whether this database supports sequences (e.g., PostgreSQL, Oracle, MSSQL) - fn supports_sequences(&self) -> bool { - false - } - /// Whether this database supports rowid for row identification (e.g., Oracle, SQLite) fn supports_rowid(&self) -> bool { false @@ -425,10 +410,6 @@ pub trait DatabasePlugin: Send + Sync { // === Function Operations === - fn supports_functions(&self) -> bool { - true - } - async fn list_functions( &self, connection: &dyn DbConnection, @@ -441,23 +422,17 @@ pub trait DatabasePlugin: Send + Sync { database: &str, ) -> Result; - fn supports_procedures(&self) -> bool { - true + fn capabilities(&self) -> DatabaseCapabilities { + DatabaseUiCapabilities { + supports_functions: true, + supports_procedures: true, + table_engines: self.engines(), + ..DatabaseUiCapabilities::default() + } } fn ui_manifest(&self) -> DatabaseUiManifest { - DatabaseUiManifest { - capabilities: DatabaseUiCapabilities { - supports_schema: self.supports_schema(), - uses_schema_as_database: self.uses_schema_as_database(), - supports_sequences: self.supports_sequences(), - supports_functions: self.supports_functions(), - supports_procedures: self.supports_procedures(), - table_engines: self.engines(), - ..DatabaseUiCapabilities::default() - }, - ..DatabaseUiManifest::default() - } + DatabaseUiManifest::default() } fn resolve_reference_data( @@ -569,7 +544,7 @@ pub trait DatabasePlugin: Send + Sync { let id = &node.id; let schemas; let mut metadata: HashMap = HashMap::new(); - if self.uses_schema_as_database() { + if self.capabilities().uses_schema_as_database { schemas = self.list_schemas(connection, "").await?; metadata.insert("database".to_string(), "".to_string()); } else { @@ -693,8 +668,10 @@ pub trait DatabasePlugin: Send + Sync { } nodes.push(views_folder); + let capabilities = self.capabilities(); + // Functions folder - if self.supports_functions() { + if capabilities.supports_functions { let functions = self .list_functions(connection, database) .await @@ -730,7 +707,7 @@ pub trait DatabasePlugin: Send + Sync { } // Procedures folder - if self.supports_procedures() { + if capabilities.supports_procedures { let procedures = self .list_procedures(connection, database) .await @@ -766,7 +743,7 @@ pub trait DatabasePlugin: Send + Sync { } // Sequences folder (only for databases that support sequences) - if self.supports_sequences() { + if capabilities.supports_sequences { let sequences = self .list_sequences(connection, database, schema) .await @@ -848,14 +825,14 @@ pub trait DatabasePlugin: Send + Sync { let id = &node.id; match node.node_type { DbNodeType::Connection => { - if self.uses_schema_as_database() { + if self.capabilities().uses_schema_as_database { self.build_schema_tree(connection, node).await } else { self.build_database_tree(connection, node).await } } DbNodeType::Database => { - if self.supports_schema() { + if self.capabilities().supports_schema { self.build_schema_tree(connection, node).await } else { self.build_database_or_schema_children(connection, node, None) @@ -2814,6 +2791,16 @@ mod tests { use sqlparser::dialect::MySqlDialect; use sqlparser::parser::Parser; + // ==================== capabilities tests ==================== + + #[test] + fn default_capabilities_support_functions_and_procedures() { + let plugin = MySqlPlugin::new(); + let capabilities = DatabasePlugin::capabilities(&plugin); + assert!(capabilities.supports_functions); + assert!(capabilities.supports_procedures); + } + // ==================== is_query_stmt tests (AST-based) ==================== #[test] diff --git a/crates/db/src/plugin_manifest.rs b/crates/db/src/plugin_manifest.rs index cc64549b30..620badfc61 100644 --- a/crates/db/src/plugin_manifest.rs +++ b/crates/db/src/plugin_manifest.rs @@ -24,6 +24,7 @@ impl Default for DatabaseUiManifest { } #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] pub struct DatabaseUiCapabilities { pub supports_schema: bool, pub uses_schema_as_database: bool, @@ -43,6 +44,8 @@ pub struct DatabaseUiCapabilities { pub table_engines: Vec, } +pub type DatabaseCapabilities = DatabaseUiCapabilities; + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum DatabaseFormKind { Connection, diff --git a/crates/db/src/postgresql/plugin.rs b/crates/db/src/postgresql/plugin.rs index 1c252722bd..0df32d9d76 100644 --- a/crates/db/src/postgresql/plugin.rs +++ b/crates/db/src/postgresql/plugin.rs @@ -20,8 +20,8 @@ use crate::manifest_helpers::{ use crate::plugin::{DatabasePlugin, SqlCompletionInfo}; use crate::plugin_manifest::{ DatabaseActionId, DatabaseActionManifest, DatabaseActionPlacement, DatabaseActionToolbarScope, - DatabaseFormFieldType, DatabaseFormKind, DatabaseFormManifest, DatabaseUiCapabilities, - DatabaseUiManifest, + DatabaseCapabilities, DatabaseFormFieldType, DatabaseFormKind, DatabaseFormManifest, + DatabaseUiCapabilities, DatabaseUiManifest, }; use crate::postgresql::connection::PostgresDbConnection; use crate::types::*; @@ -681,6 +681,20 @@ impl DatabasePlugin for PostgresPlugin { format!("\"{}\"", identifier.replace("\"", "\"\"")) } + fn capabilities(&self) -> DatabaseCapabilities { + DatabaseUiCapabilities { + supports_schema: true, + supports_sequences: true, + supports_functions: true, + supports_procedures: true, + supports_triggers: true, + supports_table_charset: true, + supports_table_collation: true, + supports_tablespace: true, + ..DatabaseUiCapabilities::default() + } + } + fn ui_manifest(&self) -> DatabaseUiManifest { POSTGRESQL_UI_MANIFEST.clone() } @@ -997,14 +1011,6 @@ impl DatabasePlugin for PostgresPlugin { } } - fn supports_schema(&self) -> bool { - true - } - - fn supports_sequences(&self) -> bool { - true - } - fn sql_dialect(&self) -> Box { Box::new(sqlparser::dialect::PostgreSqlDialect {}) } @@ -2226,15 +2232,15 @@ mod tests { } #[test] - fn test_supports_schema() { + fn test_capabilities_support_schema() { let plugin = create_plugin(); - assert!(plugin.supports_schema()); + assert!(plugin.capabilities().supports_schema); } #[test] - fn test_supports_sequences() { + fn test_capabilities_support_sequences() { let plugin = create_plugin(); - assert!(plugin.supports_sequences()); + assert!(plugin.capabilities().supports_sequences); } #[test] diff --git a/crates/db/src/sqlite/plugin.rs b/crates/db/src/sqlite/plugin.rs index 8cdbac90fd..00b62dfbdb 100644 --- a/crates/db/src/sqlite/plugin.rs +++ b/crates/db/src/sqlite/plugin.rs @@ -15,8 +15,8 @@ use crate::manifest_helpers::{DatabaseActionDescriptorExt, action, action_with_s use crate::plugin::{DatabasePlugin, SqlCompletionInfo}; use crate::plugin_manifest::{ DatabaseActionId, DatabaseActionManifest, DatabaseActionPlacement, DatabaseActionToolbarScope, - DatabaseFormFieldType, DatabaseFormKind, DatabaseFormManifest, DatabaseUiCapabilities, - DatabaseUiManifest, + DatabaseCapabilities, DatabaseFormFieldType, DatabaseFormKind, DatabaseFormManifest, + DatabaseUiCapabilities, DatabaseUiManifest, }; use crate::sqlite::SqliteDbConnection; use crate::types::*; @@ -572,6 +572,13 @@ impl DatabasePlugin for SqlitePlugin { DatabaseType::SQLite } + fn capabilities(&self) -> DatabaseCapabilities { + DatabaseUiCapabilities { + supports_auto_increment: true, + ..DatabaseUiCapabilities::default() + } + } + fn ui_manifest(&self) -> DatabaseUiManifest { SQLITE_UI_MANIFEST.clone() } @@ -1013,10 +1020,6 @@ impl DatabasePlugin for SqlitePlugin { }) } - fn supports_functions(&self) -> bool { - false - } - async fn list_functions( &self, _connection: &dyn DbConnection, @@ -1042,10 +1045,6 @@ impl DatabasePlugin for SqlitePlugin { }) } - fn supports_procedures(&self) -> bool { - false - } - async fn list_procedures( &self, _connection: &dyn DbConnection, @@ -1460,6 +1459,15 @@ mod tests { assert_eq!(plugin.quote_identifier("col\"umn"), "\"col\"\"umn\""); } + #[test] + fn test_capabilities() { + let capabilities = create_plugin().capabilities(); + assert!(capabilities.supports_auto_increment); + assert!(!capabilities.supports_functions); + assert!(!capabilities.supports_procedures); + assert!(!capabilities.supports_sequences); + } + #[test] fn test_ui_manifest_smoke() { let manifest = create_plugin().ui_manifest(); diff --git a/crates/db/tests/ipc_concurrency.rs b/crates/db/tests/ipc_concurrency.rs index fdce037d3a..7ec9814bc8 100644 --- a/crates/db/tests/ipc_concurrency.rs +++ b/crates/db/tests/ipc_concurrency.rs @@ -39,6 +39,7 @@ fn make_manifest(socket_name: String) -> IpcDriverManifest { working_dir: None, }, dialect: Default::default(), + capabilities: None, ui: Default::default(), transport: IpcDriverTransport::local_socket(socket_name), manifest_dir: PathBuf::new(), diff --git a/crates/db/tests/ipc_duckdb_driver.rs b/crates/db/tests/ipc_duckdb_driver.rs index 5fd140e5bc..57aff32eb2 100644 --- a/crates/db/tests/ipc_duckdb_driver.rs +++ b/crates/db/tests/ipc_duckdb_driver.rs @@ -38,6 +38,7 @@ fn make_driver(binary: &std::path::Path, manifest_dir: &std::path::Path) -> IpcD working_dir: None, }, dialect: Default::default(), + capabilities: None, ui: Default::default(), transport: IpcDriverTransport::local_socket("duckdb-driver.sock"), manifest_dir: manifest_dir.to_path_buf(), diff --git a/crates/db/tests/ipc_mock_driver.rs b/crates/db/tests/ipc_mock_driver.rs index 83bac13b18..9b81779df2 100644 --- a/crates/db/tests/ipc_mock_driver.rs +++ b/crates/db/tests/ipc_mock_driver.rs @@ -36,6 +36,7 @@ async fn external_connection_uses_mock_local_socket_driver() { working_dir: None, }, dialect: Default::default(), + capabilities: None, ui: Default::default(), transport: IpcDriverTransport::local_socket(socket_name), manifest_dir: PathBuf::new(), diff --git a/crates/db_view/src/chatdb/db_connection_selector.rs b/crates/db_view/src/chatdb/db_connection_selector.rs index 66242ed202..a2d6e36f52 100644 --- a/crates/db_view/src/chatdb/db_connection_selector.rs +++ b/crates/db_view/src/chatdb/db_connection_selector.rs @@ -223,14 +223,6 @@ impl DbConnectionSelector { )) } - pub fn supports_schema(&self) -> bool { - self.supports_schema - } - - pub fn uses_schema_as_database(&self) -> bool { - self.uses_schema_as_database - } - fn snapshot(&self) -> DbConnectionSelectorSnapshot { DbConnectionSelectorSnapshot { connections: self.connections.clone(), @@ -427,9 +419,9 @@ impl DbConnectionSelector { self.loading_schemas = false; let global_db_state = cx.global::().clone(); - self.supports_schema = global_db_state.supports_schema(&connection.database_type); - self.uses_schema_as_database = - global_db_state.uses_schema_as_database(&connection.database_type); + let capabilities = global_db_state.capabilities(&connection.database_type); + self.supports_schema = capabilities.supports_schema; + self.uses_schema_as_database = capabilities.uses_schema_as_database; self.register_connection(connection.id.clone(), cx); self.emit_selection(cx); diff --git a/crates/db_view/src/database_objects_tab.rs b/crates/db_view/src/database_objects_tab.rs index 9459a6a905..cb8fc84e02 100644 --- a/crates/db_view/src/database_objects_tab.rs +++ b/crates/db_view/src/database_objects_tab.rs @@ -802,7 +802,7 @@ impl DatabaseObjects { }); let toolbar_buttons = - build_toolbar_buttons_for(database_type, node_type, data_db_node_type); + build_toolbar_buttons_for(database_type, node_type, data_db_node_type, cx); for btn_config in toolbar_buttons { let button = match btn_config.button_type { diff --git a/crates/db_view/src/database_view_plugin.rs b/crates/db_view/src/database_view_plugin.rs index caa167a8e2..53ca970517 100644 --- a/crates/db_view/src/database_view_plugin.rs +++ b/crates/db_view/src/database_view_plugin.rs @@ -1,18 +1,10 @@ use db::DbNodeType; -use db::clickhouse::ClickHousePlugin; -use db::duckdb::DuckDbPlugin; -use db::ipc::ExternalDatabasePlugin; use db::ipc::{EXTERNAL_DRIVER_ID_PARAM, IpcDriverManifest, IpcDriverRegistry}; -use db::mssql::MsSqlPlugin; -use db::mysql::MySqlPlugin; -use db::oracle::OraclePlugin; use db::plugin::DatabasePlugin; use db::plugin_manifest::{ DatabaseActionDescriptor, DatabaseActionId, DatabaseActionPlacement, - DatabaseActionToolbarScope, DatabaseFormKind, DatabaseUiManifest, + DatabaseActionToolbarScope, DatabaseCapabilities, DatabaseFormKind, DatabaseUiManifest, }; -use db::postgresql::PostgresPlugin; -use db::sqlite::SqlitePlugin; use gpui::{App, AppContext, Entity, Window}; use gpui_component::IconName; use one_core::storage::DatabaseType; @@ -201,13 +193,15 @@ impl Default for ColumnEditorCapabilities { struct ManifestDatabaseViewPlugin { database_type: DatabaseType, manifest: DatabaseUiManifest, + capabilities: DatabaseCapabilities, } impl ManifestDatabaseViewPlugin { - fn new(database_type: DatabaseType) -> Self { + fn new(database_type: DatabaseType, plugin: &dyn DatabasePlugin) -> Self { Self { database_type, - manifest: build_ui_manifest(database_type), + manifest: plugin.ui_manifest(), + capabilities: plugin.capabilities(), } } @@ -304,15 +298,15 @@ impl ManifestDatabaseViewPlugin { } fn get_table_designer_capabilities(&self) -> TableDesignerCapabilities { - to_table_designer_capabilities(&self.manifest.capabilities) + to_table_designer_capabilities(&self.capabilities) } fn get_engines(&self) -> Vec { - self.manifest.capabilities.table_engines.clone() + self.capabilities.table_engines.clone() } fn get_column_editor_capabilities(&self) -> ColumnEditorCapabilities { - to_column_editor_capabilities(&self.manifest.capabilities) + to_column_editor_capabilities(&self.capabilities) } fn build_context_menu(&self, node_id: &str, node_type: DbNodeType) -> Vec { @@ -404,8 +398,16 @@ impl ManifestDatabaseViewPlugin { } } -fn manifest_plugin(database_type: DatabaseType) -> ManifestDatabaseViewPlugin { - ManifestDatabaseViewPlugin::new(database_type) +fn manifest_plugin( + database_type: DatabaseType, + cx: &impl AppContext, +) -> ManifestDatabaseViewPlugin { + let plugin = cx.read_global::(|state, _| { + state + .get_plugin(&database_type) + .expect("database plugin should exist") + }); + ManifestDatabaseViewPlugin::new(database_type, plugin.as_ref()) } fn action_to_context_menu_item( @@ -567,7 +569,7 @@ pub fn create_connection_form_for( window: &mut Window, cx: &mut App, ) -> Entity { - manifest_plugin(database_type).create_connection_form(window, cx) + manifest_plugin(database_type, cx).create_connection_form(window, cx) } pub fn create_external_connection_form_for( @@ -654,7 +656,7 @@ pub fn create_database_editor_view_for_new( window: &mut Window, cx: &mut App, ) -> Entity { - manifest_plugin(database_type).create_database_editor_view(connection_id, window, cx) + manifest_plugin(database_type, cx).create_database_editor_view(connection_id, window, cx) } pub fn create_database_editor_view_for_edit_type( @@ -664,7 +666,7 @@ pub fn create_database_editor_view_for_edit_type( window: &mut Window, cx: &mut App, ) -> Entity { - manifest_plugin(database_type).create_database_editor_view_for_edit( + manifest_plugin(database_type, cx).create_database_editor_view_for_edit( connection_id, database_name, window, @@ -679,7 +681,7 @@ pub fn create_schema_editor_view_for( window: &mut Window, cx: &mut App, ) -> Option> { - manifest_plugin(database_type).create_schema_editor_view( + manifest_plugin(database_type, cx).create_schema_editor_view( connection_id, database_name, window, @@ -691,8 +693,9 @@ pub fn build_context_menu_for( database_type: DatabaseType, node_id: &str, node_type: DbNodeType, + cx: &impl AppContext, ) -> Vec { - let mut items = manifest_plugin(database_type).build_context_menu(node_id, node_type); + let mut items = manifest_plugin(database_type, cx).build_context_menu(node_id, node_type); append_er_diagram_item(&mut items, node_id, node_type); items } @@ -701,8 +704,9 @@ pub fn build_toolbar_buttons_for( database_type: DatabaseType, node_type: DbNodeType, data_node_type: DbNodeType, + cx: &impl AppContext, ) -> Vec { - manifest_plugin(database_type).build_toolbar_buttons(node_type, data_node_type) + manifest_plugin(database_type, cx).build_toolbar_buttons(node_type, data_node_type) } fn append_er_diagram_item(items: &mut Vec, node_id: &str, node_type: DbNodeType) { @@ -722,29 +726,20 @@ fn append_er_diagram_item(items: &mut Vec, node_id: &str, node_ pub fn get_table_designer_capabilities_for( database_type: DatabaseType, + cx: &impl AppContext, ) -> TableDesignerCapabilities { - manifest_plugin(database_type).get_table_designer_capabilities() + manifest_plugin(database_type, cx).get_table_designer_capabilities() } -pub fn get_column_editor_capabilities_for(database_type: DatabaseType) -> ColumnEditorCapabilities { - manifest_plugin(database_type).get_column_editor_capabilities() +pub fn get_column_editor_capabilities_for( + database_type: DatabaseType, + cx: &impl AppContext, +) -> ColumnEditorCapabilities { + manifest_plugin(database_type, cx).get_column_editor_capabilities() } -pub fn get_engines_for(database_type: DatabaseType) -> Vec { - manifest_plugin(database_type).get_engines() -} - -fn build_ui_manifest(database_type: DatabaseType) -> DatabaseUiManifest { - match database_type { - DatabaseType::MySQL => MySqlPlugin::new().ui_manifest(), - DatabaseType::PostgreSQL => PostgresPlugin::new().ui_manifest(), - DatabaseType::MSSQL => MsSqlPlugin::new().ui_manifest(), - DatabaseType::Oracle => OraclePlugin::new().ui_manifest(), - DatabaseType::ClickHouse => ClickHousePlugin::new().ui_manifest(), - DatabaseType::SQLite => SqlitePlugin::new().ui_manifest(), - DatabaseType::DuckDB => DuckDbPlugin::new().ui_manifest(), - DatabaseType::External => ExternalDatabasePlugin::new().ui_manifest(), - } +pub fn get_engines_for(database_type: DatabaseType, cx: &impl AppContext) -> Vec { + manifest_plugin(database_type, cx).get_engines() } fn map_tree_event(action_id: DatabaseActionId, node_id: &str) -> Option { @@ -902,6 +897,12 @@ fn action_id(action: &DatabaseActionDescriptor) -> &'static str { #[cfg(test)] mod tests { use super::*; + use db::mysql::MySqlPlugin; + + fn mysql_manifest_plugin() -> ManifestDatabaseViewPlugin { + let plugin = MySqlPlugin::new(); + ManifestDatabaseViewPlugin::new(DatabaseType::MySQL, &plugin) + } fn has_label(items: &[ContextMenuItem], expected: &str) -> bool { items.iter().any(|item| match item { @@ -915,7 +916,7 @@ mod tests { #[test] fn mysql_table_context_menu_keeps_design_table_action() { - let items = build_context_menu_for(DatabaseType::MySQL, "node-1", DbNodeType::Table); + let items = mysql_manifest_plugin().build_context_menu("node-1", DbNodeType::Table); assert!( has_label(&items, &translate("Table.design_table")), @@ -925,7 +926,7 @@ mod tests { #[test] fn mysql_table_context_menu_keeps_dump_sql_submenu() { - let items = build_context_menu_for(DatabaseType::MySQL, "node-1", DbNodeType::Table); + let items = mysql_manifest_plugin().build_context_menu("node-1", DbNodeType::Table); let dump_submenu = items.iter().find_map(|item| match item { ContextMenuItem::Submenu { label, items, .. } @@ -956,7 +957,7 @@ mod tests { #[test] fn mysql_database_context_menu_restores_legacy_order_and_separators() { - let items = build_context_menu_for(DatabaseType::MySQL, "node-1", DbNodeType::Database); + let items = mysql_manifest_plugin().build_context_menu("node-1", DbNodeType::Database); let labels: Vec = items .iter() diff --git a/crates/db_view/src/db_tree_view.rs b/crates/db_view/src/db_tree_view.rs index 1c617fcfb1..51d6c7afa3 100644 --- a/crates/db_view/src/db_tree_view.rs +++ b/crates/db_view/src/db_tree_view.rs @@ -2614,7 +2614,7 @@ impl DbTreeView { let is_active = conn_active && (node.node_type != DbNodeType::Database || node.children_loaded); - let menu_items = build_context_menu_for(node.database_type, node_id, node.node_type); + let menu_items = build_context_menu_for(node.database_type, node_id, node.node_type, cx); if !menu_items.is_empty() { // 渲染 plugin 提供的菜单,传入连接激活状态 menu = Self::render_context_menu_items(menu, menu_items, is_active, view, window, cx); diff --git a/crates/db_view/src/sql_editor_view.rs b/crates/db_view/src/sql_editor_view.rs index bc56582127..fe56347961 100644 --- a/crates/db_view/src/sql_editor_view.rs +++ b/crates/db_view/src/sql_editor_view.rs @@ -81,8 +81,9 @@ impl SqlEditorTab { cx.new(|cx| SelectState::new(SearchableVec::new(vec![]), None, window, cx)); let global_state = cx.global::().clone(); - let supports_schema = global_state.supports_schema(&database_type); - let uses_schema_as_database = global_state.uses_schema_as_database(&database_type); + let capabilities = global_state.capabilities(&database_type); + let supports_schema = capabilities.supports_schema; + let uses_schema_as_database = capabilities.uses_schema_as_database; let connection_id_str = connection_id.into(); let should_load_file = file_path.is_some(); diff --git a/crates/db_view/src/table_designer_tab.rs b/crates/db_view/src/table_designer_tab.rs index 9290fcca30..9f09c96308 100644 --- a/crates/db_view/src/table_designer_tab.rs +++ b/crates/db_view/src/table_designer_tab.rs @@ -319,11 +319,11 @@ impl TableDesigner { Vec, ColumnEditorCapabilities, ) = { - let engines = get_engines_for(config.database_type) + let engines = get_engines_for(config.database_type, cx) .into_iter() .map(|name| EngineSelectItem { name }) .collect(); - let capabilities = get_column_editor_capabilities_for(config.database_type); + let capabilities = get_column_editor_capabilities_for(config.database_type, cx); (engines, capabilities) }; @@ -1175,7 +1175,7 @@ impl TableDesigner { } fn render_options(&self, cx: &Context) -> AnyElement { - let capabilities = get_table_designer_capabilities_for(self.config.database_type); + let capabilities = get_table_designer_capabilities_for(self.config.database_type, cx); v_flex() .size_full() diff --git a/crates/duckdb_driver/src/metadata.rs b/crates/duckdb_driver/src/metadata.rs index e9abcfc841..75c00c7da4 100644 --- a/crates/duckdb_driver/src/metadata.rs +++ b/crates/duckdb_driver/src/metadata.rs @@ -54,17 +54,24 @@ struct ViewInfo { comment: Option, } -pub fn handle(session: &DuckDbSession, method: &str, params: &Value) -> Result { +pub fn handle(session: &DuckDbSession, method: &str, params: &Value) -> Result> { let connection = session.connection()?; match method { - "metadata.list_databases" => Ok(json!(vec!["main"])), - "metadata.list_databases_detailed" => to_value(list_databases_detailed()), - "metadata.list_schemas" => to_value(list_schemas(connection)?), - "metadata.list_tables" => to_value(list_tables(connection, params)?), - "metadata.list_columns" => to_value(list_columns(connection, params)?), - "metadata.list_indexes" => to_value(list_indexes(connection, params)?), - "metadata.list_views" => to_value(list_views(connection, params)?), - _ => anyhow::bail!("unsupported metadata method: {method}"), + "metadata.list_databases" => Ok(Some(json!(vec!["main"]))), + "metadata.list_databases_detailed" => to_value(list_databases_detailed()).map(Some), + "metadata.list_schemas" => to_value(list_schemas(connection)?).map(Some), + "metadata.list_tables" => to_value(list_tables(connection, params)?).map(Some), + "metadata.list_columns" => to_value(list_columns(connection, params)?).map(Some), + "metadata.list_indexes" => to_value(list_indexes(connection, params)?).map(Some), + "metadata.list_views" => to_value(list_views(connection, params)?).map(Some), + "metadata.list_functions" + | "metadata.list_procedures" + | "metadata.list_triggers" + | "metadata.list_sequences" + | "metadata.list_foreign_keys" + | "metadata.list_table_triggers" + | "metadata.list_table_checks" => Ok(Some(json!([]))), + _ => Ok(None), } } diff --git a/crates/duckdb_driver/src/server.rs b/crates/duckdb_driver/src/server.rs index 1ea2ffb06b..b5aba48ae6 100644 --- a/crates/duckdb_driver/src/server.rs +++ b/crates/duckdb_driver/src/server.rs @@ -45,7 +45,15 @@ async fn handle_connection(mut stream: Stream) -> Result<()> { let response = match handle_request(&mut session, &request) { Ok(result) => IpcResponse::result(request_id, result), - Err(error) => IpcResponse::error(request_id, IpcErrorCode::Internal, error.to_string()), + Err(error) => { + let message = error.to_string(); + let code = if message.starts_with("unsupported method:") { + IpcErrorCode::UnsupportedMethod + } else { + IpcErrorCode::Internal + }; + IpcResponse::error(request_id, code, message) + } }; send_msg_async(&mut stream, &response).await?; @@ -80,7 +88,8 @@ fn handle_request(session: &mut DuckDbSession, request: &IpcRequest) -> Result { - crate::metadata::handle(session, method, &request.params) + crate::metadata::handle(session, method, &request.params)? + .ok_or_else(|| anyhow::anyhow!("unsupported method: {method}")) } method => anyhow::bail!("unsupported method: {method}"), } From ac6656638143478a7e936ff7c01086c830f2efeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=83=A1=E9=A3=9E?= <1835698775@qq.com> Date: Fri, 8 May 2026 15:21:53 +0800 Subject: [PATCH 03/45] =?UTF-8?q?feat:=20ferrum-flow=E4=BD=BF=E7=94=A8?= =?UTF-8?q?=E5=AE=98=E6=96=B9=E4=BE=9D=E8=B5=96=EF=BC=8C=E4=B8=8D=E5=86=8D?= =?UTF-8?q?fork?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 1 + Cargo.toml | 5 +- crates/db_view/src/er_diagram/mod.rs | 42 +- crates/ferrum-flow/Cargo.toml | 25 - crates/ferrum-flow/README.md | 240 ---- crates/ferrum-flow/examples/basic.rs | 24 - crates/ferrum-flow/examples/basic2.rs | 58 - crates/ferrum-flow/examples/bench.rs | 71 -- crates/ferrum-flow/examples/extension.rs | 77 -- crates/ferrum-flow/examples/plugin.rs | 142 --- crates/ferrum-flow/examples/theme.rs | 85 -- crates/ferrum-flow/src/canvas.rs | 942 -------------- .../ferrum-flow/src/canvas/node_renderer.rs | 182 --- crates/ferrum-flow/src/canvas/port_cache.rs | 117 -- crates/ferrum-flow/src/canvas/types.rs | 51 - crates/ferrum-flow/src/canvas/undo.rs | 341 ------ crates/ferrum-flow/src/command_interop.rs | 208 ---- crates/ferrum-flow/src/edge.rs | 123 -- crates/ferrum-flow/src/graph.rs | 378 ------ crates/ferrum-flow/src/graph/store.rs | 102 -- crates/ferrum-flow/src/lib.rs | 35 - crates/ferrum-flow/src/node.rs | 760 ------------ crates/ferrum-flow/src/plugin.rs | 1089 ----------------- crates/ferrum-flow/src/plugin/sync.rs | 88 -- crates/ferrum-flow/src/plugin/utils.rs | 85 -- crates/ferrum-flow/src/plugin_testing.rs | 142 --- crates/ferrum-flow/src/plugins/align.rs | 166 --- crates/ferrum-flow/src/plugins/background.rs | 206 ---- .../src/plugins/clipboard/clipboard_ops.rs | 172 --- .../src/plugins/clipboard/copied_subgraph.rs | 8 - .../ferrum-flow/src/plugins/clipboard/mod.rs | 10 - .../src/plugins/clipboard/plugin.rs | 58 - .../ferrum-flow/src/plugins/context_menu.rs | 455 ------- crates/ferrum-flow/src/plugins/delete.rs | 278 ----- .../ferrum-flow/src/plugins/edge/command.rs | 163 --- crates/ferrum-flow/src/plugins/edge/mod.rs | 286 ----- crates/ferrum-flow/src/plugins/fit_all.rs | 114 -- .../src/plugins/focus_selection.rs | 63 - crates/ferrum-flow/src/plugins/history.rs | 39 - crates/ferrum-flow/src/plugins/minimap.rs | 443 ------- crates/ferrum-flow/src/plugins/mod.rs | 44 - .../ferrum-flow/src/plugins/node/command.rs | 178 --- .../src/plugins/node/drag_events.rs | 31 - .../src/plugins/node/interaction.rs | 230 ---- crates/ferrum-flow/src/plugins/node/mod.rs | 131 -- .../ferrum-flow/src/plugins/port/command.rs | 158 --- .../src/plugins/port/interaction.rs | 458 ------- crates/ferrum-flow/src/plugins/port/mod.rs | 16 - crates/ferrum-flow/src/plugins/port/utils.rs | 81 -- .../ferrum-flow/src/plugins/port/validator.rs | 111 -- .../src/plugins/select_all_viewport.rs | 68 - .../ferrum-flow/src/plugins/selection/mod.rs | 340 ----- crates/ferrum-flow/src/plugins/snap_guides.rs | 244 ---- crates/ferrum-flow/src/plugins/toast.rs | 172 --- crates/ferrum-flow/src/plugins/viewport.rs | 152 --- .../ferrum-flow/src/plugins/viewport_frame.rs | 166 --- .../ferrum-flow/src/plugins/zoom_controls.rs | 314 ----- crates/ferrum-flow/src/port_screen.rs | 60 - crates/ferrum-flow/src/shared_state.rs | 55 - crates/ferrum-flow/src/theme.rs | 139 --- crates/ferrum-flow/src/viewport.rs | 192 --- 61 files changed, 5 insertions(+), 11209 deletions(-) delete mode 100644 crates/ferrum-flow/Cargo.toml delete mode 100644 crates/ferrum-flow/README.md delete mode 100644 crates/ferrum-flow/examples/basic.rs delete mode 100644 crates/ferrum-flow/examples/basic2.rs delete mode 100644 crates/ferrum-flow/examples/bench.rs delete mode 100644 crates/ferrum-flow/examples/extension.rs delete mode 100644 crates/ferrum-flow/examples/plugin.rs delete mode 100644 crates/ferrum-flow/examples/theme.rs delete mode 100644 crates/ferrum-flow/src/canvas.rs delete mode 100644 crates/ferrum-flow/src/canvas/node_renderer.rs delete mode 100644 crates/ferrum-flow/src/canvas/port_cache.rs delete mode 100644 crates/ferrum-flow/src/canvas/types.rs delete mode 100644 crates/ferrum-flow/src/canvas/undo.rs delete mode 100644 crates/ferrum-flow/src/command_interop.rs delete mode 100644 crates/ferrum-flow/src/edge.rs delete mode 100644 crates/ferrum-flow/src/graph.rs delete mode 100644 crates/ferrum-flow/src/graph/store.rs delete mode 100644 crates/ferrum-flow/src/lib.rs delete mode 100644 crates/ferrum-flow/src/node.rs delete mode 100644 crates/ferrum-flow/src/plugin.rs delete mode 100644 crates/ferrum-flow/src/plugin/sync.rs delete mode 100644 crates/ferrum-flow/src/plugin/utils.rs delete mode 100644 crates/ferrum-flow/src/plugin_testing.rs delete mode 100644 crates/ferrum-flow/src/plugins/align.rs delete mode 100644 crates/ferrum-flow/src/plugins/background.rs delete mode 100644 crates/ferrum-flow/src/plugins/clipboard/clipboard_ops.rs delete mode 100644 crates/ferrum-flow/src/plugins/clipboard/copied_subgraph.rs delete mode 100644 crates/ferrum-flow/src/plugins/clipboard/mod.rs delete mode 100644 crates/ferrum-flow/src/plugins/clipboard/plugin.rs delete mode 100644 crates/ferrum-flow/src/plugins/context_menu.rs delete mode 100644 crates/ferrum-flow/src/plugins/delete.rs delete mode 100644 crates/ferrum-flow/src/plugins/edge/command.rs delete mode 100644 crates/ferrum-flow/src/plugins/edge/mod.rs delete mode 100644 crates/ferrum-flow/src/plugins/fit_all.rs delete mode 100644 crates/ferrum-flow/src/plugins/focus_selection.rs delete mode 100644 crates/ferrum-flow/src/plugins/history.rs delete mode 100644 crates/ferrum-flow/src/plugins/minimap.rs delete mode 100644 crates/ferrum-flow/src/plugins/mod.rs delete mode 100644 crates/ferrum-flow/src/plugins/node/command.rs delete mode 100644 crates/ferrum-flow/src/plugins/node/drag_events.rs delete mode 100644 crates/ferrum-flow/src/plugins/node/interaction.rs delete mode 100644 crates/ferrum-flow/src/plugins/node/mod.rs delete mode 100644 crates/ferrum-flow/src/plugins/port/command.rs delete mode 100644 crates/ferrum-flow/src/plugins/port/interaction.rs delete mode 100644 crates/ferrum-flow/src/plugins/port/mod.rs delete mode 100644 crates/ferrum-flow/src/plugins/port/utils.rs delete mode 100644 crates/ferrum-flow/src/plugins/port/validator.rs delete mode 100644 crates/ferrum-flow/src/plugins/select_all_viewport.rs delete mode 100644 crates/ferrum-flow/src/plugins/selection/mod.rs delete mode 100644 crates/ferrum-flow/src/plugins/snap_guides.rs delete mode 100644 crates/ferrum-flow/src/plugins/toast.rs delete mode 100644 crates/ferrum-flow/src/plugins/viewport.rs delete mode 100644 crates/ferrum-flow/src/plugins/viewport_frame.rs delete mode 100644 crates/ferrum-flow/src/plugins/zoom_controls.rs delete mode 100644 crates/ferrum-flow/src/port_screen.rs delete mode 100644 crates/ferrum-flow/src/shared_state.rs delete mode 100644 crates/ferrum-flow/src/theme.rs delete mode 100644 crates/ferrum-flow/src/viewport.rs diff --git a/Cargo.lock b/Cargo.lock index a0ecd566ff..3620836ea6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3254,6 +3254,7 @@ dependencies = [ [[package]] name = "ferrum-flow" version = "0.2.1" +source = "git+https://github.com/tu6ge/ferrum-flow.git?rev=507cab7505b8dfa6a1e4a121e3c503e3f2f882d6#507cab7505b8dfa6a1e4a121e3c503e3f2f882d6" dependencies = [ "anyhow", "futures", diff --git a/Cargo.toml b/Cargo.toml index 6bd4407853..369750dc97 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,8 +22,7 @@ members = [ "crates/er_flow", "crates/terminal", "crates/terminal_view", - "crates/ferrum-flow" - , "main", "crates/ssh", "crates/sftp", "crates/sftp_view", "crates/one_ui", "crates/redis_view", "crates/license_tool", "crates/mongodb_view", "crates/remote_file_editor"] + "main", "crates/ssh", "crates/sftp", "crates/sftp_view", "crates/one_ui", "crates/redis_view", "crates/license_tool", "crates/mongodb_view", "crates/remote_file_editor"] resolver = "2" [workspace.package] @@ -151,7 +150,7 @@ interprocess = { version = "2.4.0", features = ["tokio"] } url = "2.5.4" percent-encoding = "2.3.1" global-hotkey = "0.7.0" -ferrum-flow = { path = "crates/ferrum-flow" } +ferrum-flow = { git = "https://github.com/tu6ge/ferrum-flow.git", rev = "507cab7505b8dfa6a1e4a121e3c503e3f2f882d6" } [patch.crates-io] gpui = { git = "https://github.com/zed-industries/zed", rev = "8b5328ca" } diff --git a/crates/db_view/src/er_diagram/mod.rs b/crates/db_view/src/er_diagram/mod.rs index bd0d597b90..b7b81f5ff3 100644 --- a/crates/db_view/src/er_diagram/mod.rs +++ b/crates/db_view/src/er_diagram/mod.rs @@ -4,15 +4,13 @@ mod scroll_pan_plugin; use db::GlobalDbState; use ferrum_flow::{ - BackgroundPlugin, EdgePlugin, FitAllGraphPlugin, FlowCanvas, FlowTheme, Graph, MinimapPlugin, + BackgroundPlugin, EdgePlugin, FitAllGraphPlugin, FlowCanvas, Graph, MinimapPlugin, NodeInteractionPlugin, NodePlugin, ViewportPlugin, ZoomControlsPlugin, }; use crate::er_diagram::pan_mode_plugin::ErDiagramPanModePlugin; use crate::er_diagram::scroll_pan_plugin::ErDiagramScrollPanPlugin; -use er_flow::{ - ErCardTheme, er_flow_theme_from_ui, er_node_renderers_from_theme, graph_from_diagram, -}; +use er_flow::{er_flow_theme_from_ui, er_node_renderers_from_theme, graph_from_diagram}; use gpui::{ App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement as _, IntoElement, ParentElement as _, Render, SharedString, Styled as _, @@ -42,16 +40,9 @@ pub(crate) struct ErDiagramTab { canvas: Option>, loading: bool, error: Option, - theme_snapshot: Option, focus_handle: FocusHandle, } -#[derive(Clone, PartialEq)] -struct ErDiagramThemeSnapshot { - flow_theme: FlowTheme, - card_theme: ErCardTheme, -} - impl ErDiagramTab { pub(crate) fn new( config: ErDiagramConfig, @@ -63,7 +54,6 @@ impl ErDiagramTab { canvas: None, loading: true, error: None, - theme_snapshot: None, focus_handle: cx.focus_handle(), }; tab.reload(window, cx); @@ -108,27 +98,9 @@ impl ErDiagramTab { self.error = Some(err.to_string()); } } - self.theme_snapshot = Some(current_theme_snapshot(cx)); cx.notify(); } - fn sync_canvas_theme(&mut self, cx: &mut Context) { - let Some(canvas) = self.canvas.as_ref() else { - return; - }; - let next_snapshot = current_theme_snapshot(cx); - if self.theme_snapshot.as_ref() == Some(&next_snapshot) { - return; - } - let theme = cx.theme(); - let renderers = er_node_renderers_from_theme(theme); - canvas.update(cx, |canvas, cx| { - canvas.set_theme(next_snapshot.flow_theme.clone(), cx); - canvas.replace_node_renderers(renderers, cx); - }); - self.theme_snapshot = Some(next_snapshot); - } - fn render_loading(&self, cx: &mut Context) -> impl IntoElement { v_flex() .size_full() @@ -248,8 +220,6 @@ fn build_canvas( impl Render for ErDiagramTab { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - self.sync_canvas_theme(cx); - div() .track_focus(&self.focus_handle) .size_full() @@ -266,14 +236,6 @@ impl Render for ErDiagramTab { } } -fn current_theme_snapshot(cx: &mut App) -> ErDiagramThemeSnapshot { - let theme = cx.theme(); - ErDiagramThemeSnapshot { - flow_theme: er_flow_theme_from_ui(theme), - card_theme: ErCardTheme::from_ui_theme(theme), - } -} - impl Focusable for ErDiagramTab { fn focus_handle(&self, _cx: &App) -> FocusHandle { self.focus_handle.clone() diff --git a/crates/ferrum-flow/Cargo.toml b/crates/ferrum-flow/Cargo.toml deleted file mode 100644 index e17528df32..0000000000 --- a/crates/ferrum-flow/Cargo.toml +++ /dev/null @@ -1,25 +0,0 @@ -[package] -name = "ferrum-flow" -version = "0.2.1" -edition = "2024" -license = "Apache-2.0" -authors = ["tu6ge"] -repository = "https://github.com/tu6ge/ferrum-flow" -description = "A high-performance node-based editor framework built with Rust and GPUI." - -[features] -default = [] -## Public `command_interop` test helpers; run `cargo test -p ferrum-flow --features testing`. -testing = [] - -[dependencies] -anyhow = { workspace = true } -gpui = { workspace = true } -# used by background plugin -image = { workspace = true } -# used by background plugin -smallvec = { workspace = true } -serde = { workspace = true, features = ["derive"] } -serde_json = { workspace = true } -futures = { workspace = true, features = ["std"] } -uuid = { workspace = true } diff --git a/crates/ferrum-flow/README.md b/crates/ferrum-flow/README.md deleted file mode 100644 index 3f9523a29b..0000000000 --- a/crates/ferrum-flow/README.md +++ /dev/null @@ -1,240 +0,0 @@ -# FerrumFlow - -A high-performance, extensible node-based editor built with Rust and gpui. -Designed for building visual programming tools, workflow editors, and graph-based UIs. - -**This project is in early stage (alpha), API may change** - -## Features - -- Plugin-based architecture -- Interaction system (drag, pan, select, etc.) -- Undo / Redo (Command pattern) -- Viewport control (zoom & pan) -- Box selection & multi-select -- Node / Port / Edge model -- Custom node rendering system -- Built with performance in mind -- Multi-user collaboration support (by [plugin](https://github.com/tu6ge/ferrum-flow/tree/master/crates/sync_plugin)) - -[![Watch the video](https://img.youtube.com/vi/mimeKsIldog/0.jpg)](https://www.youtube.com/watch?v=mimeKsIldog) - -[GitHub](https://github.com/tu6ge/ferrum-flow) - -## Usage - -```bash -cargo add ferrum-flow -``` - -This is a hello world example: - -```rust -use ferrum_flow::{FlowCanvas, Graph}; -use gpui::{AppContext as _, Application, WindowOptions}; -use serde_json::json; - -fn main() { - Application::new().run(|cx| { - let mut graph = Graph::new(); - - graph - .create_node("default") - .position(100.0, 100.0) - .data(json!({ "label": "Hello World" })) - .build(); - - cx.open_window(WindowOptions::default(), |window, cx| { - cx.new(|ctx| { - FlowCanvas::builder(graph, ctx, window) - .default_plugins() // Includes built-in rendering for nodes, edges, selection, and more. Replace with custom plugins as needed. - .build() - }) - }) - .unwrap(); - }); -} -``` - -For more examples, see the [examples directory](./examples/). - -## Architecture Overview - -The system is designed with clear separation of concerns: - -### Core Concepts - -- Graph - Stores persistent data (nodes, edges, ports) - -- Viewport - Handles zooming and panning - -- Plugin System - Extends behavior (rendering, input handling, etc.) -- Interaction System - Manages ongoing user interactions (dragging, selecting, etc.) - -- Command System - Enables undo/redo support - -### Plugin System - -Plugins are the primary extension mechanism: - -```rust -pub trait Plugin { - fn name(&self) -> &'static str; - - fn setup(&mut self, ctx: &mut InitPluginContext); - - fn on_event(&mut self, event: &FlowEvent, ctx: &mut PluginContext) -> EventResult; - - fn render(&mut self, ctx: &mut RenderContext) -> Option; - - fn priority(&self) -> i32 { - 0 - } - - fn render_layer(&self) -> RenderLayer { - RenderLayer::Overlay - } -} -``` - -**Responsibilities** - -A plugin can: - -- Handle input events -- Start interactions -- Render UI layers -- Modify graph state - -### Interaction System - -Interactions represent ongoing user actions, such as: - -- Node dragging -- Box selection -- Viewport panning - -```rust -pub trait Interaction { - fn on_mouse_move(&mut self, event: &MouseMoveEvent, ctx: &mut PluginContext) -> InteractionResult; - - fn on_mouse_up(&mut self, event: &MouseUpEvent, ctx: &mut PluginContext) -> InteractionResult; - - fn render(&self, ctx: &mut RenderContext) -> Option; -} -``` - -Interaction Lifecycle - -``` -Start → Update → End / Replace -``` - -```rust -pub enum InteractionResult { - Continue, - End, - Replace(Box), -} -``` - -### Command System (Undo / Redo) - -Implements the Command Pattern: - -```rust -pub trait Command { - fn execute(&mut self, ctx: &mut CommandContext); - fn undo(&mut self, ctx: &mut CommandContext); -} -``` - -Built-in Features - -- Undo / Redo stacks -- Composite commands -- Easy integration via PluginContext - -```rust -ctx.execute_command(MyCommand { ... }); -``` - -### Node Rendering - -Rendering is fully customizable via a registry: - -```rust -pub trait NodeRenderer { - fn render(&self, node: &Node, ctx: &mut RenderContext) -> AnyElement; - - // custom render port UI - fn port_render(&self, node: &Node, port: &Port, ctx: &mut RenderContext) -> Option { - // ... default implement - } - - // computing the position of port relative to node - fn port_offset(&self, node: &Node, port: &Port, graph: &Graph) -> Point { - // ... default implement - } -} -``` - -Render example: - -```rust -// Absolute-positioned node card shell: screen origin, zoom-scaled size. -ctx.node_card_shell(node, false, NodeCardVariant::Custom) - .rounded(px(6.0)) - .border(px(1.5)) -``` - -### Graph Model - -```rust -pub struct Node { - id: NodeId, - node_type: String, - x: Pixels, - y: Pixels, - size: Size, - inputs: Vec, - outputs: Vec, - data: serde_json::Value, -} -``` - -🏗️ Creating Nodes (Builder API) - -```rust -graph.create_node("math.add") - .position(100.0, 100.0) - .input() - .output() - .build(); -``` - -### Performance - -Designed to scale to large graphs: - -- Viewport-based rendering (virtualization) -- Layered rendering system -- Interaction-aware rendering (degraded mode during drag) -- Ready for spatial indexing - -### Design Principles - -- Separation of data and interaction -- Plugins over hardcoded behavior -- Explicit state transitions -- Performance-first rendering -- Composable architecture - -## License - -Apache2.0 diff --git a/crates/ferrum-flow/examples/basic.rs b/crates/ferrum-flow/examples/basic.rs deleted file mode 100644 index fd188ea481..0000000000 --- a/crates/ferrum-flow/examples/basic.rs +++ /dev/null @@ -1,24 +0,0 @@ -use ferrum_flow::{FlowCanvas, Graph}; -use gpui::{AppContext as _, Application, WindowOptions}; -use serde_json::json; - -fn main() { - Application::new().run(|cx| { - let mut graph = Graph::new(); - - graph - .create_node("default") - .position(100.0, 100.0) - .data(json!({ "label": "Hello World" })) - .build(); - - cx.open_window(WindowOptions::default(), |window, cx| { - cx.new(|ctx| { - FlowCanvas::builder(graph, ctx, window) - .default_plugins() - .build() - }) - }) - .unwrap(); - }); -} diff --git a/crates/ferrum-flow/examples/basic2.rs b/crates/ferrum-flow/examples/basic2.rs deleted file mode 100644 index 95e5095421..0000000000 --- a/crates/ferrum-flow/examples/basic2.rs +++ /dev/null @@ -1,58 +0,0 @@ -use ferrum_flow::*; -use gpui::{AppContext as _, Application, Size, WindowOptions, px}; -use serde_json::json; - -fn main() { - Application::new().run(|cx| { - let mut graph = Graph::new(); - - graph - .create_node("") - .position(100.0, 100.0) - .output() - .output() - .output_with(PortPosition::Bottom, Size::new(px(20.0), px(20.0))) - .output_at(PortPosition::Bottom) - .data(json!({ "label": "Node 1" })) - .build(); - - graph - .create_node("") - .position(300.0, 400.0) - .input() - .input_at(PortPosition::Top) - .input_at(PortPosition::Top) - .output() - .output_at(PortPosition::Bottom) - .output_at(PortPosition::Bottom) - .data(json!({ "label": "Node 2" })) - .build(); - - graph - .create_node("") - .position(500.0, 500.0) - .input() - .output() - .data(json!({ "label": "Node 3" })) - .build(); - - cx.open_window(WindowOptions::default(), |window, cx| { - cx.new(|ctx| { - FlowCanvas::builder(graph, ctx, window) - .default_plugins() - .plugin(MinimapPlugin::new()) - .plugin(ZoomControlsPlugin::new()) - .plugin(ClipboardPlugin::new()) - .plugin(ContextMenuPlugin::new()) - .plugin(SelectAllViewportPlugin::new()) - .plugin(AlignPlugin::new()) - .plugin(FocusSelectionPlugin::new()) - .plugin(FitAllGraphPlugin::new()) - .plugin(SnapGuidesPlugin::new()) - .plugin(ToastPlugin::new()) - .build() - }) - }) - .unwrap(); - }); -} diff --git a/crates/ferrum-flow/examples/bench.rs b/crates/ferrum-flow/examples/bench.rs deleted file mode 100644 index 8bd367f290..0000000000 --- a/crates/ferrum-flow/examples/bench.rs +++ /dev/null @@ -1,71 +0,0 @@ -use ferrum_flow::*; -use gpui::{AppContext as _, Application, WindowOptions}; -use serde_json::json; - -fn main() { - Application::new().run(|cx| { - let mut graph = Graph::new(); - - for j in 0..100 { - for i in 0..100 { - graph - .create_node("") - .position(200.0 * i as f32, 200.0 * j as f32) - .input() - .output() - .data(json!({ "label": format!("Node {}", i * 100 + j) })) - .build(); - } - } - - let node_ids = graph.nodes().keys().copied().collect::>(); - - generate_chain_edges(&mut graph, node_ids); - - cx.open_window(WindowOptions::default(), |window, cx| { - cx.new(|ctx| { - FlowCanvas::builder(graph, ctx, window) - .plugin(BackgroundPlugin::new()) - .plugin(SelectionPlugin::new()) - .plugin(NodeInteractionPlugin::new()) - .plugin(ViewportPlugin::new()) - .plugin(NodePlugin::new()) - .plugin(PortInteractionPlugin::new()) - .plugin(EdgePlugin::new()) - .plugin(DeletePlugin::new()) - .plugin(HistoryPlugin::new()) - .plugin(MinimapPlugin::new()) - .plugin(ClipboardPlugin::new()) - .plugin(ContextMenuPlugin::new()) - .plugin(SelectAllViewportPlugin::new()) - .plugin(AlignPlugin::new()) - .plugin(FocusSelectionPlugin::new()) - .plugin(ZoomControlsPlugin::new()) - .plugin(SnapGuidesPlugin::new()) - .plugin(ToastPlugin::new()) - //.plugin(FitAllGraphPlugin::new()) - .build() - }) - }) - .unwrap(); - }); -} - -pub fn generate_chain_edges(graph: &mut Graph, node_ids: Vec) { - for window in node_ids.windows(2) { - let from = window[0]; - let to = window[1]; - - let from_node = graph.get_node(&from).unwrap(); - let to_node = graph.get_node(&to).unwrap(); - - let source_port = from_node.outputs()[0]; - let target_port = to_node.inputs()[0]; - - graph - .create_edge() - .source(source_port) - .target(target_port) - .build(); - } -} diff --git a/crates/ferrum-flow/examples/extension.rs b/crates/ferrum-flow/examples/extension.rs deleted file mode 100644 index 95a64678ef..0000000000 --- a/crates/ferrum-flow/examples/extension.rs +++ /dev/null @@ -1,77 +0,0 @@ -use ferrum_flow::*; -use gpui::{ - AnyElement, AppContext as _, Application, Element as _, ParentElement as _, Styled, - WindowOptions, div, rgb, white, -}; -use serde_json::json; - -fn main() { - Application::new().run(|cx| { - let mut graph = Graph::new(); - - graph - .create_node("number") - .position(100.0, 100.0) - .size(300.0, 150.0) - .output() - .data(json!({ "label": "Number Node" })) - .build(); - - graph.create_node("").position(300.0, 400.0).input().build(); - - graph - .create_node("undefined") - .position(500.0, 500.0) - .input() - .output() - .build(); - - cx.open_window(WindowOptions::default(), |window, cx| { - cx.new(|ctx| { - FlowCanvas::builder(graph, ctx, window) - .default_plugins() - .plugin(ZoomControlsPlugin::new()) - .plugin(FocusSelectionPlugin::new()) - .plugin(FitAllGraphPlugin::new()) - .plugin(ClipboardPlugin::new()) - .plugin(ContextMenuPlugin::new()) - .node_renderer("number", NumberNode {}) - .build() - }) - }) - .unwrap(); - }); -} - -pub struct NumberNode; - -impl NodeRenderer for NumberNode { - fn render(&self, node: &Node, ctx: &mut RenderContext) -> AnyElement { - let screen = ctx.world_to_screen(node.point()); - let node_x = screen.x; - let node_y = screen.y; - - div() - .absolute() - .left(node_x) - .top(node_y) - .w(ctx.world_length_to_screen(node.size_ref().width)) - .h(ctx.world_length_to_screen(node.size_ref().height)) - .bg(rgb(0x505078)) - .child(div().child("Number Node").text_color(white())) - .into_any() - } - - fn port_render(&self, node: &Node, port: &Port, ctx: &mut RenderContext) -> Option { - let frame = ctx.port_screen_frame(node, port)?; - Some( - frame - .anchor_div() - .rounded_full() - .border_1() - .border_color(rgb(0x1A192B)) - .bg(white()) - .into_any(), - ) - } -} diff --git a/crates/ferrum-flow/examples/plugin.rs b/crates/ferrum-flow/examples/plugin.rs deleted file mode 100644 index 9f53565608..0000000000 --- a/crates/ferrum-flow/examples/plugin.rs +++ /dev/null @@ -1,142 +0,0 @@ -use ferrum_flow::*; -use gpui::{ - AnyElement, AppContext as _, Application, Element as _, ParentElement as _, Styled, - WindowOptions, div, px, rgb, white, -}; -use serde_json::json; - -/// A beginner-friendly custom plugin example. -/// -/// Run with: -/// `cargo run -p ferrum-flow --example plugin` -fn main() { - Application::new().run(|cx| { - let mut graph = Graph::new(); - - graph - .create_node("default") - .position(120.0, 120.0) - .input() - .output() - .data(json!({ "label": "Base Node" })) - .build(); - - cx.open_window(WindowOptions::default(), |window, cx| { - cx.new(|ctx| { - FlowCanvas::builder(graph, ctx, window) - .default_plugins() - .plugin(StarterPlugin::new()) - .plugin(ToastPlugin::new()) - .build() - }) - }) - .unwrap(); - }); -} - -/// A tiny plugin that demonstrates: -/// 1) plugin state -/// 2) input handling -/// 3) custom overlay rendering -/// 4) mutating graph data through `PluginContext` -struct StarterPlugin { - next_index: usize, - clicks: usize, - show_hud: bool, -} - -impl StarterPlugin { - fn new() -> Self { - Self { - next_index: 1, - clicks: 0, - show_hud: true, - } - } - - fn add_demo_node(&mut self, ctx: &mut PluginContext) { - let i = self.next_index; - self.next_index += 1; - - let x = 120.0 + ((i % 6) as f32) * 180.0; - let y = 280.0 + ((i / 6) as f32) * 140.0; - - ctx.create_node("default") - .position(x, y) - .input() - .output() - .data(json!({ "label": format!("Plugin Node {i}") })) - .build(); - - ctx.emit(FlowEvent::custom(ToastMessage::success(format!( - "Created node #{i} from StarterPlugin" - )))); - } -} - -impl Plugin for StarterPlugin { - fn name(&self) -> &'static str { - "starter_plugin" - } - - fn setup(&mut self, _ctx: &mut InitPluginContext) { - // Put one initial node index behind the first generated node label. - self.next_index = 1; - } - - fn on_event(&mut self, event: &FlowEvent, ctx: &mut PluginContext) -> EventResult { - if let FlowEvent::Input(InputEvent::KeyDown(ev)) = event { - if ev.keystroke.key == "n" { - self.add_demo_node(ctx); - return EventResult::Stop; - } - if ev.keystroke.key == "h" { - self.show_hud = !self.show_hud; - ctx.notify(); - return EventResult::Stop; - } - } - - if let FlowEvent::Input(InputEvent::MouseDown(_)) = event { - self.clicks += 1; - ctx.notify(); - } - - EventResult::Continue - } - - fn render(&mut self, _ctx: &mut RenderContext) -> Option { - if !self.show_hud { - return None; - } - - Some( - div() - .absolute() - .left(px(12.0)) - .top(px(12.0)) - .px_3() - .py_2() - .rounded(px(8.0)) - .bg(rgb(0x001F2937)) - .text_color(white()) - .child(div().text_sm().child("StarterPlugin (custom example)")) - .child( - div() - .text_sm() - .child(format!("Mouse clicks: {}", self.clicks)), - ) - .child(div().text_sm().child("Press N: create node")) - .child(div().text_sm().child("Press H: hide/show this panel")) - .into_any(), - ) - } - - fn priority(&self) -> i32 { - 120 - } - - fn render_layer(&self) -> RenderLayer { - RenderLayer::Overlay - } -} diff --git a/crates/ferrum-flow/examples/theme.rs b/crates/ferrum-flow/examples/theme.rs deleted file mode 100644 index d2ce87221e..0000000000 --- a/crates/ferrum-flow/examples/theme.rs +++ /dev/null @@ -1,85 +0,0 @@ -//! Custom canvas chrome via [`InitPluginContext::theme`] in [`Plugin::setup`]. -use ferrum_flow::*; -use gpui::{AppContext as _, Application, WindowOptions}; -use serde_json::json; - -struct DarkGridThemePlugin; - -impl Plugin for DarkGridThemePlugin { - fn name(&self) -> &'static str { - "dark_grid_theme" - } - - fn setup(&mut self, ctx: &mut InitPluginContext) { - ctx.theme.background = 0x001a1d2a; - ctx.theme.background_grid_dot = 0x003d4559; - ctx.theme.node_card_background = 0x0024283a; - ctx.theme.node_card_border = 0x004a5568; - ctx.theme.node_card_border_selected = 0x00f5a524; - ctx.theme.node_caption_text = 0x00e8eaef; - ctx.theme.default_port_fill = 0x004a5568; - ctx.theme.undefined_node_background = 0x00303845; - ctx.theme.undefined_node_border = 0x00f5a524; - ctx.theme.undefined_node_caption_text = 0x00b8bcc8; - ctx.theme.edge_stroke = 0x0050586b; - ctx.theme.edge_stroke_selected = 0x00f5a524; - ctx.theme.selection_rect_border = 0x006b8cff; - ctx.theme.selection_rect_fill_rgba = 0x6b8cff33; - ctx.theme.port_preview_line = 0x0050586b; - ctx.theme.port_preview_dot = 0x0060809e; - ctx.theme.minimap_background = 0x0018202e; - ctx.theme.minimap_border = 0x004a5568; - ctx.theme.minimap_edge = 0x0050586b; - ctx.theme.minimap_node_fill = 0x0024283a; - ctx.theme.minimap_node_stroke = 0x00607080; - ctx.theme.minimap_viewport_stroke = 0x006b8cff; - ctx.theme.zoom_controls_background = 0x0024283a; - ctx.theme.zoom_controls_border = 0x004a5568; - ctx.theme.zoom_controls_text = 0x00e8eaef; - ctx.theme.context_menu_background = 0x0024283a; - ctx.theme.context_menu_border = 0x004a5568; - ctx.theme.context_menu_text = 0x00e8eaef; - ctx.theme.context_menu_shortcut_text = 0x009098a8; - ctx.theme.context_menu_separator = 0x003d4559; - } -} - -fn main() { - Application::new().run(|cx| { - let mut graph = Graph::new(); - - graph - .create_node("") - .position(100.0, 100.0) - .output() - .output() - .data(json!({ "label": "Themed" })) - .build(); - - cx.open_window(WindowOptions::default(), |window, cx| { - cx.new(|ctx| { - FlowCanvas::builder(graph, ctx, window) - .plugin(DarkGridThemePlugin) - .plugin(MinimapPlugin::new()) - .plugin(SelectionPlugin::new()) - .plugin(NodeInteractionPlugin::new()) - .plugin(ViewportPlugin::new()) - .plugin(ZoomControlsPlugin::new()) - .plugin(BackgroundPlugin::new()) - .plugin(NodePlugin::new()) - .plugin(PortInteractionPlugin::new()) - .plugin(EdgePlugin::new()) - .plugin(ClipboardPlugin::new()) - .plugin(ContextMenuPlugin::new()) - .plugin(SelectAllViewportPlugin::new()) - .plugin(AlignPlugin::new()) - .plugin(FocusSelectionPlugin::new()) - .plugin(FitAllGraphPlugin::new()) - .plugin(DeletePlugin::new()) - .plugin(HistoryPlugin::new()) - .build() - }) - }) - .unwrap(); - }); -} diff --git a/crates/ferrum-flow/src/canvas.rs b/crates/ferrum-flow/src/canvas.rs deleted file mode 100644 index 5717d41523..0000000000 --- a/crates/ferrum-flow/src/canvas.rs +++ /dev/null @@ -1,942 +0,0 @@ -use futures::{StreamExt, channel::mpsc}; -use gpui::*; -use std::cell::Cell; -use std::collections::BTreeMap; -use std::rc::Rc; -use std::time::Duration; - -use crate::{ - BackgroundPlugin, DeletePlugin, EdgePlugin, FlowTheme, GraphChange, HistoryPlugin, - NodeInteractionPlugin, NodePlugin, PortInteractionPlugin, SelectionPlugin, SharedState, - SyncPlugin, SyncPluginContext, ViewportPlugin, - graph::Graph, - plugin::{ - EventResult, FlowEvent, InitPluginContext, InputEvent, Plugin, PluginContext, - PluginRegistry, RenderContext, RenderLayer, invalidate_port_layout_cache_for_graph_change, - }, - viewport::Viewport, -}; - -mod node_renderer; -mod port_cache; -mod types; -mod undo; - -pub use port_cache::PortLayoutCache; - -pub use undo::{Command, CommandContext, CompositeCommand, HistoryProvider, LocalHistory}; - -pub use types::{Interaction, InteractionResult, InteractionState}; - -#[allow(deprecated)] -pub use node_renderer::port_screen_position; -pub use node_renderer::{NodeRenderer, RendererRegistry, default_node_caption}; - -/// Host-side callback for **outbound** [`FlowEvent`]s: invoked synchronously whenever a plugin calls -/// [`PluginContext::emit`](crate::plugin::PluginContext::emit) with the same event that is then -/// enqueued for the internal plugin pipeline ([`FlowCanvas::event_queue`]). -/// -/// # Parent / shell integration -/// -/// This is **not** a GPUI `subscribe` / `observe` stream: you install **one** `FnMut` on the canvas -/// ([`FlowCanvasBuilder::outbound`] or [`FlowCanvas::set_outbound`]). The closure runs on the **UI -/// thread**, **before** the event is pushed onto [`FlowCanvas::event_queue`], and receives a -/// **read-only** reference for inspection ([`FlowEvent::as_custom`]). -/// -/// **Typical patterns** -/// -/// - **Shared counters or queues** — [`std::sync::Arc`] + [`std::sync::atomic::AtomicUsize`] / -/// [`std::sync::Mutex`] / `mpsc` sender; the host view reads them in [`gpui::Render`] (see the -/// `outbound_host` example in this crate). -/// - **Refresh a parent `Entity`** — capture `gpui::Entity` (or a weak handle) in the -/// closure and call [`gpui::Entity::update`] + [`gpui::Context::notify`] after filtering with -/// `as_custom::()`. -/// - **Graph edits without `emit`** — outbound does **not** run for plain [`FlowCanvas::dispatch_command`] -/// unless a plugin later `emit`s; also use [`gpui::Context::observe`] on `Entity` for -/// those cases. -/// -/// ```ignore -/// use ferrum_flow::{FlowCanvas, FlowEvent}; -/// use gpui::{Context, Entity}; -/// -/// fn wire(canvas: &Entity, shell: &Entity, cx: &mut Context) { -/// let shell = shell.clone(); -/// canvas.update(cx, |canvas, cx| { -/// canvas.set_outbound(Some(Box::new(move |ev: &FlowEvent| { -/// if ev.as_custom::().is_some() { -/// let _ = shell.update(cx, |_, cx| cx.notify()); -/// } -/// }))); -/// }); -/// } -/// ``` -pub type FlowCanvasOutbound = Box; - -fn enqueue_plugin_emit( - outbound: &mut Option, - queue: &mut Vec, - e: FlowEvent, -) { - if let Some(h) = outbound.as_mut() { - h(&e); - } - queue.push(e); -} - -pub struct FlowCanvas { - graph: Graph, - - pub(crate) viewport: Viewport, - - pub(crate) plugins_registry: PluginRegistry, - - pub(crate) sync_plugin: Option>, - - renderers: RendererRegistry, - - pub(crate) focus_handle: FocusHandle, - - pub(crate) interaction: InteractionState, - - pub history: Box, - - event_queue: Vec, - port_offset_cache: PortLayoutCache, - - /// Visual tokens for canvas chrome; plugins adjust via [`InitPluginContext::theme`](crate::plugin::InitPluginContext::theme). - theme: FlowTheme, - - /// Type-erased map for cross-plugin data on this canvas instance. - shared_state: SharedState, - canvas_bounds: Rc>>>, - delayed_notify_tx: mpsc::UnboundedSender<()>, - - /// Optional host hook for every plugin [`PluginContext::emit`](crate::plugin::PluginContext::emit). - outbound: Option, -} - -// // TODO -// impl Clone for FlowCanvas { -// fn clone(&self) -> Self { -// Self { -// graph: self.graph.clone(), -// viewport: self.viewport.clone(), -// plugins_registry: PluginRegistry::new(), -// focus_handle: self.focus_handle.clone(), -// interaction: InteractionState::new(), -// event_queue: vec![], -// } -// } -// } - -impl FlowCanvas { - fn init_delayed_notify_channel(&mut self, cx: &mut Context) { - let (tx, mut rx) = mpsc::unbounded::<()>(); - self.delayed_notify_tx = tx; - cx.spawn(async move |this, ctx| { - while rx.next().await.is_some() { - let _ = this.update(ctx, |_, cx| { - cx.notify(); - }); - } - }) - .detach(); - } - - #[deprecated(note = "use builder instead")] - pub fn new(graph: Graph, cx: &mut Context) -> Self { - let focus_handle = cx.focus_handle(); - let (delayed_notify_tx, _rx) = mpsc::unbounded::<()>(); - let mut canvas = Self { - graph, - viewport: Viewport::new(), - plugins_registry: PluginRegistry::new(), - sync_plugin: None, - renderers: RendererRegistry::new(), - focus_handle, - interaction: InteractionState::new(), - history: Box::new(LocalHistory::new()), - event_queue: vec![], - port_offset_cache: PortLayoutCache::new(), - theme: FlowTheme::default(), - shared_state: SharedState::new(), - canvas_bounds: Rc::new(Cell::new(None)), - delayed_notify_tx, - outbound: None, - }; - canvas.init_delayed_notify_channel(cx); - canvas - } - - pub fn builder<'a, 'b>( - graph: Graph, - ctx: &'a mut Context<'b, Self>, - window: &'a Window, - ) -> FlowCanvasBuilder<'a, 'b> { - FlowCanvasBuilder { - graph, - ctx, - window, - plugins: PluginRegistry::new(), - sync_plugin: None, - renderers: RendererRegistry::new(), - theme: FlowTheme::default(), - outbound: None, - } - } - - /// If there is an active [`Interaction`], deliver `MouseMove` / `MouseUp` only to it and return - /// `true` so the plugin chain is skipped for this dispatch (avoids duplicate handling and keeps - /// drag ownership consistent, including for [`Self::process_event_queue`]). - fn dispatch_interaction_pointer(&mut self, event: &FlowEvent, cx: &mut Context) -> bool { - let mut notify = || cx.notify(); - let delayed_notify_tx = self.delayed_notify_tx.clone(); - let mut schedule_after = move |delay: Duration| { - let tx = delayed_notify_tx.clone(); - std::thread::spawn(move || { - std::thread::sleep(delay); - let _ = tx.unbounded_send(()); - }); - }; - match event { - FlowEvent::Input(InputEvent::MouseMove(ev)) => { - let Some(mut handler) = self.interaction.handler.take() else { - return false; - }; - let outbound = &mut self.outbound; - let event_queue = &mut self.event_queue; - let mut emit = |e| enqueue_plugin_emit(outbound, event_queue, e); - let mut ctx = PluginContext::new( - &mut self.graph, - &mut self.port_offset_cache, - &mut self.viewport, - &mut self.interaction, - &mut self.renderers, - &mut self.sync_plugin, - self.history.as_mut(), - &mut self.theme, - &mut self.shared_state, - &mut emit, - &mut notify, - &mut schedule_after, - ); - let result = handler.on_mouse_move(ev, &mut ctx); - match result { - InteractionResult::Continue => self.interaction.handler = Some(handler), - InteractionResult::End => self.interaction.handler = None, - InteractionResult::Replace(h) => self.interaction.handler = Some(h), - } - true - } - FlowEvent::Input(InputEvent::MouseUp(ev)) => { - let Some(mut handler) = self.interaction.handler.take() else { - return false; - }; - let outbound = &mut self.outbound; - let event_queue = &mut self.event_queue; - let mut emit = |e| enqueue_plugin_emit(outbound, event_queue, e); - let mut ctx = PluginContext::new( - &mut self.graph, - &mut self.port_offset_cache, - &mut self.viewport, - &mut self.interaction, - &mut self.renderers, - &mut self.sync_plugin, - self.history.as_mut(), - &mut self.theme, - &mut self.shared_state, - &mut emit, - &mut notify, - &mut schedule_after, - ); - let result = handler.on_mouse_up(ev, &mut ctx); - match result { - InteractionResult::Continue => self.interaction.handler = Some(handler), - InteractionResult::End => self.interaction.handler = None, - InteractionResult::Replace(h) => self.interaction.handler = Some(h), - } - true - } - _ => false, - } - } - - fn handle_event(&mut self, event: FlowEvent, cx: &mut Context) { - if let Some(sync_plugin) = &mut self.sync_plugin { - let mut ctx = SyncPluginContext::new(&self.viewport); - sync_plugin.on_event(&event, &mut ctx); - } - - // Pointer stream is owned by the active [`Interaction`]; do not also give Move/Up to plugins. - if self.dispatch_interaction_pointer(&event, cx) { - return; - } - - let outbound = &mut self.outbound; - let event_queue = &mut self.event_queue; - let mut emit = |e| enqueue_plugin_emit(outbound, event_queue, e); - let mut notify = || cx.notify(); - let delayed_notify_tx = self.delayed_notify_tx.clone(); - let mut schedule_after = move |delay: Duration| { - let tx = delayed_notify_tx.clone(); - std::thread::spawn(move || { - std::thread::sleep(delay); - let _ = tx.unbounded_send(()); - }); - }; - - let mut ctx = PluginContext::new( - &mut self.graph, - &mut self.port_offset_cache, - &mut self.viewport, - &mut self.interaction, - &mut self.renderers, - &mut self.sync_plugin, - self.history.as_mut(), - &mut self.theme, - &mut self.shared_state, - &mut emit, - &mut notify, - &mut schedule_after, - ); - - for plugin in self.plugins_registry.iter_mut() { - let result = plugin.on_event(&event, &mut ctx); - match result { - EventResult::Continue => {} - EventResult::Stop => break, - } - } - } - - /// Same [`PluginContext`] wiring as input dispatch, for **inbound** control from other GPUI - /// entities (toolbar, palette, automation) without touching `graph` directly. - /// - /// Use from `Entity::update`: - /// - /// ```ignore - /// canvas_entity.update(cx, |canvas, cx| { - /// canvas.dispatch_command(CreateNode::new(node), cx); - /// }); - /// ``` - fn with_plugin_context_for_dispatch( - &mut self, - cx: &mut Context, - f: impl FnOnce(&mut PluginContext<'_>), - ) { - let outbound = &mut self.outbound; - let event_queue = &mut self.event_queue; - let mut emit = |e| enqueue_plugin_emit(outbound, event_queue, e); - let mut notify = || cx.notify(); - let delayed_notify_tx = self.delayed_notify_tx.clone(); - let mut schedule_after = move |delay: Duration| { - let tx = delayed_notify_tx.clone(); - std::thread::spawn(move || { - std::thread::sleep(delay); - let _ = tx.unbounded_send(()); - }); - }; - - let mut ctx = PluginContext::new( - &mut self.graph, - &mut self.port_offset_cache, - &mut self.viewport, - &mut self.interaction, - &mut self.renderers, - &mut self.sync_plugin, - self.history.as_mut(), - &mut self.theme, - &mut self.shared_state, - &mut emit, - &mut notify, - &mut schedule_after, - ); - f(&mut ctx); - } - - /// Run a [`Command`] through the same path as plugins: local [`HistoryProvider`] or - /// [`SyncPlugin::process_intent`], then redraw. - /// - /// Prefer this for graph edits so undo/redo and sync stay consistent. - pub fn dispatch_command(&mut self, command: impl Command + 'static, cx: &mut Context) { - self.with_plugin_context_for_dispatch(cx, |ctx| { - ctx.execute_command(command); - }); - } - - /// Undo the last command (same as plugin [`PluginContext::undo`]). - pub fn dispatch_undo(&mut self, cx: &mut Context) { - self.with_plugin_context_for_dispatch(cx, |ctx| { - ctx.undo(); - }); - } - - /// Redo (same as plugin [`PluginContext::redo`]). - pub fn dispatch_redo(&mut self, cx: &mut Context) { - self.with_plugin_context_for_dispatch(cx, |ctx| { - ctx.redo(); - }); - } - - /// Replace or clear the outbound hook ([`FlowCanvasOutbound`]). Prefer calling from - /// `Entity::update` once the canvas exists; see [`FlowCanvasOutbound`] for parent - /// wiring and the `outbound_host` example in this crate. - /// - /// The hook runs on the same thread as input dispatch, **before** the event is pushed onto - /// [`Self::event_queue`]. Graph changes that do not go through - /// [`PluginContext::emit`](crate::plugin::PluginContext::emit) (for example plain - /// [`Self::dispatch_command`] with no follow-up emit) are **not** reported here; use - /// [`gpui::Context::observe`] on the canvas entity if you need those as well. - pub fn set_outbound(&mut self, hook: Option) { - self.outbound = hook; - } - - /// Read-only view of the document graph (nodes, edges, selection). - pub fn graph(&self) -> &Graph { - &self.graph - } - - /// Clone the graph for use outside the current `update` closure (e.g. async snapshots). - pub fn graph_snapshot(&self) -> Graph { - self.graph.clone() - } - - /// Replace the active canvas visual tokens and request a redraw when they changed. - pub fn set_theme(&mut self, theme: FlowTheme, cx: &mut Context) { - if self.theme == theme { - return; - } - self.theme = theme; - cx.notify(); - } - - /// Replace node renderers and clear cached port layout derived from renderer geometry. - pub fn replace_node_renderers>( - &mut self, - items: impl IntoIterator)>, - cx: &mut Context, - ) { - let mut renderers = RendererRegistry::new(); - for (name, renderer) in items { - renderers.register_boxed(name, renderer); - } - self.renderers = renderers; - self.port_offset_cache.clear_all(); - cx.notify(); - } - - fn process_event_queue(&mut self, cx: &mut Context) { - while let Some(event) = self.event_queue.pop() { - if let Some(sync_plugin) = &mut self.sync_plugin { - let mut ctx = SyncPluginContext::new(&self.viewport); - sync_plugin.on_event(&event, &mut ctx); - } - - if self.dispatch_interaction_pointer(&event, cx) { - continue; - } - - let outbound = &mut self.outbound; - let event_queue = &mut self.event_queue; - let mut emit = |e| enqueue_plugin_emit(outbound, event_queue, e); - let mut notify = || cx.notify(); - let delayed_notify_tx = self.delayed_notify_tx.clone(); - let mut schedule_after = |delay: Duration| { - let tx = delayed_notify_tx.clone(); - std::thread::spawn(move || { - std::thread::sleep(delay); - let _ = tx.unbounded_send(()); - }); - }; - - let mut ctx = PluginContext::new( - &mut self.graph, - &mut self.port_offset_cache, - &mut self.viewport, - &mut self.interaction, - &mut self.renderers, - &mut self.sync_plugin, - self.history.as_mut(), - &mut self.theme, - &mut self.shared_state, - &mut emit, - &mut notify, - &mut schedule_after, - ); - - for plugin in self.plugins_registry.iter_mut() { - let result = plugin.on_event(&event, &mut ctx); - match result { - EventResult::Continue => {} - EventResult::Stop => break, - } - } - } - } - - fn on_key_down(&mut self, ev: &KeyDownEvent, _: &mut Window, cx: &mut Context) { - self.handle_event(FlowEvent::Input(InputEvent::KeyDown(ev.clone())), cx); - self.process_event_queue(cx); - } - - fn on_key_up(&mut self, ev: &KeyUpEvent, _: &mut Window, cx: &mut Context) { - self.handle_event(FlowEvent::Input(InputEvent::KeyUp(ev.clone())), cx); - self.process_event_queue(cx); - } - - fn on_mouse_down(&mut self, ev: &MouseDownEvent, _: &mut Window, cx: &mut Context) { - self.sync_viewport_to_canvas_bounds(); - let ev = self.mouse_down_event_in_canvas(ev); - self.handle_event(FlowEvent::Input(InputEvent::MouseDown(ev)), cx); - self.process_event_queue(cx); - } - - fn on_mouse_move(&mut self, ev: &MouseMoveEvent, _: &mut Window, cx: &mut Context) { - self.sync_viewport_to_canvas_bounds(); - let ev = self.mouse_move_event_in_canvas(ev); - self.handle_event(FlowEvent::Input(InputEvent::MouseMove(ev)), cx); - self.process_event_queue(cx); - } - - fn on_mouse_up(&mut self, ev: &MouseUpEvent, _: &mut Window, cx: &mut Context) { - self.sync_viewport_to_canvas_bounds(); - let ev = self.mouse_up_event_in_canvas(ev); - self.handle_event(FlowEvent::Input(InputEvent::MouseUp(ev)), cx); - self.process_event_queue(cx); - } - - fn on_scroll_wheel(&mut self, ev: &ScrollWheelEvent, _: &mut Window, cx: &mut Context) { - self.sync_viewport_to_canvas_bounds(); - let ev = self.scroll_wheel_event_in_canvas(ev); - self.handle_event(FlowEvent::Input(InputEvent::Wheel(ev)), cx); - self.process_event_queue(cx); - } - - fn on_canvas_hover(&mut self, hovered: &bool, _: &mut Window, cx: &mut Context) { - self.handle_event(FlowEvent::Input(InputEvent::Hover(*hovered)), cx); - self.process_event_queue(cx); - } - - fn canvas_origin(&self) -> Point { - self.canvas_bounds - .get() - .map(|bounds| bounds.origin) - .unwrap_or(Point::new(px(0.0), px(0.0))) - } - - fn sync_viewport_to_canvas_bounds(&mut self) { - if let Some(bounds) = self.canvas_bounds.get() { - self.viewport.sync_canvas_bounds(bounds); - } - } - - fn mouse_down_event_in_canvas(&self, ev: &MouseDownEvent) -> MouseDownEvent { - let mut ev = ev.clone(); - ev.position = window_point_to_canvas_point(ev.position, self.canvas_origin()); - ev - } - - fn mouse_move_event_in_canvas(&self, ev: &MouseMoveEvent) -> MouseMoveEvent { - let mut ev = ev.clone(); - ev.position = window_point_to_canvas_point(ev.position, self.canvas_origin()); - ev - } - - fn mouse_up_event_in_canvas(&self, ev: &MouseUpEvent) -> MouseUpEvent { - let mut ev = ev.clone(); - ev.position = window_point_to_canvas_point(ev.position, self.canvas_origin()); - ev - } - - fn scroll_wheel_event_in_canvas(&self, ev: &ScrollWheelEvent) -> ScrollWheelEvent { - let mut ev = ev.clone(); - ev.position = window_point_to_canvas_point(ev.position, self.canvas_origin()); - ev - } -} - -impl Render for FlowCanvas { - fn render(&mut self, window: &mut Window, this_cx: &mut Context) -> impl IntoElement { - if let Some(bounds) = self.canvas_bounds.get() { - self.viewport.sync_canvas_bounds(bounds); - } else { - self.viewport.sync_drawable_bounds(window); - } - - let entity = this_cx.entity(); - - let graph = &mut self.graph; - let viewport = &self.viewport; - let renderers = &self.renderers; - let port_offset_cache = &mut self.port_offset_cache; - let theme = &self.theme; - let shared_state = &self.shared_state; - - let mut layers: Vec> = - (0..RenderLayer::ALL.len()).map(|_| Vec::new()).collect(); - - for plugin in self.plugins_registry.iter_mut() { - let mut ctx = RenderContext::new( - graph, - port_offset_cache, - viewport, - renderers, - window, - theme, - shared_state, - ); - - if let Some(el) = plugin.render(&mut ctx) { - layers[plugin.render_layer().index()].push(el); - } - } - - if let Some(i) = self.interaction.handler.as_ref() { - let mut ctx = RenderContext::new( - graph, - port_offset_cache, - viewport, - renderers, - window, - theme, - shared_state, - ); - - if let Some(el) = i.render(&mut ctx) { - layers[RenderLayer::Interaction.index()].push(el); - } - } - - if let Some(sync_plugin) = &mut self.sync_plugin { - let mut ctx = RenderContext::new( - graph, - port_offset_cache, - viewport, - renderers, - window, - theme, - shared_state, - ); - let els = sync_plugin.render(&mut ctx); - for el in els { - layers[RenderLayer::Overlay.index()].push(el); - } - } - - let root = div() - .id("ferrum_flow_canvas") - .size_full() - .track_focus(&self.focus_handle) - .on_key_down(window.listener_for(&entity, Self::on_key_down)) - .on_key_up(window.listener_for(&entity, Self::on_key_up)) - .on_mouse_down( - MouseButton::Left, - window.listener_for(&entity, Self::on_mouse_down), - ) - .on_mouse_down( - MouseButton::Right, - window.listener_for(&entity, Self::on_mouse_down), - ) - .on_mouse_move(window.listener_for(&entity, Self::on_mouse_move)) - .on_hover(window.listener_for(&entity, Self::on_canvas_hover)) - .on_mouse_up( - MouseButton::Left, - window.listener_for(&entity, Self::on_mouse_up), - ) - .on_scroll_wheel(window.listener_for(&entity, Self::on_scroll_wheel)) - .children(RenderLayer::ALL.iter().map(|layer| { - div() - .id(ElementId::Integer(layer.index() as u64)) - .absolute() - .size_full() - .children(layers[layer.index()].drain(..)) - })); - - CanvasRootElement::new(root, Rc::clone(&self.canvas_bounds)) - } -} - -fn window_point_to_canvas_point( - window_point: Point, - canvas_origin: Point, -) -> Point { - window_point - canvas_origin -} - -struct CanvasRootElement { - element: E, - canvas_bounds: Rc>>>, -} - -impl CanvasRootElement { - fn new(element: E, canvas_bounds: Rc>>>) -> Self { - Self { - element, - canvas_bounds, - } - } -} - -impl IntoElement for CanvasRootElement -where - E: Element, -{ - type Element = Self; - - fn into_element(self) -> Self::Element { - self - } -} - -impl Element for CanvasRootElement -where - E: Element, -{ - type RequestLayoutState = E::RequestLayoutState; - type PrepaintState = E::PrepaintState; - - fn id(&self) -> Option { - self.element.id() - } - - fn source_location(&self) -> Option<&'static core::panic::Location<'static>> { - self.element.source_location() - } - - fn request_layout( - &mut self, - id: Option<&GlobalElementId>, - inspector_id: Option<&InspectorElementId>, - window: &mut Window, - cx: &mut App, - ) -> (LayoutId, Self::RequestLayoutState) { - self.element.request_layout(id, inspector_id, window, cx) - } - - fn prepaint( - &mut self, - id: Option<&GlobalElementId>, - inspector_id: Option<&InspectorElementId>, - bounds: Bounds, - request_layout: &mut Self::RequestLayoutState, - window: &mut Window, - cx: &mut App, - ) -> Self::PrepaintState { - self.canvas_bounds.set(Some(bounds)); - self.element - .prepaint(id, inspector_id, bounds, request_layout, window, cx) - } - - fn paint( - &mut self, - id: Option<&GlobalElementId>, - inspector_id: Option<&InspectorElementId>, - bounds: Bounds, - request_layout: &mut Self::RequestLayoutState, - prepaint: &mut Self::PrepaintState, - window: &mut Window, - cx: &mut App, - ) { - self.element.paint( - id, - inspector_id, - bounds, - request_layout, - prepaint, - window, - cx, - ); - } -} - -#[cfg(test)] -mod tests { - use gpui::{Point, px}; - - use super::window_point_to_canvas_point; - - #[test] - fn window_point_to_canvas_point_subtracts_canvas_origin() { - let window_point = Point::new(px(360.0), px(140.0)); - let canvas_origin = Point::new(px(320.0), px(96.0)); - - let canvas_point = window_point_to_canvas_point(window_point, canvas_origin); - - assert_eq!(canvas_point, Point::new(px(40.0), px(44.0))); - } -} - -pub struct FlowCanvasBuilder<'a, 'b> { - graph: Graph, - ctx: &'a mut Context<'b, FlowCanvas>, - window: &'a Window, - - plugins: PluginRegistry, - renderers: RendererRegistry, - sync_plugin: Option>, - theme: FlowTheme, - outbound: Option, -} - -impl<'a, 'b> FlowCanvasBuilder<'a, 'b> { - /// register plugin - pub fn plugin(mut self, plugin: impl Plugin + 'static) -> Self { - self.plugins = self.plugins.add(plugin); - self - } - - /// Registers several plugins in one call (each item is a `Box`). - /// - /// Order is only relevant before [`Self::build`], which sorts by [`Plugin::priority`]. Prefer - /// [`.plugin`](Self::plugin) for single plugins so the compiler boxes them for you. - /// - /// When building a list of heterogeneous plugin types, use an explicitly typed - /// `Vec>` so each `Box::new(concrete)` coerces to the trait object. - pub fn plugins(mut self, plugins: impl IntoIterator>) -> Self { - self.plugins.extend_boxed(plugins); - self - } - - /// Registers the **core** plugin set for editing a node graph on the canvas: background, - /// selection, node drag, pan/zoom, node/edge rendering, port wiring, delete, and undo/redo - /// ([`BackgroundPlugin`], [`SelectionPlugin`], [`NodeInteractionPlugin`], [`ViewportPlugin`], - /// [`NodePlugin`], [`PortInteractionPlugin`], [`EdgePlugin`], [`DeletePlugin`], [`HistoryPlugin`]). - /// - /// Event order is determined by each plugin’s [`Plugin::priority`] when [`FlowCanvas::build`] - /// runs (not by the order of calls to [`.plugin`](Self::plugin)). Add minimap, clipboard, - /// context menu, etc. with [`.plugin`](Self::plugin) before or after this call. - pub fn default_plugins(mut self) -> Self { - self.plugins = self - .plugins - .add(BackgroundPlugin::new()) - .add(SelectionPlugin::new()) - .add(NodeInteractionPlugin::new()) - .add(ViewportPlugin::new()) - .add(NodePlugin::new()) - .add(PortInteractionPlugin::new()) - .add(EdgePlugin::new()) - .add(DeletePlugin::new()) - .add(HistoryPlugin::new()); - self - } - - pub fn sync_plugin(mut self, plugin: impl SyncPlugin + 'static) -> Self { - self.sync_plugin = Some(Box::new(plugin)); - self - } - - /// register node renderer - pub fn node_renderer(mut self, name: impl Into, renderer: R) -> Self - where - R: node_renderer::NodeRenderer + 'static, - { - self.renderers.register(name, renderer); - self - } - - /// Registers several [`NodeRenderer`](node_renderer::NodeRenderer) entries (each `Box`), same idea as [`Self::plugins`]. - pub fn node_renderers>( - mut self, - items: impl IntoIterator)>, - ) -> Self { - for (name, renderer) in items { - self.renderers.register_boxed(name, renderer); - } - self - } - - /// Replace the default [`FlowTheme`] before plugins run [`Plugin::setup`](crate::plugin::Plugin::setup). - pub fn theme(mut self, theme: FlowTheme) -> Self { - self.theme = theme; - self - } - - /// Register an outbound hook: invoked for every [`PluginContext::emit`](crate::plugin::PluginContext::emit) - /// on this canvas (same as [`FlowCanvas::set_outbound`]). See [`FlowCanvasOutbound`] and the - /// `outbound_host` example for how a parent view can react (e.g. `Arc` or - /// `Entity::update` on a shell). - pub fn outbound(mut self, hook: impl FnMut(&FlowEvent) + Send + 'static) -> Self { - self.outbound = Some(Box::new(hook)); - self - } - - pub fn build(self) -> FlowCanvas { - let mut duplicate_plugins: BTreeMap<&'static str, usize> = BTreeMap::new(); - for plugin in self.plugins.iter() { - *duplicate_plugins.entry(plugin.name()).or_insert(0) += 1; - } - for (name, count) in duplicate_plugins - .into_iter() - .filter(|(_, count)| *count > 1) - { - eprintln!( - "warning: plugin '{name}' is registered {count} times; this can cause duplicated event handling" - ); - } - - let focus_handle = self.ctx.focus_handle(); - let drawable_size = self.window.viewport_size(); - let (delayed_notify_tx, _rx) = mpsc::unbounded::<()>(); - - let mut canvas = FlowCanvas { - graph: self.graph, - viewport: Viewport::new(), - plugins_registry: self.plugins, - sync_plugin: self.sync_plugin, - renderers: self.renderers, - focus_handle, - interaction: InteractionState::new(), - history: Box::new(LocalHistory::new()), - event_queue: vec![], - port_offset_cache: PortLayoutCache::new(), - theme: self.theme, - shared_state: SharedState::new(), - canvas_bounds: Rc::new(Cell::new(None)), - delayed_notify_tx, - outbound: self.outbound, - }; - canvas.init_delayed_notify_channel(self.ctx); - - if let Some(sync_plugin) = &mut canvas.sync_plugin { - let (change_sender, mut change_receiver) = mpsc::unbounded::(); - - self.ctx - .spawn(async move |this, ctx| { - while let Some(change) = change_receiver.next().await { - let _ = this.update(ctx, |this, cx| { - invalidate_port_layout_cache_for_graph_change( - &mut this.port_offset_cache, - &this.graph, - &change.kind, - ); - this.graph.apply(change.kind); - cx.notify(); - }); - } - }) - .detach(); - sync_plugin.setup(change_sender); - } - - canvas.plugins_registry.sort_by_priority_desc(); - - { - let mut ctx = InitPluginContext::new( - &mut canvas.graph, - &mut canvas.port_offset_cache, - &mut canvas.viewport, - &mut canvas.renderers, - self.ctx, - drawable_size, - &mut canvas.theme, - &mut canvas.shared_state, - ); - - for plugin in canvas.plugins_registry.iter_mut() { - plugin.setup(&mut ctx); - } - } - - canvas - } -} diff --git a/crates/ferrum-flow/src/canvas/node_renderer.rs b/crates/ferrum-flow/src/canvas/node_renderer.rs deleted file mode 100644 index 6527d66bd9..0000000000 --- a/crates/ferrum-flow/src/canvas/node_renderer.rs +++ /dev/null @@ -1,182 +0,0 @@ -use gpui::*; -use std::collections::HashMap; - -use crate::node::Node; -use crate::plugin::{NodeCardVariant, RenderContext}; -use crate::{Graph, Port, PortId, PortPosition}; - -pub trait NodeRenderer: Send + Sync { - /// render node inner UI - fn render(&self, node: &Node, ctx: &mut RenderContext) -> AnyElement; - - // custom render port UI - fn port_render(&self, node: &Node, port: &Port, ctx: &mut RenderContext) -> Option { - let frame = ctx.port_screen_frame(node, port)?; - Some( - frame - .anchor_div() - .rounded_full() - .bg(rgb(ctx.theme.default_port_fill)) - .into_any(), - ) - } - - /// computing the position of port relative to node - /// built-in Node Plugin is cached this. - fn port_offset(&self, node: &Node, port: &Port, graph: &Graph) -> Point { - let total = graph - .ports_values() - .filter(|p| { - p.node_id() == node.id() - && p.kind() == port.kind() - && p.position() == port.position() - }) - .count() as f32; - let index = port.index() as f32; - let size = *node.size_ref(); - - match port.position() { - PortPosition::Left => { - let spacing = size.height / (total + 1.0); - Point::new(px(0.0), spacing * (index + 1.0)) - } - PortPosition::Right => { - let spacing = size.height / (total + 1.0); - Point::new(size.width, spacing * (index + 1.0)) - } - PortPosition::Top => { - let spacing = size.width / (total + 1.0); - Point::new(spacing * (index + 1.0), px(0.0)) - } - PortPosition::Bottom => { - let spacing = size.width / (total + 1.0); - Point::new(spacing * (index + 1.0), size.height) - } - } - } -} - -pub struct RendererRegistry { - map: HashMap>, - default: Box, - undefined: Box, -} - -impl RendererRegistry { - pub(crate) fn new() -> Self { - Self { - map: HashMap::new(), - default: Box::new(DefaultNodeRenderer {}), - undefined: Box::new(UndefinedNodeRenderer {}), - } - } - - pub fn register(&mut self, name: impl Into, renderer: R) - where - R: NodeRenderer + 'static, - { - self.map.insert(name.into(), Box::new(renderer)); - } - - pub fn register_boxed(&mut self, name: impl Into, renderer: Box) { - self.map.insert(name.into(), renderer); - } - - pub fn get(&self, name: &str) -> &dyn NodeRenderer { - if name.is_empty() || name == "default" { - return self.default.as_ref(); - } - - self.map - .get(name) - .map(|r| r.as_ref()) - .unwrap_or(self.undefined.as_ref()) - } -} - -struct DefaultNodeRenderer; - -impl NodeRenderer for DefaultNodeRenderer { - fn render(&self, node: &Node, ctx: &mut RenderContext) -> AnyElement { - let node_id = node.id(); - let selected = ctx.graph.selected_node().iter().any(|id| *id == node_id); - - ctx.node_card_shell(node, selected, NodeCardVariant::Default) - .rounded(px(6.0)) - .border(px(1.5)) - .child( - div() - .id(ElementId::Uuid(*node_id.as_uuid())) - .size_full() - .flex() - .items_center() - .justify_center() - .text_center() - .px_2() - .child(default_node_caption(node)) - .text_color(rgb(ctx.theme.node_caption_text)), - ) - .into_any() - } -} - -struct UndefinedNodeRenderer; - -impl NodeRenderer for UndefinedNodeRenderer { - fn render(&self, node: &Node, ctx: &mut RenderContext) -> AnyElement { - ctx.node_card_shell(node, false, NodeCardVariant::UndefinedType) - .rounded(px(6.0)) - .border(px(1.5)) - .child( - div() - .id(ElementId::Uuid(*node.id().as_uuid())) - .size_full() - .flex() - .items_center() - .justify_center() - .text_center() - .px_2() - .child(undefined_node_caption(node)) - .text_color(rgb(ctx.theme.undefined_node_caption_text)), - ) - .into_any() - } -} - -#[deprecated(note = "use `ctx.port_screen_center(node, port_id)`")] -pub fn port_screen_position( - node: &Node, - port_id: PortId, - ctx: &RenderContext, -) -> Option> { - ctx.port_screen_center(node, port_id) -} - -fn data_title(data: &serde_json::Value) -> Option { - if let Some(s) = data.get("label").and_then(|v| v.as_str()) { - let t = s.trim(); - if !t.is_empty() { - return Some(t.to_string()); - } - } - None -} - -/// Label for [`DefaultNodeRenderer`]: user-facing title from `data`, else `node_type`, else a generic word. -/// UUID stays off-canvas; use debug/inspector/tooltip if operators need the id. -pub fn default_node_caption(node: &Node) -> String { - if let Some(s) = data_title(node.data_ref()) { - return s; - } - if !node.renderer_key().is_empty() { - return node.renderer_key().to_string(); - } - "Node".to_string() -} - -fn undefined_node_caption(node: &Node) -> String { - if !node.renderer_key().is_empty() { - return format!("Unknown type: {}", node.renderer_key()); - } - "Unknown node type".to_string() -} diff --git a/crates/ferrum-flow/src/canvas/port_cache.rs b/crates/ferrum-flow/src/canvas/port_cache.rs deleted file mode 100644 index 6e5fab8f06..0000000000 --- a/crates/ferrum-flow/src/canvas/port_cache.rs +++ /dev/null @@ -1,117 +0,0 @@ -use std::collections::HashMap; - -use gpui::{Pixels, Point}; - -use crate::{EdgeId, Graph, NodeId, PortId, RendererRegistry}; - -#[derive(Debug, Clone)] -pub struct PortLayoutCache { - map: HashMap>>, -} - -impl PortLayoutCache { - pub(crate) fn new() -> Self { - Self { - map: HashMap::new(), - } - } - - pub fn get_offset(&self, node_id: &NodeId, port_id: &PortId) -> Option> { - self.map.get(node_id)?.get(port_id).copied() - } - - pub fn is_node_cached(&self, node_id: &NodeId) -> bool { - self.map.contains_key(node_id) - } - - /// Port ids whose offsets are cached for `node_id` (after [`Self::ensure_node_ports`]). - /// - /// Order follows the inner [`HashMap`] and is not guaranteed stable across runs. - pub fn cached_port_ids_for_node(&self, node_id: &NodeId) -> impl Iterator + '_ { - self.map - .get(node_id) - .into_iter() - .flat_map(|ports| ports.keys().copied()) - } - - pub fn replace_node_offsets( - &mut self, - node_id: NodeId, - offsets: HashMap>, - ) { - self.map.insert(node_id, offsets); - } - - pub fn clear_node(&mut self, node_id: &NodeId) { - self.map.remove(node_id); - } - - pub fn clear_all(&mut self) { - self.map.clear(); - } - - /// Fill port layout for `node_id` if not already cached. - pub fn ensure_node_ports( - &mut self, - graph: &Graph, - renderers: &RendererRegistry, - node_id: &NodeId, - ) { - if self.is_node_cached(node_id) { - return; - } - - let Some(node) = graph.get_node(node_id) else { - return; - }; - - let renderer = renderers.get(node.renderer_key()); - - let mut result = HashMap::new(); - - for port in graph.ports_values().filter(|p| p.node_id() == node.id()) { - let pos = renderer.port_offset(node, port, graph); - result.insert(port.id(), pos); - } - - self.replace_node_offsets(node.id(), result); - } - - /// Fill port layout for every node if not already cached. - pub fn ensure_all_nodes_ports(&mut self, graph: &Graph, renderers: &RendererRegistry) { - let node_ids = graph.nodes().keys().copied(); - - for node_id in node_ids { - self.ensure_node_ports(graph, renderers, &node_id); - } - } - - /// Ensure both endpoint nodes of the edge have port layout cached. - pub fn ensure_edge_ports( - &mut self, - graph: &Graph, - renderers: &RendererRegistry, - edge_id: &EdgeId, - ) { - let Some(edge) = graph.get_edge(edge_id) else { - return; - }; - - self.ensure_node_ports_for_port(graph, renderers, &edge.source_port); - self.ensure_node_ports_for_port(graph, renderers, &edge.target_port); - } - - /// Ensure the node that owns `port_id` has port layout cached. - pub fn ensure_node_ports_for_port( - &mut self, - graph: &Graph, - renderers: &RendererRegistry, - port_id: &PortId, - ) { - let Some(port) = graph.get_port(port_id) else { - return; - }; - - self.ensure_node_ports(graph, renderers, &port.node_id()); - } -} diff --git a/crates/ferrum-flow/src/canvas/types.rs b/crates/ferrum-flow/src/canvas/types.rs deleted file mode 100644 index 6bb68c1ed6..0000000000 --- a/crates/ferrum-flow/src/canvas/types.rs +++ /dev/null @@ -1,51 +0,0 @@ -use gpui::{AnyElement, MouseMoveEvent, MouseUpEvent}; - -use crate::plugin::{PluginContext, RenderContext}; - -pub struct InteractionState { - pub(crate) handler: Option>, -} - -impl InteractionState { - pub(crate) fn new() -> Self { - Self { handler: None } - } - - pub fn add(&mut self, handler: impl Interaction + 'static) { - self.handler = Some(Box::new(handler)); - } - - pub fn clear(&mut self) { - self.handler = None; - } - - pub fn is_some(&self) -> bool { - self.handler.is_some() - } -} - -pub trait Interaction { - fn on_mouse_move( - &mut self, - event: &MouseMoveEvent, - ctx: &mut PluginContext, - ) -> InteractionResult; - - fn on_mouse_up(&mut self, event: &MouseUpEvent, ctx: &mut PluginContext) -> InteractionResult; - - fn render(&self, _ctx: &mut RenderContext) -> Option { - None - } -} - -pub enum InteractionResult { - Continue, - End, - Replace(Box), -} - -impl InteractionResult { - pub fn replace(new_handler: impl Interaction + 'static) -> Self { - Self::Replace(Box::new(new_handler)) - } -} diff --git a/crates/ferrum-flow/src/canvas/undo.rs b/crates/ferrum-flow/src/canvas/undo.rs deleted file mode 100644 index 172583add5..0000000000 --- a/crates/ferrum-flow/src/canvas/undo.rs +++ /dev/null @@ -1,341 +0,0 @@ -use std::collections::HashMap; - -use gpui::{Bounds, Pixels, Point}; - -use crate::{ - Edge, EdgeBuilder, EdgeId, Graph, GraphOp, Node, NodeBuilder, NodeId, Port, PortId, - RendererRegistry, SharedState, Viewport, - canvas::PortLayoutCache, - plugin::{is_edge_visible, is_node_visible}, -}; - -pub trait Command { - fn name(&self) -> &'static str; - - /// execute command detail, e.g: move node - fn execute(&mut self, ctx: &mut CommandContext); - - // undo command , when open sync plugin, this is diabeled. - fn undo(&mut self, ctx: &mut CommandContext); - - /// used by sync plugin - /// when open sync plugin, execute method is diasbeld, and using to_ops send graph intent - fn to_ops(&self, _ctx: &mut CommandContext) -> Vec { - vec![] - } -} - -pub trait HistoryProvider { - fn undo(&mut self, ctx: &mut CommandContext); - fn redo(&mut self, ctx: &mut CommandContext); - fn push(&mut self, command: Box, ctx: &mut CommandContext); - fn clear(&mut self); -} - -pub struct CommandContext<'a> { - pub graph: &'a mut Graph, - pub port_offset_cache: &'a mut PortLayoutCache, - viewport: &'a mut Viewport, - pub renderers: &'a mut RendererRegistry, - /// Shared plugin state on the [`FlowCanvas`](crate::canvas::FlowCanvas). - pub shared_state: &'a mut SharedState, - pub(crate) notify: &'a mut dyn FnMut(), -} -const MAX_HISTORY: usize = 100; -pub struct LocalHistory { - undo_stack: Vec>, - redo_stack: Vec>, -} - -impl LocalHistory { - pub(crate) fn new() -> Self { - Self { - undo_stack: vec![], - redo_stack: vec![], - } - } -} - -impl HistoryProvider for LocalHistory { - fn push(&mut self, mut command: Box, ctx: &mut CommandContext) { - command.execute(ctx); - - self.undo_stack.push(command); - - self.redo_stack.clear(); - - if self.undo_stack.len() > MAX_HISTORY { - self.undo_stack.remove(0); - } - } - fn undo(&mut self, ctx: &mut CommandContext) { - if let Some(mut cmd) = self.undo_stack.pop() { - cmd.undo(ctx); - self.redo_stack.push(cmd); - } - } - - fn redo(&mut self, ctx: &mut CommandContext) { - if let Some(mut cmd) = self.redo_stack.pop() { - cmd.execute(ctx); - self.undo_stack.push(cmd); - } - } - - fn clear(&mut self) { - self.undo_stack.clear(); - self.redo_stack.clear(); - } -} - -pub struct CompositeCommand { - commands: Vec>, -} - -impl Default for CompositeCommand { - fn default() -> Self { - Self::new() - } -} - -impl CompositeCommand { - pub fn new() -> Self { - Self { - commands: Vec::new(), - } - } - pub fn push(&mut self, command: impl Command + 'static) { - self.commands.push(Box::new(command)); - } -} - -impl Command for CompositeCommand { - fn name(&self) -> &'static str { - "composite" - } - fn execute(&mut self, state: &mut CommandContext) { - for cmd in &mut self.commands { - cmd.execute(state); - } - } - - fn undo(&mut self, state: &mut CommandContext) { - for cmd in self.commands.iter_mut().rev() { - cmd.undo(state); - } - } - fn to_ops(&self, ctx: &mut CommandContext) -> Vec { - let mut list = vec![]; - for cmd in &self.commands { - list.extend(cmd.to_ops(ctx)); - } - - vec![GraphOp::Batch(list)] - } -} - -impl<'a> CommandContext<'a> { - pub(crate) fn new( - graph: &'a mut Graph, - port_offset_cache: &'a mut PortLayoutCache, - viewport: &'a mut Viewport, - renderers: &'a mut RendererRegistry, - shared_state: &'a mut SharedState, - notify: &'a mut dyn FnMut(), - ) -> Self { - Self { - graph, - port_offset_cache, - viewport, - renderers, - shared_state, - notify, - } - } - pub fn create_node(&mut self, node_type: &str) -> NodeBuilder<'_> { - self.graph.create_node(node_type) - } - - pub fn create_edge(&mut self) -> EdgeBuilder<'_> { - self.graph.create_edge() - } - - pub fn next_node_id(&self) -> NodeId { - self.graph.next_node_id() - } - - pub fn next_port_id(&self) -> PortId { - self.graph.next_port_id() - } - - pub fn next_edge_id(&self) -> EdgeId { - self.graph.next_edge_id() - } - pub fn add_node(&mut self, node: Node) { - self.graph.add_node(node); - } - - pub fn add_port(&mut self, port: Port) { - self.graph.add_port(port); - } - - pub fn remove_port(&mut self, id: &PortId) { - self.graph.remove_port(id); - } - - pub fn get_node(&self, id: &NodeId) -> Option<&Node> { - self.graph.get_node(id) - } - - pub fn get_node_mut(&mut self, id: &NodeId) -> Option<&mut Node> { - self.graph.get_node_mut(id) - } - pub fn remove_node(&mut self, id: &NodeId) { - self.graph.remove_node(id); - self.port_offset_cache.clear_node(id); - } - pub fn nodes(&self) -> &HashMap { - self.graph.nodes() - } - pub fn node_order(&self) -> &Vec { - self.graph.node_order() - } - - pub fn new_edge(&self) -> Edge { - self.graph.new_edge() - } - - pub fn add_edge(&mut self, edge: Edge) { - self.graph.add_edge(edge); - } - - pub fn remove_edge(&mut self, edge_id: &EdgeId) { - self.graph.remove_edge(edge_id); - } - - pub fn add_selected_node(&mut self, id: NodeId, shift: bool) { - self.graph.add_selected_node(id, shift); - } - pub fn clear_selected_node(&mut self) { - self.graph.clear_selected_node(); - } - pub fn remove_selected_node(&mut self) -> bool { - self.graph.remove_selected_node() - } - - pub fn add_selected_edge(&mut self, id: EdgeId, shift: bool) { - self.graph.add_selected_edge(id, shift); - } - pub fn clear_selected_edge(&mut self) { - self.graph.clear_selected_edge(); - } - pub fn remove_selected_edge(&mut self) -> bool { - self.graph.remove_selected_edge() - } - - pub fn selection_bounds(&self) -> Option> { - self.graph.selection_bounds() - } - - pub fn selected_nodes_with_positions(&self) -> HashMap> { - self.graph.selected_nodes_with_positions() - } - - pub fn hit_node(&self, mouse: Point) -> Option { - self.graph.hit_node(mouse, self.viewport) - } - - pub fn bring_node_to_front(&mut self, node_id: NodeId) { - self.graph.bring_node_to_front(node_id); - } - - // ---- Viewport shortcuts ---- - pub fn zoom(&self) -> f32 { - self.viewport.zoom() - } - - pub fn set_zoom(&mut self, zoom: f32) { - self.viewport.set_zoom(zoom); - } - - pub fn zoom_scaled_by(&self, factor: f32) -> f32 { - self.viewport.zoom_scaled_by(factor) - } - - pub fn offset(&self) -> Point { - self.viewport.offset() - } - - pub fn set_offset(&mut self, offset: Point) { - self.viewport.set_offset(offset); - } - - pub fn set_offset_xy(&mut self, x: Pixels, y: Pixels) { - self.viewport.set_offset_xy(x, y); - } - - pub fn translate_offset(&mut self, dx: Pixels, dy: Pixels) { - self.viewport.translate_offset(dx, dy); - } - - pub fn window_bounds(&self) -> Option> { - self.viewport.window_bounds() - } - - pub fn set_window_bounds(&mut self, bounds: Option>) { - self.viewport.set_window_bounds(bounds); - } - - pub fn world_scalar_to_screen(&self, value: f32) -> f32 { - self.viewport.world_scalar_to_screen(value) - } - - pub fn screen_scalar_to_world(&self, value: f32) -> f32 { - self.viewport.screen_scalar_to_world(value) - } - - pub fn world_length_to_screen(&self, value: Pixels) -> Pixels { - self.viewport.world_length_to_screen(value) - } - - pub fn screen_length_to_world(&self, value: Pixels) -> Pixels { - self.viewport.screen_length_to_world(value) - } - - pub fn world_to_screen(&self, p: Point) -> Point { - self.viewport.world_to_screen(p) - } - - pub fn screen_to_world(&self, p: Point) -> Point { - self.viewport.screen_to_world(p) - } - - pub fn is_node_visible(&self, node_id: &NodeId) -> bool { - is_node_visible(self.graph, self.viewport, node_id) - } - pub fn is_node_visible_node(&self, node: &Node) -> bool { - self.viewport.is_node_visible(node) - } - - pub fn is_edge_visible(&self, edge: &Edge) -> bool { - is_edge_visible(self.graph, self.viewport, edge) - } - - pub fn port_offset_cached(&self, node_id: &NodeId, port_id: &PortId) -> Option> { - self.port_offset_cache.get_offset(node_id, port_id) - } - - pub fn cache_all_node_port_offset(&mut self) { - self.port_offset_cache - .ensure_all_nodes_ports(self.graph, self.renderers); - } - - pub fn cache_node_port_offset(&mut self, node_id: &NodeId) { - self.port_offset_cache - .ensure_node_ports(self.graph, self.renderers, node_id); - } - - pub fn notify(&mut self) { - (self.notify)(); - } -} diff --git a/crates/ferrum-flow/src/command_interop.rs b/crates/ferrum-flow/src/command_interop.rs deleted file mode 100644 index 1af7c3a2a9..0000000000 --- a/crates/ferrum-flow/src/command_interop.rs +++ /dev/null @@ -1,208 +0,0 @@ -//! Helpers for verifying that [`Command`](crate::Command) implementations agree between -//! [`Command::execute`](crate::Command::execute), [`Command::undo`](crate::Command::undo), and -//! [`Command::to_ops`](crate::Command::to_ops). -//! -//! Enable the **`testing`** Cargo feature on `ferrum-flow` to use this module: -//! -//! ```toml -//! ferrum-flow = { version = "…", features = ["testing"] } -//! ``` -//! -//! Run this crate’s built-in interop tests with: -//! -//! ```text -//! cargo test -p ferrum-flow --features testing -//! ``` -//! -//! The public entry points are [`graph_snapshot`] and [`assert_command_interop`]. Example tests that -//! use them live next to each [`Command`](crate::Command) implementation under `plugins/` (and -//! `plugins/port/command.rs` for create commands). - -use serde_json::{Value, json}; - -use crate::{ - Command, CommandContext, Graph, GraphOp, RendererRegistry, SharedState, Viewport, - canvas::PortLayoutCache, -}; - -fn with_command_ctx(graph: &mut Graph, f: impl FnOnce(&mut CommandContext) -> R) -> R { - let mut port_offset_cache = PortLayoutCache::new(); - let mut viewport = Viewport::new(); - let mut renderers = RendererRegistry::new(); - let mut shared_state = SharedState::new(); - let mut notify = || {}; - let mut ctx = CommandContext::new( - graph, - &mut port_offset_cache, - &mut viewport, - &mut renderers, - &mut shared_state, - &mut notify, - ); - f(&mut ctx) -} - -fn apply_graph_op(graph: &mut Graph, op: GraphOp) { - match op { - GraphOp::AddNode(node) => { - graph.add_node_without_order(node); - } - GraphOp::RemoveNode { id } => graph.remove_node(&id), - GraphOp::MoveNode { id, x, y } => { - if let Some(node) = graph.get_node_mut(&id) { - node.set_position(x.into(), y.into()); - } - } - GraphOp::ResizeNode { id, size } => { - if let Some(node) = graph.get_node_mut(&id) { - node.set_size_mut(size); - } - } - GraphOp::UpdateNodeData { id, data } => { - if let Some(node) = graph.get_node_mut(&id) { - node.set_data(data); - } - } - GraphOp::NodeOrderInsert { id } => graph.node_order_mut().push(id), - GraphOp::NodeOrderRemove { index } => { - if index < graph.node_order().len() { - graph.node_order_mut().remove(index); - } - } - GraphOp::AddPort(port) => graph.add_port(port), - GraphOp::RemovePort(id) => graph.remove_port(&id), - GraphOp::AddEdge(edge) => graph.add_edge(edge), - GraphOp::RemoveEdge(id) => graph.remove_edge(&id), - GraphOp::Batch(ops) => { - for op in ops { - apply_graph_op(graph, op); - } - } - } -} - -/// Canonical JSON snapshot of a [`Graph`] for stable equality checks (sorted maps / sets). -pub fn graph_snapshot(graph: &Graph) -> Value { - let mut nodes: Vec<_> = graph - .nodes() - .iter() - .map(|(id, n)| { - ( - id.to_string(), - json!({ - "x": f32::from(n.position().0), - "y": f32::from(n.position().1), - "w": f32::from(n.size_ref().width), - "h": f32::from(n.size_ref().height), - "inputs": n.inputs().iter().map(ToString::to_string).collect::>(), - "outputs": n.outputs().iter().map(ToString::to_string).collect::>(), - }), - ) - }) - .collect(); - nodes.sort_by(|a, b| a.0.cmp(&b.0)); - - let mut ports: Vec<_> = graph - .ports() - .iter() - .map(|(id, p)| { - ( - id.to_string(), - json!({ - "node_id": p.node_id().to_string(), - "kind": p.kind().to_string(), - "position": p.position().to_string(), - "index": p.index(), - "w": f32::from(p.size_ref().width), - "h": f32::from(p.size_ref().height), - }), - ) - }) - .collect(); - ports.sort_by(|a, b| a.0.cmp(&b.0)); - - let mut edges: Vec<_> = graph - .edges() - .iter() - .map(|(id, e)| { - ( - id.to_string(), - json!({ - "source": e.source_port.to_string(), - "target": e.target_port.to_string(), - }), - ) - }) - .collect(); - edges.sort_by(|a, b| a.0.cmp(&b.0)); - - let mut selected_node: Vec<_> = graph - .selected_node() - .iter() - .map(ToString::to_string) - .collect(); - selected_node.sort(); - let mut selected_edge: Vec<_> = graph - .selected_edge() - .iter() - .map(ToString::to_string) - .collect(); - selected_edge.sort(); - - json!({ - "nodes": nodes, - "ports": ports, - "edges": edges, - "node_order": graph.node_order().iter().map(ToString::to_string).collect::>(), - "selected_node": selected_node, - "selected_edge": selected_edge - }) -} - -/// Asserts that `execute` + `undo` restores `base`, and that replaying `to_ops` matches `execute`. -pub fn assert_command_interop( - base: &Graph, - mut make: impl FnMut() -> Box, - case_name: &str, -) { - let expected_after_execute = { - let mut g = base.clone(); - with_command_ctx(&mut g, |ctx| { - let mut cmd = make(); - cmd.execute(ctx); - }); - g - }; - - let execute_then_undo = { - let mut g = base.clone(); - with_command_ctx(&mut g, |ctx| { - let mut cmd = make(); - cmd.execute(ctx); - cmd.undo(ctx); - }); - g - }; - assert_eq!( - graph_snapshot(&execute_then_undo), - graph_snapshot(base), - "execute+undo must restore original graph for {case_name}" - ); - - let via_ops = { - let mut g = base.clone(); - with_command_ctx(&mut g, |ctx| { - let cmd = make(); - let ops = cmd.to_ops(ctx); - for op in ops { - apply_graph_op(ctx.graph, op); - } - }); - g - }; - assert_eq!( - graph_snapshot(&via_ops), - graph_snapshot(&expected_after_execute), - "to_ops replay must match execute result for {case_name}" - ); -} diff --git a/crates/ferrum-flow/src/edge.rs b/crates/ferrum-flow/src/edge.rs deleted file mode 100644 index bf5f47d0cb..0000000000 --- a/crates/ferrum-flow/src/edge.rs +++ /dev/null @@ -1,123 +0,0 @@ -use std::{fmt::Display, str::FromStr as _}; - -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -use crate::{Graph, PortId}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct EdgeId(Uuid); - -impl Display for EdgeId { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.0) - } -} - -impl Default for EdgeId { - fn default() -> Self { - Self::new() - } -} - -impl EdgeId { - pub fn new() -> Self { - Self(Uuid::new_v4()) - } - pub fn from_string(s: impl Into) -> Option { - let string = s.into(); - Uuid::from_str(&string).ok().map(Self) - } - pub fn from_uuid(uuid: Uuid) -> Self { - Self(uuid) - } - - pub fn as_uuid(&self) -> &Uuid { - &self.0 - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Edge { - pub id: EdgeId, - pub source_port: PortId, - - pub target_port: PortId, -} - -impl Default for Edge { - fn default() -> Self { - Self::new() - } -} - -impl Edge { - pub fn new() -> Self { - Self { - id: EdgeId::new(), - source_port: PortId::new(), - target_port: PortId::new(), - } - } - pub fn source(mut self, port: PortId) -> Self { - self.source_port = port; - self - } - pub fn target(mut self, port: PortId) -> Self { - self.target_port = port; - self - } -} - -pub struct EdgeBuilder<'a> { - graph: Option<&'a mut Graph>, - source: Option, - target: Option, -} - -impl<'a> Default for EdgeBuilder<'a> { - fn default() -> Self { - Self::new() - } -} - -impl<'a> EdgeBuilder<'a> { - pub fn new() -> Self { - Self { - graph: None, - source: None, - target: None, - } - } - - pub fn graph(mut self, graph: &'a mut Graph) -> Self { - self.graph = Some(graph); - self - } - - pub fn source(mut self, port: PortId) -> Self { - self.source = Some(port); - self - } - - pub fn target(mut self, port: PortId) -> Self { - self.target = Some(port); - self - } - - pub fn build(self) -> Option { - let graph = self.graph?; - let source = self.source?; - let target = self.target?; - - let edge_id = graph.next_edge_id(); - - graph.add_edge(Edge { - id: edge_id, - source_port: source, - target_port: target, - }); - - Some(edge_id) - } -} diff --git a/crates/ferrum-flow/src/graph.rs b/crates/ferrum-flow/src/graph.rs deleted file mode 100644 index e75c52fd3e..0000000000 --- a/crates/ferrum-flow/src/graph.rs +++ /dev/null @@ -1,378 +0,0 @@ -use std::collections::hash_map::Values as HashMapValues; -use std::collections::hash_set::Iter as HashSetIter; -use std::collections::{HashMap, HashSet}; - -use gpui::{Bounds, Pixels, Point, Size, px}; -use serde::{Deserialize, Serialize}; - -use crate::edge::{Edge, EdgeId}; -use crate::node::{Node, NodeId, Port, PortId}; -use crate::{EdgeBuilder, NodeBuilder, PortKind, PortPosition, Viewport}; - -mod store; - -pub use store::{ChangeSource, GraphChange, GraphChangeKind, GraphOp}; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Graph { - nodes: HashMap, - node_order: Vec, - ports: HashMap, - - edges: HashMap, - - selected_edge: HashSet, - selected_node: HashSet, -} - -impl Default for Graph { - fn default() -> Self { - Self::new() - } -} - -impl Graph { - pub fn new() -> Self { - Self { - nodes: HashMap::new(), - node_order: vec![], - ports: HashMap::new(), - edges: HashMap::new(), - selected_edge: HashSet::new(), - selected_node: HashSet::new(), - } - } - - pub fn from_json(json: &str) -> serde_json::Result { - serde_json::from_str(json) - } - - pub fn to_json(&self) -> serde_json::Result { - serde_json::to_string(self) - } - - pub fn is_empty(&self) -> bool { - self.nodes.is_empty() - && self.ports.is_empty() - && self.edges.is_empty() - && self.node_order.is_empty() - } - - pub fn apply(&mut self, op: GraphChangeKind) { - match op { - GraphChangeKind::NodeAdded(node) => self.add_node(node), - GraphChangeKind::NodeRemoved { id } => self.remove_node(&id), - GraphChangeKind::NodeMoved { id, x, y } => { - if let Some(node) = self.nodes.get_mut(&id) { - node.set_position(px(x), px(y)); - } - } - GraphChangeKind::NodeSetWidthed { id, width } => { - if let Some(node) = self.nodes.get_mut(&id) { - node.set_size_width(px(width)); - } - } - GraphChangeKind::NodeSetHeighted { id, height } => { - if let Some(node) = self.nodes.get_mut(&id) { - node.set_size_height(px(height)); - } - } - GraphChangeKind::NodeDataUpdated { id, data } => { - if let Some(node) = self.nodes.get_mut(&id) { - node.set_data(data); - } - } - GraphChangeKind::NodeOrderUpdate(vec) => { - self.node_order = vec; - } - GraphChangeKind::PortAdded(port) => self.add_port(port), - GraphChangeKind::PortRemoved { id } => { - self.remove_port(&id); - } - GraphChangeKind::EdgeAdded(edge) => self.add_edge(edge), - GraphChangeKind::EdgeRemoved { id } => self.remove_edge(&id), - GraphChangeKind::RedrawRequested => {} - GraphChangeKind::Batch(graph_change_kinds) => { - for change in graph_change_kinds { - self.apply(change); - } - } - } - } - - pub fn create_node(&mut self, renderer_key: &str) -> NodeBuilder<'_> { - NodeBuilder::new(renderer_key).graph(self) - } - - pub fn create_edge(&mut self) -> EdgeBuilder<'_> { - EdgeBuilder::new().graph(self) - } - - #[deprecated(note = "use `Graph::create_edge`")] - pub fn create_dege(&mut self) -> EdgeBuilder<'_> { - EdgeBuilder::new().graph(self) - } - - pub fn next_node_id(&self) -> NodeId { - NodeId::new() - } - - pub fn next_port_id(&self) -> PortId { - PortId::new() - } - - pub fn next_edge_id(&self) -> EdgeId { - EdgeId::new() - } - - pub fn add_node(&mut self, node: Node) { - let node_id = node.id(); - self.nodes.insert(node.id(), node); - self.node_order.push(node_id); - } - #[cfg(any(test, feature = "testing"))] - pub(crate) fn add_node_without_order(&mut self, node: Node) { - self.nodes.insert(node.id(), node); - } - - pub fn add_port(&mut self, port: Port) { - let map = &mut self.ports; - map.insert(port.id(), port); - } - - pub fn remove_port(&mut self, id: &PortId) { - self.ports.remove(id); - } - - pub fn nodes(&self) -> &HashMap { - &self.nodes - } - - pub fn node_order(&self) -> &Vec { - &self.node_order - } - pub fn node_order_mut(&mut self) -> &mut Vec { - &mut self.node_order - } - pub fn ports(&self) -> &HashMap { - &self.ports - } - pub fn get_port(&self, id: &PortId) -> Option<&Port> { - self.ports.get(id) - } - pub fn ports_values(&self) -> HashMapValues<'_, PortId, Port> { - self.ports.values() - } - pub fn edges(&self) -> &HashMap { - &self.edges - } - pub fn get_edge(&self, id: &EdgeId) -> Option<&Edge> { - self.edges.get(id) - } - pub fn edges_values(&self) -> HashMapValues<'_, EdgeId, Edge> { - self.edges.values() - } - pub fn selected_node(&self) -> &HashSet { - &self.selected_node - } - pub fn selected_node_is_empty(&self) -> bool { - self.selected_node.is_empty() - } - pub fn selected_node_iter(&self) -> HashSetIter<'_, NodeId> { - self.selected_node.iter() - } - pub fn selected_edge(&self) -> &HashSet { - &self.selected_edge - } - pub fn selected_edge_iter(&self) -> HashSetIter<'_, EdgeId> { - self.selected_edge.iter() - } - pub fn set_selected_node(&mut self, selected: HashSet) { - self.selected_node = selected; - } - pub fn set_selected_edge(&mut self, selected: HashSet) { - self.selected_edge = selected; - } - - pub fn new_edge(&self) -> Edge { - Edge::new() - } - - pub fn add_edge(&mut self, edge: Edge) { - self.edges.insert(edge.id, edge); - } - - pub fn remove_edge(&mut self, edge_id: &EdgeId) { - self.edges.remove(edge_id); - self.selected_edge.remove(edge_id); - } - - pub fn get_node(&self, id: &NodeId) -> Option<&Node> { - self.nodes.get(id) - } - - pub fn get_node_mut(&mut self, id: &NodeId) -> Option<&mut Node> { - self.nodes.get_mut(id) - } - - pub fn remove_node(&mut self, id: &NodeId) { - let Some(node) = &self.nodes.get(id) else { - return; - }; - - let mut edge_ids_to_remove = HashSet::new(); - for port_id in node.inputs().iter().chain(node.outputs().iter()).copied() { - edge_ids_to_remove.extend( - self.edges - .iter() - .filter(|(_, edge)| edge.source_port == port_id || edge.target_port == port_id) - .map(|(id, _)| *id), - ); - self.ports.remove(&port_id); - } - for edge_id in edge_ids_to_remove { - self.remove_edge(&edge_id); - } - - self.nodes.remove(id); - self.selected_node.remove(id); - let index = self.node_order.iter().position(|v| *v == *id); - if let Some(index) = index { - self.node_order.remove(index); - } - } - - pub fn add_selected_node(&mut self, id: NodeId, shift: bool) { - if shift { - if self.selected_node.contains(&id) { - self.selected_node.remove(&id); - } else { - self.selected_node.insert(id); - } - } else { - self.selected_node.clear(); - self.selected_node.insert(id); - } - } - pub fn clear_selected_node(&mut self) { - self.selected_node.clear(); - } - - pub fn remove_selected_node(&mut self) -> bool { - if self.selected_node.is_empty() { - return false; - } - - let mut ids = vec![]; - for id in self.selected_node.iter() { - ids.push(*id); - } - for id in ids.iter() { - self.remove_node(id); - } - self.selected_node.clear(); - true - } - - pub fn add_selected_edge(&mut self, id: EdgeId, shift: bool) { - if shift { - if self.selected_edge.contains(&id) { - self.selected_edge.remove(&id); - } else { - self.selected_edge.insert(id); - } - } else { - self.selected_edge.clear(); - self.selected_edge.insert(id); - } - } - pub fn clear_selected_edge(&mut self) { - self.selected_edge.clear(); - } - - pub fn remove_selected_edge(&mut self) -> bool { - if self.selected_edge.is_empty() { - return false; - } - - let mut ids = vec![]; - for id in self.selected_edge.iter() { - ids.push(*id); - } - for id in ids.iter() { - self.edges.remove(id); - } - self.selected_edge.clear(); - true - } - - pub fn selection_bounds(&self) -> Option> { - let mut min_x = f32::MAX; - let mut min_y = f32::MAX; - let mut max_x = f32::MIN; - let mut max_y = f32::MIN; - - let mut found = false; - - for id in &self.selected_node { - let node = &self.nodes.get(id)?; - let (x, y) = node.position(); - let size = *node.size_ref(); - - min_x = min_x.min(x.into()); - min_y = min_y.min(y.into()); - - max_x = max_x.max((x + size.width).into()); - max_y = max_y.max((y + size.height).into()); - - found = true; - } - - if !found { - return None; - } - - Some(Bounds::new( - Point::new(px(min_x), px(min_y)), - Size::new(px(max_x - min_x), px(max_y - min_y)), - )) - } - - pub fn selected_nodes_with_positions(&self) -> HashMap> { - self.selected_node - .iter() - .filter_map(|id| { - let n = &self.nodes.get(id)?; - Some((*id, n.point())) - }) - .collect() - } - - pub fn hit_node(&self, mouse: Point, viewport: &Viewport) -> Option { - self.nodes - .iter() - .filter(|(_, node)| viewport.is_node_visible(node)) - .find(|(_, n)| n.bounds().contains(&mouse)) - .map(|(id, _)| *id) - } - - pub fn bring_node_to_front(&mut self, node_id: NodeId) { - if let Some(index) = self.node_order_mut().iter().position(|id| *id == node_id) { - self.node_order_mut().remove(index); - } - - self.node_order_mut().push(node_id); - } - - pub fn ports_on_node_side( - &self, - node_id: NodeId, - kind: PortKind, - position: PortPosition, - ) -> Vec<&Port> { - self.ports - .values() - .filter(|p| p.node_id() == node_id && p.kind() == kind && p.position() == position) - .collect() - } -} diff --git a/crates/ferrum-flow/src/graph/store.rs b/crates/ferrum-flow/src/graph/store.rs deleted file mode 100644 index c0408cdf6a..0000000000 --- a/crates/ferrum-flow/src/graph/store.rs +++ /dev/null @@ -1,102 +0,0 @@ -use gpui::{Pixels, Size}; -use serde::{Deserialize, Serialize}; - -use crate::{Edge, EdgeId, Node, NodeId, Port, PortId}; - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[non_exhaustive] -pub enum GraphOp { - // --- Node --- - AddNode(Node), - - RemoveNode { id: NodeId }, - - MoveNode { id: NodeId, x: f32, y: f32 }, - - ResizeNode { id: NodeId, size: Size }, - - UpdateNodeData { id: NodeId, data: serde_json::Value }, - - // --- node_order --- - NodeOrderInsert { id: NodeId }, - NodeOrderRemove { index: usize }, - - // --- Port --- - AddPort(Port), - - RemovePort(PortId), - - // --- Edge --- - AddEdge(Edge), - - RemoveEdge(EdgeId), - - Batch(Vec), -} - -#[derive(Debug, Clone)] -pub struct GraphChange { - pub kind: GraphChangeKind, - pub source: ChangeSource, -} - -impl GraphChange { - pub fn is_local(&self) -> bool { - matches!(self.source, ChangeSource::Local) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ChangeSource { - Local, - Remote, - Undo, - Redo, -} - -#[derive(Debug, Clone)] -#[non_exhaustive] -pub enum GraphChangeKind { - // --- Node --- - NodeAdded(Node), - NodeRemoved { - id: NodeId, - }, - NodeMoved { - id: NodeId, - x: f32, - y: f32, - }, - NodeSetWidthed { - id: NodeId, - width: f32, - }, - NodeSetHeighted { - id: NodeId, - height: f32, - }, - NodeDataUpdated { - id: NodeId, - data: serde_json::Value, - }, - - // --- node_order --- - NodeOrderUpdate(Vec), - - // --- Port --- - PortAdded(Port), - PortRemoved { - id: PortId, - }, - - // --- Edge --- - EdgeAdded(Edge), - EdgeRemoved { - id: EdgeId, - }, - - /// No graph mutation; used to request a frame repaint (e.g. after remote awareness updates). - RedrawRequested, - - Batch(Vec), -} diff --git a/crates/ferrum-flow/src/lib.rs b/crates/ferrum-flow/src/lib.rs deleted file mode 100644 index 425b1f7d6b..0000000000 --- a/crates/ferrum-flow/src/lib.rs +++ /dev/null @@ -1,35 +0,0 @@ -mod canvas; -#[cfg(any(feature = "testing", test))] -pub mod command_interop; -mod edge; -mod graph; -mod node; -mod plugin; -#[cfg(any(feature = "testing", test))] -pub mod plugin_testing; -mod plugins; -mod port_screen; -mod shared_state; -mod theme; -mod viewport; - -/// Prefer [`RenderContext::port_screen_frame`](crate::plugin::RenderContext::port_screen_frame). -#[allow(deprecated)] -pub use canvas::port_screen_position; -pub use canvas::{ - Command, CommandContext, CompositeCommand, FlowCanvas, FlowCanvasOutbound, HistoryProvider, - Interaction, InteractionResult, InteractionState, LocalHistory, NodeRenderer, RendererRegistry, - default_node_caption, -}; -pub use edge::*; -pub use graph::*; -pub use node::*; -pub use plugin::{ - EventResult, FlowEvent, InitPluginContext, InputEvent, NodeCardVariant, Plugin, PluginContext, - RenderContext, RenderLayer, SyncPlugin, SyncPluginContext, primary_platform_modifier, -}; -pub use plugins::*; -pub use port_screen::PortScreenFrame; -pub use shared_state::SharedState; -pub use theme::FlowTheme; -pub use viewport::Viewport; diff --git a/crates/ferrum-flow/src/node.rs b/crates/ferrum-flow/src/node.rs deleted file mode 100644 index 674f862cd6..0000000000 --- a/crates/ferrum-flow/src/node.rs +++ /dev/null @@ -1,760 +0,0 @@ -use std::{collections::HashMap, fmt::Display, str::FromStr}; - -use gpui::{Bounds, Pixels, Point, Size, px}; -use serde::{Deserialize, Serialize}; -use serde_json::json; -use uuid::Uuid; - -use crate::Graph; - -pub const DEFAULT_NODE_WIDTH: Pixels = px(120.0); -pub const DEFAULT_NODE_HEIGHT: Pixels = px(60.0); - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct NodeId(Uuid); - -impl Display for NodeId { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.0) - } -} - -impl Default for NodeId { - fn default() -> Self { - Self::new() - } -} - -impl NodeId { - pub fn new() -> Self { - Self(Uuid::new_v4()) - } - pub fn from_string(s: impl Into) -> Option { - let string = s.into(); - Uuid::from_str(&string).ok().map(Self) - } - pub fn from_uuid(uuid: Uuid) -> Self { - Self(uuid) - } - - pub fn as_uuid(&self) -> &Uuid { - &self.0 - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Node { - // Transitional API: these fields stay public for compatibility in this release. - // Prefer using methods on `Node`; fields will become private in a future release. - #[deprecated(note = "Use `Node::id()` instead; fields will be private in next release.")] - pub id: NodeId, - #[deprecated( - note = "Use `Node::renderer_key()` / `Node::set_renderer_key()` instead; fields will be private in next release." - )] - pub node_type: String, - #[deprecated( - note = "Use `Node::execute_type_ref()` / `Node::set_execute_type()` instead; fields will be private in next release." - )] - pub execute_type: String, - #[deprecated( - note = "Use `Node::position()` / `Node::set_position()` instead; fields will be private in next release." - )] - pub x: Pixels, - #[deprecated( - note = "Use `Node::position()` / `Node::set_position()` instead; fields will be private in next release." - )] - pub y: Pixels, - #[deprecated( - note = "Use `Node::size_ref()` / `Node::set_size_mut()` instead; fields will be private in next release." - )] - pub size: Size, - - #[deprecated( - note = "Use `Node::inputs()` / `Node::push_input()` instead; fields will be private in next release." - )] - pub inputs: Vec, - #[deprecated( - note = "Use `Node::outputs()` / `Node::push_output()` instead; fields will be private in next release." - )] - pub outputs: Vec, - #[deprecated( - note = "Use `Node::data_ref()` / `Node::data_mut()` / `Node::set_data()` instead; fields will be private in next release." - )] - pub data: serde_json::Value, -} - -impl Node { - // Transitional period: `Node` fields are deprecated for external callers, - // but internal constructors/methods still need to read/write those fields. - #[allow(deprecated)] - pub fn new(x: f32, y: f32) -> Self { - Self { - id: NodeId::new(), - node_type: String::new(), - execute_type: String::new(), - x: x.into(), - y: y.into(), - size: Size { - width: DEFAULT_NODE_WIDTH, - height: DEFAULT_NODE_HEIGHT, - }, - inputs: vec![], - outputs: vec![], - data: json!({}), - } - } - - #[allow(deprecated)] - pub fn id(&self) -> NodeId { - self.id - } - - #[allow(deprecated)] - pub(crate) fn set_id(&mut self, id: NodeId) { - self.id = id; - } - - #[allow(deprecated)] - pub fn renderer_key(&self) -> &str { - &self.node_type - } - - #[allow(deprecated)] - pub fn execute_type_ref(&self) -> &str { - &self.execute_type - } - - #[allow(deprecated)] - pub fn set_renderer_key(&mut self, node_type: impl Into) { - self.node_type = node_type.into(); - } - - #[allow(deprecated)] - pub fn set_execute_type(&mut self, execute_type: impl Into) { - self.execute_type = execute_type.into(); - } - - #[allow(deprecated)] - pub fn position(&self) -> (Pixels, Pixels) { - (self.x, self.y) - } - - #[allow(deprecated)] - pub fn position_point(&self) -> Point { - Point::new(self.x, self.y) - } - - #[allow(deprecated)] - pub fn size_ref(&self) -> &Size { - &self.size - } - - #[allow(deprecated)] - pub fn inputs(&self) -> &[PortId] { - &self.inputs - } - - #[allow(deprecated)] - pub fn outputs(&self) -> &[PortId] { - &self.outputs - } - - #[allow(deprecated)] - pub fn data_ref(&self) -> &serde_json::Value { - &self.data - } - - #[allow(deprecated)] - pub fn data_mut(&mut self) -> &mut serde_json::Value { - &mut self.data - } - - #[allow(deprecated)] - pub fn set_position(&mut self, x: Pixels, y: Pixels) { - self.x = x; - self.y = y; - } - - #[allow(deprecated)] - pub fn set_position_with_point(&mut self, point: Point) { - self.x = point.x; - self.y = point.y; - } - - #[allow(deprecated)] - pub fn set_size_mut(&mut self, size: Size) { - self.size = size; - } - - #[allow(deprecated)] - pub fn set_size_width(&mut self, width: Pixels) { - self.size.width = width; - } - - #[allow(deprecated)] - pub fn set_size_height(&mut self, height: Pixels) { - self.size.height = height; - } - - #[allow(deprecated)] - pub fn set_data(&mut self, data: serde_json::Value) { - self.data = data; - } - - #[allow(deprecated)] - pub fn push_input(&mut self, id: PortId) { - self.inputs.push(id); - } - - #[allow(deprecated)] - pub fn push_output(&mut self, id: PortId) { - self.outputs.push(id); - } - - #[allow(deprecated)] - pub fn point(&self) -> Point { - Point::new(self.x, self.y) - } - - #[allow(deprecated)] - pub fn bounds(&self) -> Bounds { - Bounds::new(self.point(), self.size) - } - - #[allow(deprecated)] - pub fn set_size(mut self, size: Size) -> Self { - self.size = size; - self - } - - #[allow(deprecated)] - pub fn output(mut self, id: PortId) -> Self { - self.outputs.push(id); - self - } - - #[allow(deprecated)] - pub fn input(mut self, id: PortId) -> Self { - self.inputs.push(id); - self - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct PortId(Uuid); - -impl Display for PortId { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.0) - } -} - -impl Default for PortId { - fn default() -> Self { - Self::new() - } -} - -impl PortId { - pub fn new() -> Self { - Self(Uuid::new_v4()) - } - pub fn from_string(s: impl Into) -> Option { - let string = s.into(); - Uuid::from_str(&string).ok().map(Self) - } - pub fn from_uuid(uuid: Uuid) -> Self { - Self(uuid) - } - - pub fn as_uuid(&self) -> &Uuid { - &self.0 - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum PortKind { - Input, - Output, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)] -pub enum PortPosition { - Left, - Right, - Top, - Bottom, -} - -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub enum PortType { - Any, - Bool, - Int, - Float, - String, - List(Box), - Map(Box, Box), - Custom(String), - Union(Vec), -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Port { - // Transitional API: these fields stay public for compatibility in this release. - // Prefer using methods on `Port`; fields will become private in a future release. - #[deprecated(note = "Use `Port::id()` instead; fields will be private in next release.")] - pub id: PortId, - #[deprecated(note = "Use `Port::kind()` instead; fields will be private in next release.")] - pub kind: PortKind, - #[deprecated( - note = "Use `Port::index()` / `Port::set_index()` instead; fields will be private in next release." - )] - pub index: usize, - #[deprecated(note = "Use `Port::node_id()` instead; fields will be private in next release.")] - pub node_id: NodeId, - #[deprecated( - note = "Use `Port::position()` / `Port::set_position()` instead; fields will be private in next release." - )] - pub position: PortPosition, - #[deprecated( - note = "Use `Port::size_ref()` / `Port::set_size()` instead; fields will be private in next release." - )] - pub size: Size, - #[deprecated( - note = "Use `Port::port_type_ref()` / `Port::port_type_mut()` instead; fields will be private in next release." - )] - pub port_type: PortType, -} - -impl Port { - #[allow(clippy::too_many_arguments)] - #[allow(deprecated)] - pub fn new( - id: PortId, - kind: PortKind, - index: usize, - node_id: NodeId, - position: PortPosition, - size: Size, - port_type: PortType, - ) -> Self { - Self { - id, - kind, - index, - node_id, - position, - size, - port_type, - } - } - - #[allow(deprecated)] - pub fn id(&self) -> PortId { - self.id - } - - #[allow(deprecated)] - pub fn kind(&self) -> PortKind { - self.kind - } - - #[allow(deprecated)] - pub fn index(&self) -> usize { - self.index - } - - #[allow(deprecated)] - pub fn node_id(&self) -> NodeId { - self.node_id - } - - #[allow(deprecated)] - pub fn position(&self) -> PortPosition { - self.position - } - - #[allow(deprecated)] - pub fn size_ref(&self) -> &Size { - &self.size - } - - #[allow(deprecated)] - pub fn port_type_ref(&self) -> &PortType { - &self.port_type - } - - #[allow(deprecated)] - pub fn port_type_mut(&mut self) -> &mut PortType { - &mut self.port_type - } - - #[allow(deprecated)] - pub fn set_size(&mut self, size: Size) { - self.size = size; - } - - #[allow(deprecated)] - pub fn set_index(&mut self, index: usize) { - self.index = index; - } - - #[allow(deprecated)] - pub fn set_position(&mut self, position: PortPosition) { - self.position = position; - } -} - -impl Display for PortKind { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - PortKind::Input => write!(f, "input"), - PortKind::Output => write!(f, "output"), - } - } -} - -impl Display for PortPosition { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - PortPosition::Left => write!(f, "left"), - PortPosition::Right => write!(f, "right"), - PortPosition::Top => write!(f, "top"), - PortPosition::Bottom => write!(f, "bottom"), - } - } -} - -impl FromStr for PortPosition { - type Err = anyhow::Error; - - fn from_str(s: &str) -> Result { - match s { - "right" => Ok(Self::Right), - "top" => Ok(Self::Top), - "bottom" => Ok(Self::Bottom), - "left" => Ok(Self::Left), - _ => Err(anyhow::anyhow!("Invalid port position: {}", s)), - } - } -} - -pub struct NodeBuilder<'a> { - graph: Option<&'a mut Graph>, - node_type: String, - execute_type: String, - x: Pixels, - y: Pixels, - size: Size, - inputs: Vec, - outputs: Vec, - data: serde_json::Value, -} - -#[derive(Clone)] -pub struct PortSpec { - position: PortPosition, - size: Size, - port_type: PortType, -} - -impl PortSpec { - pub fn input(position: PortPosition) -> Self { - Self { - position, - size: DEFAULT_PORT_SIZE, - port_type: PortType::Any, - } - } - - pub fn output(position: PortPosition) -> Self { - Self { - position, - size: DEFAULT_PORT_SIZE, - port_type: PortType::Any, - } - } - - pub fn with_size(mut self, size: Size) -> Self { - self.size = size; - self - } - - pub fn with_type(mut self, port_type: PortType) -> Self { - self.port_type = port_type; - self - } -} - -const DEFAULT_PORT_SIZE: Size = Size { - width: px(12.0), - height: px(12.0), -}; - -pub struct PortBuilder { - id: PortId, - kind: PortKind, - index: usize, - node_id: NodeId, - position: PortPosition, - size: Size, - port_type: PortType, -} - -impl PortBuilder { - pub fn new(id: PortId) -> Self { - Self { - id, - kind: PortKind::Input, - index: 0, - node_id: NodeId::from_uuid(Uuid::nil()), - position: PortPosition::Left, - size: DEFAULT_PORT_SIZE, - port_type: PortType::Any, - } - } - - pub fn kind(mut self, kind: PortKind) -> Self { - self.kind = kind; - self - } - - pub fn node_id(mut self, node_id: NodeId) -> Self { - self.node_id = node_id; - self - } - - pub fn index(mut self, index: usize) -> Self { - self.index = index; - self - } - - pub fn position(mut self, position: PortPosition) -> Self { - self.position = position; - self - } - - pub fn size(mut self, width: f32, height: f32) -> Self { - self.size = Size::new(px(width), px(height)); - self - } - - pub fn port_type(mut self, port_type: PortType) -> Self { - self.port_type = port_type; - self - } - - pub fn build(self) -> Port { - Port::new( - self.id, - self.kind, - self.index, - self.node_id, - self.position, - self.size, - self.port_type, - ) - } -} - -impl<'a> NodeBuilder<'a> { - pub fn new(renderer_key: impl Into) -> NodeBuilder<'static> { - NodeBuilder { - graph: None, - node_type: renderer_key.into(), - execute_type: String::new(), - x: px(0.0), - y: px(0.0), - size: Size { - width: DEFAULT_NODE_WIDTH, - height: DEFAULT_NODE_HEIGHT, - }, - inputs: vec![], - outputs: vec![], - data: json!({}), - } - } - - pub fn graph(mut self, graph: &'a mut Graph) -> NodeBuilder<'a> { - self.graph = Some(graph); - self - } - - pub fn execute_type(mut self, execute_type: impl Into) -> Self { - self.execute_type = execute_type.into(); - self - } - - pub fn position(mut self, x: f32, y: f32) -> Self { - self.x = x.into(); - self.y = y.into(); - self - } - - pub fn size(mut self, w: f32, h: f32) -> Self { - self.size = Size { - width: w.into(), - height: h.into(), - }; - self - } - - fn push_input_spec(&mut self, spec: PortSpec) { - self.inputs.push(spec); - } - - fn push_output_spec(&mut self, spec: PortSpec) { - self.outputs.push(spec); - } - - pub fn input(mut self) -> Self { - self.push_input_spec(PortSpec::input(PortPosition::Left)); - self - } - - pub fn output(mut self) -> Self { - self.push_output_spec(PortSpec::output(PortPosition::Right)); - self - } - - pub fn input_at(mut self, pos: PortPosition) -> Self { - self.push_input_spec(PortSpec::input(pos)); - self - } - - pub fn output_at(mut self, pos: PortPosition) -> Self { - self.push_output_spec(PortSpec::output(pos)); - self - } - - pub fn input_with(mut self, pos: PortPosition, size: Size) -> Self { - self.push_input_spec(PortSpec::input(pos).with_size(size)); - self - } - - pub fn output_with(mut self, pos: PortPosition, size: Size) -> Self { - self.push_output_spec(PortSpec::output(pos).with_size(size)); - self - } - - pub fn input_port(mut self, spec: PortSpec) -> Self { - self.push_input_spec(spec); - self - } - - pub fn output_port(mut self, spec: PortSpec) -> Self { - self.push_output_spec(spec); - self - } - - pub fn data(mut self, data: serde_json::Value) -> Self { - self.data = data; - self - } - - /// Like [`Self::build_raw`], but uses the given node id and input/output port id lists. - /// Returns an empty port vector: port records are expected to be loaded separately - /// (e.g. from persistence). Any [`PortSpec`]s on this builder are ignored. - #[allow(deprecated)] - pub fn build_raw_with_port_ids( - self, - node_id: NodeId, - input_ids: Vec, - output_ids: Vec, - ) -> Node { - Node { - id: node_id, - node_type: self.node_type, - execute_type: self.execute_type, - x: self.x, - y: self.y, - size: self.size, - inputs: input_ids, - outputs: output_ids, - data: self.data, - } - } - - #[allow(deprecated)] - pub fn build_raw(self) -> (Node, Vec, Option<&'a mut Graph>) { - let node_id = NodeId::new(); - - let mut inputs = Vec::new(); - let mut outputs = Vec::new(); - - let mut input_counters: HashMap = HashMap::new(); - - // Create input ports - let mut ports = vec![]; - for spec in self.inputs { - let port_id = PortId::new(); - - let index = input_counters.entry(spec.position).or_insert(0); - let current_index = *index; - *index += 1; - - ports.push(Port { - id: port_id, - kind: PortKind::Input, - index: current_index, - node_id, - position: spec.position, - size: spec.size, - port_type: spec.port_type, - }); - - inputs.push(port_id); - } - - let mut output_counters: HashMap = HashMap::new(); - - // Create output ports - for spec in self.outputs { - let port_id = PortId::new(); - - let index = output_counters.entry(spec.position).or_insert(0); - let current_index = *index; - *index += 1; - - ports.push(Port { - id: port_id, - kind: PortKind::Output, - index: current_index, - node_id, - position: spec.position, - size: spec.size, - port_type: spec.port_type, - }); - - outputs.push(port_id); - } - - ( - Node { - id: node_id, - node_type: self.node_type, - execute_type: self.execute_type, - x: self.x, - y: self.y, - size: self.size, - inputs, - outputs, - data: self.data, - }, - ports, - self.graph, - ) - } - - pub fn build(self) -> Option { - let (node, ports, graph) = self.build_raw(); - let id = node.id(); - let graph = graph?; - graph.add_node(node); - for port in ports { - graph.add_port(port); - } - Some(id) - } -} diff --git a/crates/ferrum-flow/src/plugin.rs b/crates/ferrum-flow/src/plugin.rs deleted file mode 100644 index af4252083c..0000000000 --- a/crates/ferrum-flow/src/plugin.rs +++ /dev/null @@ -1,1089 +0,0 @@ -use std::{any::Any, collections::HashMap, time::Duration}; - -use gpui::{ - AnyElement, Bounds, Context, Div, ElementId, InteractiveElement as _, KeyDownEvent, KeyUpEvent, - MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Point, ScrollWheelEvent, Size, Stateful, - Styled, Window, div, rgb, -}; - -use crate::{ - Edge, EdgeBuilder, EdgeId, FlowCanvas, FlowTheme, Graph, GraphOp, Node, NodeBuilder, NodeId, - NodeRenderer, Port, PortId, PortPosition, RendererRegistry, SharedState, Viewport, - canvas::{ - Command, CommandContext, HistoryProvider, Interaction, InteractionState, PortLayoutCache, - }, - port_screen::PortScreenFrame, -}; - -mod sync; -mod utils; - -pub use sync::{SyncPlugin, SyncPluginContext}; - -pub use utils::{ - invalidate_port_layout_cache_for_graph_change, is_edge_visible, is_node_visible, - primary_platform_modifier, -}; - -/// Chrome for [`RenderContext::node_card_shell`]. [`NodeCardVariant::Default`] and -/// [`NodeCardVariant::UndefinedType`] read colors from [`RenderContext::theme`]; plugins may change -/// them via [`InitPluginContext::theme`] / [`PluginContext::theme`]. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum NodeCardVariant { - /// Card from [`FlowTheme::node_card_background`] and border from [`FlowTheme::node_card_border`] - /// / [`FlowTheme::node_card_border_selected`] when `selected`. - Default, - /// Card from [`FlowTheme::undefined_node_background`] and [`FlowTheme::undefined_node_border`] - /// (no selection styling). - UndefinedType, - - /// Geometry and border width only; set `.bg` / `.border_color` yourself. - Custom, -} - -pub trait Plugin { - fn name(&self) -> &'static str; - - fn setup(&mut self, _ctx: &mut InitPluginContext) {} - - fn on_event(&mut self, _event: &FlowEvent, _ctx: &mut PluginContext) -> EventResult { - EventResult::Continue - } - - fn render(&mut self, _ctx: &mut RenderContext) -> Option { - None - } - - fn priority(&self) -> i32 { - 0 - } - - fn render_layer(&self) -> RenderLayer { - RenderLayer::Overlay - } -} - -pub struct InitPluginContext<'a, 'b> { - graph: &'a mut Graph, - port_offset_cache: &'a mut PortLayoutCache, - viewport: &'a mut Viewport, - renderers: &'a mut RendererRegistry, - pub gpui_ctx: &'a Context<'b, FlowCanvas>, - /// Drawable size from the `window` passed to [`FlowCanvas::builder`] (`Window::viewport_size` when `build()` runs). - pub drawable_size: Size, - /// Canvas colors and strokes; mutate in [`Plugin::setup`](Plugin::setup) to customize chrome. - pub theme: &'a mut FlowTheme, - /// Plugin-local shared state on the [`FlowCanvas`](FlowCanvas). - pub shared_state: &'a mut SharedState, - // pub notify: &'a mut dyn FnMut(), -} - -impl<'a, 'b> InitPluginContext<'a, 'b> { - #[allow(clippy::too_many_arguments)] - pub(crate) fn new( - graph: &'a mut Graph, - port_offset_cache: &'a mut PortLayoutCache, - viewport: &'a mut Viewport, - renderers: &'a mut RendererRegistry, - gpui_ctx: &'a Context<'b, FlowCanvas>, - drawable_size: Size, - theme: &'a mut FlowTheme, - shared_state: &'a mut SharedState, - ) -> Self { - Self { - graph, - port_offset_cache, - viewport, - renderers, - gpui_ctx, - drawable_size, - theme, - shared_state, - } - } - pub fn create_node(&mut self, node_type: &str) -> NodeBuilder<'_> { - self.graph.create_node(node_type) - } - - pub fn create_edge(&mut self) -> EdgeBuilder<'_> { - self.graph.create_edge() - } - - pub fn next_node_id(&self) -> NodeId { - self.graph.next_node_id() - } - - pub fn next_port_id(&self) -> PortId { - self.graph.next_port_id() - } - - pub fn next_edge_id(&self) -> EdgeId { - self.graph.next_edge_id() - } - - pub fn add_node(&mut self, node: Node) { - self.graph.add_node(node); - } - - pub fn add_port(&mut self, port: Port) { - self.graph.add_port(port); - } - - pub fn get_node(&self, id: &NodeId) -> Option<&Node> { - self.graph.get_node(id) - } - - pub fn get_node_mut(&mut self, id: &NodeId) -> Option<&mut Node> { - self.graph.get_node_mut(id) - } - pub fn remove_node(&mut self, id: &NodeId) { - self.graph.remove_node(id); - } - pub fn nodes(&self) -> &HashMap { - self.graph.nodes() - } - pub fn node_order(&self) -> &Vec { - self.graph.node_order() - } - - pub fn new_edge(&self) -> Edge { - self.graph.new_edge() - } - - pub fn add_edge(&mut self, edge: Edge) { - self.graph.add_edge(edge); - } - - pub fn remove_edge(&mut self, edge_id: &EdgeId) { - self.graph.remove_edge(edge_id); - } - - pub fn add_selected_node(&mut self, id: NodeId, shift: bool) { - self.graph.add_selected_node(id, shift); - } - pub fn clear_selected_node(&mut self) { - self.graph.clear_selected_node(); - } - pub fn remove_selected_node(&mut self) -> bool { - self.graph.remove_selected_node() - } - - pub fn add_selected_edge(&mut self, id: EdgeId, shift: bool) { - self.graph.add_selected_edge(id, shift); - } - pub fn clear_selected_edge(&mut self) { - self.graph.clear_selected_edge(); - } - pub fn remove_selected_edge(&mut self) -> bool { - self.graph.remove_selected_edge() - } - - pub fn selection_bounds(&self) -> Option> { - self.graph.selection_bounds() - } - - pub fn selected_nodes_with_positions(&self) -> HashMap> { - self.graph.selected_nodes_with_positions() - } - - pub fn hit_node(&self, mouse: Point) -> Option { - self.graph.hit_node(mouse, self.viewport) - } - - pub fn bring_node_to_front(&mut self, node_id: NodeId) { - self.graph.bring_node_to_front(node_id); - } - - // ---- Viewport shortcuts ---- - pub fn zoom(&self) -> f32 { - self.viewport.zoom() - } - - pub fn set_zoom(&mut self, zoom: f32) { - self.viewport.set_zoom(zoom); - } - - pub fn zoom_scaled_by(&self, factor: f32) -> f32 { - self.viewport.zoom_scaled_by(factor) - } - - pub fn offset(&self) -> Point { - self.viewport.offset() - } - - pub fn set_offset(&mut self, offset: Point) { - self.viewport.set_offset(offset); - } - - pub fn set_offset_xy(&mut self, x: Pixels, y: Pixels) { - self.viewport.set_offset_xy(x, y); - } - - pub fn translate_offset(&mut self, dx: Pixels, dy: Pixels) { - self.viewport.translate_offset(dx, dy); - } - - pub fn window_bounds(&self) -> Option> { - self.viewport.window_bounds() - } - - pub fn set_window_bounds(&mut self, bounds: Option>) { - self.viewport.set_window_bounds(bounds); - } - - pub fn world_scalar_to_screen(&self, value: f32) -> f32 { - self.viewport.world_scalar_to_screen(value) - } - - pub fn screen_scalar_to_world(&self, value: f32) -> f32 { - self.viewport.screen_scalar_to_world(value) - } - - pub fn world_length_to_screen(&self, value: Pixels) -> Pixels { - self.viewport.world_length_to_screen(value) - } - - pub fn screen_length_to_world(&self, value: Pixels) -> Pixels { - self.viewport.screen_length_to_world(value) - } - - pub fn world_to_screen(&self, p: Point) -> Point { - self.viewport.world_to_screen(p) - } - - pub fn screen_to_world(&self, p: Point) -> Point { - self.viewport.screen_to_world(p) - } - - pub fn edge_control_point( - &self, - source: Point, - position: PortPosition, - ) -> Point { - self.viewport.edge_control_point(source, position) - } - - pub fn is_node_visible(&self, node_id: &NodeId) -> bool { - is_node_visible(self.graph, self.viewport, node_id) - } - pub fn is_node_visible_node(&self, node: &Node) -> bool { - self.viewport.is_node_visible(node) - } - - pub fn is_edge_visible(&self, edge: &Edge) -> bool { - is_edge_visible(self.graph, self.viewport, edge) - } - - pub fn port_offset_cached(&self, node_id: &NodeId, port_id: &PortId) -> Option> { - self.port_offset_cache.get_offset(node_id, port_id) - } - - /// Port center in screen pixels when you already have the owning [`Node`]. - /// *warning*: this is using port offset cache, so it will not be accurate if the port offset is not cached. - pub fn port_screen_center(&self, node: &Node, port_id: PortId) -> Option> { - let node_pos = node.point(); - let offset = self.port_offset_cached(&node.id(), &port_id)?; - Some(self.viewport.world_to_screen(node_pos + offset)) - } - - /// Like [`Self::port_screen_center`], resolving the port from [`Graph::ports`]. - /// *warning*: this is using port offset cache, so it will not be accurate if the port offset is not cached. - pub fn port_screen_center_by_port_id(&self, port_id: PortId) -> Option> { - let port = self.graph.get_port(&port_id)?; - let node = self.get_node(&port.node_id())?; - self.port_screen_center(node, port_id) - } - - /// Full port layout for custom [`NodeRenderer::port_render`]. - /// *warning*: this is using port offset cache, so it will not be accurate if the port offset is not cached. - pub fn port_screen_frame(&self, node: &Node, port: &Port) -> Option { - Some(PortScreenFrame { - center: self.port_screen_center(node, port.id())?, - size: *port.size_ref(), - zoom: self.viewport.zoom(), - port_id: port.id(), - }) - } - - pub fn cache_port_offset_with_node(&mut self, node_ids: &Vec) { - for node_id in node_ids { - self.cache_node_port_offset(node_id); - } - } - - pub fn cache_port_offset_with_edge(&mut self, edge_id: &EdgeId) { - self.port_offset_cache - .ensure_edge_ports(self.graph, self.renderers, edge_id); - } - - pub fn cache_port_offset_with_port(&mut self, port_id: &PortId) { - self.port_offset_cache - .ensure_node_ports_for_port(self.graph, self.renderers, port_id); - } - - fn cache_node_port_offset(&mut self, node_id: &NodeId) { - self.port_offset_cache - .ensure_node_ports(self.graph, self.renderers, node_id); - } -} - -pub struct PluginContext<'a> { - pub graph: &'a mut Graph, - port_offset_cache: &'a mut PortLayoutCache, - viewport: &'a mut Viewport, - pub(crate) interaction: &'a mut InteractionState, - renderers: &'a mut RendererRegistry, - - sync_plugin: &'a mut Option>, - - history: &'a mut dyn HistoryProvider, - /// Canvas theme; change during event handling and call [`PluginContext::notify`] to redraw. - pub theme: &'a mut FlowTheme, - /// Plugin-local shared state on the [`FlowCanvas`](FlowCanvas). - pub shared_state: &'a mut SharedState, - emit: &'a mut dyn FnMut(FlowEvent), - notify: &'a mut dyn FnMut(), - schedule_after: &'a mut dyn FnMut(Duration), -} - -pub enum EventResult { - Continue, - Stop, -} - -impl<'a> PluginContext<'a> { - #[allow(clippy::too_many_arguments)] - pub(crate) fn new( - graph: &'a mut Graph, - port_offset_cache: &'a mut PortLayoutCache, - viewport: &'a mut Viewport, - interaction: &'a mut InteractionState, - renderers: &'a mut RendererRegistry, - sync_plugin: &'a mut Option>, - history: &'a mut dyn HistoryProvider, - theme: &'a mut FlowTheme, - shared_state: &'a mut SharedState, - emit: &'a mut dyn FnMut(FlowEvent), - notify: &'a mut dyn FnMut(), - schedule_after: &'a mut dyn FnMut(Duration), - ) -> Self { - Self { - graph, - port_offset_cache, - viewport, - interaction, - renderers, - sync_plugin, - history, - theme, - shared_state, - emit, - notify, - schedule_after, - } - } - - pub fn start_interaction(&mut self, handler: impl Interaction + 'static) { - self.interaction.handler = Some(Box::new(handler)); - } - - pub fn cancel_interaction(&mut self) { - self.interaction.handler = None; - } - - pub fn has_interaction(&self) -> bool { - self.interaction.handler.is_some() - } - - /// Tell GPUI that this entity has changed and observers of it should be notified. - pub fn notify(&mut self) { - (self.notify)(); - } - - /// Schedule a future canvas refresh after a delay. - pub fn schedule_after(&mut self, delay: Duration) { - (self.schedule_after)(delay); - } - - /// Enqueue a follow-up event for plugins and notify the canvas. If the host registered - /// [`FlowCanvas::set_outbound`](crate::canvas::FlowCanvas::set_outbound) or - /// [`FlowCanvasBuilder::outbound`](crate::canvas::FlowCanvasBuilder::outbound), the same - /// `event` is passed there first (synchronous, read-only). - pub fn emit(&mut self, event: FlowEvent) { - (self.emit)(event); - self.notify(); - } - - pub fn has_sync_plugin(&self) -> bool { - self.sync_plugin.is_some() - } - - pub fn execute_command(&mut self, command: impl Command + 'static) { - let mut ctx = CommandContext::new( - self.graph, - self.port_offset_cache, - self.viewport, - self.renderers, - self.shared_state, - self.notify, - ); - if let Some(sync) = &mut self.sync_plugin { - sync.process_intent(GraphOp::Batch(command.to_ops(&mut ctx))); - - self.notify(); - } else { - self.history.push(Box::new(command), &mut ctx); - - self.notify(); - } - } - - pub fn undo(&mut self) { - if let Some(sync) = &mut self.sync_plugin { - sync.undo(); - } else { - let mut ctx = CommandContext::new( - self.graph, - self.port_offset_cache, - self.viewport, - self.renderers, - self.shared_state, - self.notify, - ); - - self.history.undo(&mut ctx); - - self.notify(); - } - } - - pub fn redo(&mut self) { - if let Some(sync) = &mut self.sync_plugin { - sync.redo(); - } else { - let mut ctx = CommandContext::new( - self.graph, - self.port_offset_cache, - self.viewport, - self.renderers, - self.shared_state, - self.notify, - ); - - self.history.redo(&mut ctx); - - self.notify(); - } - } - - pub fn history_clear(&mut self) { - self.history.clear(); - } - - pub fn create_node(&mut self, node_type: &str) -> NodeBuilder<'_> { - self.graph.create_node(node_type) - } - - pub fn create_edge(&mut self) -> EdgeBuilder<'_> { - self.graph.create_edge() - } - - pub fn next_node_id(&self) -> NodeId { - self.graph.next_node_id() - } - - pub fn next_port_id(&self) -> PortId { - self.graph.next_port_id() - } - - pub fn next_edge_id(&self) -> EdgeId { - self.graph.next_edge_id() - } - - pub fn add_node(&mut self, node: Node) { - self.graph.add_node(node); - } - - pub fn add_port(&mut self, port: Port) { - self.graph.add_port(port); - } - - pub fn get_node(&self, id: &NodeId) -> Option<&Node> { - self.graph.get_node(id) - } - - pub fn get_node_render(&self, id: &NodeId) -> Option<&dyn NodeRenderer> { - let node = self.get_node(id)?; - - Some(self.renderers.get(node.renderer_key())) - } - - /// World-space offset from the node's top-left ([`Node::point`]) to the port anchor used for - /// edge wiring (same as [`NodeRenderer::port_offset`]). - /// - /// `graph` must contain `node` and that node's ports (a scratch graph is fine) so multi-port - /// spacing matches runtime layout. - pub(crate) fn port_world_offset_relative( - &self, - graph: &Graph, - node: &Node, - port: &Port, - ) -> Point { - self.renderers - .get(node.renderer_key()) - .port_offset(node, port, graph) - } - - pub fn get_node_mut(&mut self, id: &NodeId) -> Option<&mut Node> { - self.graph.get_node_mut(id) - } - pub fn remove_node(&mut self, id: &NodeId) { - self.graph.remove_node(id); - self.port_offset_cache.clear_node(id); - } - pub fn nodes(&self) -> &HashMap { - self.graph.nodes() - } - pub fn node_order(&self) -> &Vec { - self.graph.node_order() - } - - pub fn new_edge(&self) -> Edge { - self.graph.new_edge() - } - - pub fn add_edge(&mut self, edge: Edge) { - self.graph.add_edge(edge); - } - - pub fn remove_edge(&mut self, edge_id: &EdgeId) { - self.graph.remove_edge(edge_id); - } - - pub fn add_selected_node(&mut self, id: NodeId, shift: bool) { - self.graph.add_selected_node(id, shift); - } - pub fn clear_selected_node(&mut self) { - self.graph.clear_selected_node(); - } - pub fn remove_selected_node(&mut self) -> bool { - self.graph.remove_selected_node() - } - - pub fn add_selected_edge(&mut self, id: EdgeId, shift: bool) { - self.graph.add_selected_edge(id, shift); - } - pub fn clear_selected_edge(&mut self) { - self.graph.clear_selected_edge(); - } - pub fn remove_selected_edge(&mut self) -> bool { - self.graph.remove_selected_edge() - } - - pub fn selection_bounds(&self) -> Option> { - self.graph.selection_bounds() - } - - pub fn selected_nodes_with_positions(&self) -> HashMap> { - self.graph.selected_nodes_with_positions() - } - - pub fn hit_node(&self, mouse: Point) -> Option { - self.graph.hit_node(mouse, self.viewport) - } - - pub fn bring_node_to_front(&mut self, node_id: NodeId) { - self.graph.bring_node_to_front(node_id); - } - - // ---- Viewport shortcuts ---- - pub fn zoom(&self) -> f32 { - self.viewport.zoom() - } - - pub fn set_zoom(&mut self, zoom: f32) { - self.viewport.set_zoom(zoom); - } - - pub fn zoom_scaled_by(&self, factor: f32) -> f32 { - self.viewport.zoom_scaled_by(factor) - } - - pub fn offset(&self) -> Point { - self.viewport.offset() - } - - pub fn set_offset(&mut self, offset: Point) { - self.viewport.set_offset(offset); - } - - pub fn set_offset_xy(&mut self, x: Pixels, y: Pixels) { - self.viewport.set_offset_xy(x, y); - } - - pub fn translate_offset(&mut self, dx: Pixels, dy: Pixels) { - self.viewport.translate_offset(dx, dy); - } - - pub fn window_bounds(&self) -> Option> { - self.viewport.window_bounds() - } - - pub fn set_window_bounds(&mut self, bounds: Option>) { - self.viewport.set_window_bounds(bounds); - } - - pub fn world_scalar_to_screen(&self, value: f32) -> f32 { - self.viewport.world_scalar_to_screen(value) - } - - pub fn screen_scalar_to_world(&self, value: f32) -> f32 { - self.viewport.screen_scalar_to_world(value) - } - - pub fn world_length_to_screen(&self, value: Pixels) -> Pixels { - self.viewport.world_length_to_screen(value) - } - - pub fn screen_length_to_world(&self, value: Pixels) -> Pixels { - self.viewport.screen_length_to_world(value) - } - - pub fn world_to_screen(&self, p: Point) -> Point { - self.viewport.world_to_screen(p) - } - - pub fn screen_to_world(&self, p: Point) -> Point { - self.viewport.screen_to_world(p) - } - - pub fn edge_control_point( - &self, - source: Point, - position: PortPosition, - ) -> Point { - self.viewport.edge_control_point(source, position) - } - - pub fn is_node_visible(&self, node_id: &NodeId) -> bool { - is_node_visible(self.graph, self.viewport, node_id) - } - pub fn is_node_visible_node(&self, node: &Node) -> bool { - self.viewport.is_node_visible(node) - } - - pub fn is_edge_visible(&self, edge: &Edge) -> bool { - is_edge_visible(self.graph, self.viewport, edge) - } - - pub fn port_offset_cached(&self, node_id: &NodeId, port_id: &PortId) -> Option> { - self.port_offset_cache.get_offset(node_id, port_id) - } - - pub fn port_offset_cache_clear_all(&mut self) { - self.port_offset_cache.clear_all(); - } - - /// Port center in screen pixels when you already have the owning [`Node`]. - /// *warning*: this is using port offset cache, so it will not be accurate if the port offset is not cached. - pub fn port_screen_center(&self, node: &Node, port_id: PortId) -> Option> { - let node_pos = node.point(); - let offset = self.port_offset_cached(&node.id(), &port_id)?; - Some(self.viewport.world_to_screen(node_pos + offset)) - } - - /// Like [`Self::port_screen_center`], resolving the port from [`Graph::ports`]. - /// *warning*: this is using port offset cache, so it will not be accurate if the port offset is not cached. - pub fn port_screen_center_by_port_id(&self, port_id: PortId) -> Option> { - let port = self.graph.get_port(&port_id)?; - let node = self.get_node(&port.node_id())?; - self.port_screen_center(node, port_id) - } - - /// Full port layout for custom [`NodeRenderer::port_render`]. - /// *warning*: this is using port offset cache, so it will not be accurate if the port offset is not cached. - pub fn port_screen_frame(&self, node: &Node, port: &Port) -> Option { - Some(PortScreenFrame { - center: self.port_screen_center(node, port.id())?, - size: *port.size_ref(), - zoom: self.viewport.zoom(), - port_id: port.id(), - }) - } - - pub fn cache_all_node_port_offset(&mut self) { - self.port_offset_cache - .ensure_all_nodes_ports(self.graph, self.renderers); - } - - pub fn cache_port_offset_with_node(&mut self, node_ids: &Vec) { - for node_id in node_ids { - self.cache_node_port_offset(node_id); - } - } - - pub fn cache_port_offset_with_edge(&mut self, edge_id: &EdgeId) { - self.port_offset_cache - .ensure_edge_ports(self.graph, self.renderers, edge_id); - } - - pub fn cache_port_offset_with_port(&mut self, port_id: &PortId) { - self.port_offset_cache - .ensure_node_ports_for_port(self.graph, self.renderers, port_id); - } - - fn cache_node_port_offset(&mut self, node_id: &NodeId) { - self.port_offset_cache - .ensure_node_ports(self.graph, self.renderers, node_id); - } -} - -pub enum FlowEvent { - Input(InputEvent), - Custom(Box), -} - -impl FlowEvent { - pub fn custom(event: T) -> Self { - FlowEvent::Custom(Box::new(event)) - } - pub fn as_custom(&self) -> Option<&T> { - match self { - FlowEvent::Custom(e) => e.downcast_ref::(), - _ => None, - } - } -} - -pub enum InputEvent { - KeyDown(KeyDownEvent), - KeyUp(KeyUpEvent), - - MouseDown(MouseDownEvent), - MouseMove(MouseMoveEvent), - MouseUp(MouseUpEvent), - - Wheel(ScrollWheelEvent), - - Hover(bool), -} - -pub struct RenderContext<'a> { - pub graph: &'a Graph, - port_offset_cache: &'a mut PortLayoutCache, - viewport: &'a Viewport, - pub renderers: &'a RendererRegistry, - - pub window: &'a Window, - /// Active canvas theme (from [`FlowCanvas::theme`](crate::canvas::FlowCanvas::theme)). - pub theme: &'a FlowTheme, - /// Read-only shared plugin state on the [`FlowCanvas`](FlowCanvas). - shared_state: &'a SharedState, -} - -impl<'a> RenderContext<'a> { - pub(crate) fn new( - graph: &'a Graph, - port_offset_cache: &'a mut PortLayoutCache, - viewport: &'a Viewport, - renderers: &'a RendererRegistry, - window: &'a Window, - theme: &'a FlowTheme, - shared_state: &'a SharedState, - ) -> Self { - Self { - graph, - port_offset_cache, - viewport, - renderers, - window, - theme, - shared_state, - } - } - - /// Detached builder (no graph); use [`PluginContext::create_node`] or [`Graph::create_node`] to commit. - pub fn create_node(&self, renderer_key: &str) -> NodeBuilder<'_> { - NodeBuilder::new(renderer_key) - } - - pub fn next_node_id(&self) -> NodeId { - self.graph.next_node_id() - } - - pub fn next_port_id(&self) -> PortId { - self.graph.next_port_id() - } - - pub fn next_edge_id(&self) -> EdgeId { - self.graph.next_edge_id() - } - - pub fn get_node(&self, id: &NodeId) -> Option<&Node> { - self.graph.get_node(id) - } - - pub fn get_node_render(&self, id: &NodeId) -> Option<&dyn NodeRenderer> { - let node = self.get_node(id)?; - - Some(self.renderers.get(node.renderer_key())) - } - - pub fn nodes(&self) -> &HashMap { - self.graph.nodes() - } - pub fn node_order(&self) -> &Vec { - self.graph.node_order() - } - - pub fn new_edge(&self) -> Edge { - self.graph.new_edge() - } - - pub fn selection_bounds(&self) -> Option> { - self.graph.selection_bounds() - } - - pub fn selected_nodes_with_positions(&self) -> HashMap> { - self.graph.selected_nodes_with_positions() - } - - pub fn hit_node(&self, mouse: Point) -> Option { - self.graph.hit_node(mouse, self.viewport) - } - - // ---- Viewport shortcuts ---- - - pub fn viewport(&self) -> &Viewport { - self.viewport - } - - pub fn zoom(&self) -> f32 { - self.viewport.zoom() - } - - pub fn zoom_scaled_by(&self, factor: f32) -> f32 { - self.viewport.zoom_scaled_by(factor) - } - - pub fn offset(&self) -> Point { - self.viewport.offset() - } - - pub fn window_bounds(&self) -> Option> { - self.viewport.window_bounds() - } - - pub fn world_scalar_to_screen(&self, value: f32) -> f32 { - self.viewport.world_scalar_to_screen(value) - } - - pub fn screen_scalar_to_world(&self, value: f32) -> f32 { - self.viewport.screen_scalar_to_world(value) - } - - pub fn world_length_to_screen(&self, value: Pixels) -> Pixels { - self.viewport.world_length_to_screen(value) - } - - pub fn screen_length_to_world(&self, value: Pixels) -> Pixels { - self.viewport.screen_length_to_world(value) - } - - pub fn world_to_screen(&self, p: Point) -> Point { - self.viewport.world_to_screen(p) - } - - /// Absolute-positioned node card shell: screen origin, zoom-scaled size. - /// - /// Chain `.child(...)` for the inner body, then `.into_any()` (see [`gpui::Element`]). - pub fn node_card_shell( - &self, - node: &Node, - selected: bool, - variant: NodeCardVariant, - ) -> Stateful
{ - let screen = self.world_to_screen(node.point()); - let z = self.viewport.zoom(); - let base = div() - .id(ElementId::Uuid(*node.id().as_uuid())) - .absolute() - .left(screen.x) - .top(screen.y) - .w(node.size_ref().width * z) - .h(node.size_ref().height * z); - let t = self.theme; - match variant { - NodeCardVariant::Default => { - base.bg(rgb(t.node_card_background)) - .border_color(rgb(if selected { - t.node_card_border_selected - } else { - t.node_card_border - })) - } - NodeCardVariant::UndefinedType => base - .bg(rgb(t.undefined_node_background)) - .border_color(rgb(t.undefined_node_border)), - NodeCardVariant::Custom => base, - } - } - - pub fn screen_to_world(&self, p: Point) -> Point { - self.viewport.screen_to_world(p) - } - - pub fn edge_control_point( - &self, - source: Point, - position: PortPosition, - ) -> Point { - self.viewport.edge_control_point(source, position) - } - - pub fn is_node_visible(&self, node_id: &NodeId) -> bool { - is_node_visible(self.graph, self.viewport, node_id) - } - pub fn is_node_visible_node(&self, node: &Node) -> bool { - self.viewport.is_node_visible(node) - } - - pub fn is_edge_visible(&self, edge: &Edge) -> bool { - is_edge_visible(self.graph, self.viewport, edge) - } - - pub fn port_offset_cached(&self, node_id: &NodeId, port_id: &PortId) -> Option> { - self.port_offset_cache.get_offset(node_id, port_id) - } - - /// Port ids with layout cached for this node (see [`PortLayoutCache::cached_port_ids_for_node`]). - /// - /// Call [`Self::cache_port_offset_with_nodes`] (or other `cache_port_offset_*` helpers) first - /// so the list is complete for rendering. - pub fn cached_port_ids_for_node(&self, node_id: &NodeId) -> impl Iterator + '_ { - self.port_offset_cache.cached_port_ids_for_node(node_id) - } - - /// Port center in screen pixels when you already have the owning [`Node`]. - /// *warning*: this is using port offset cache, so it will not be accurate if the port offset is not cached. - pub fn port_screen_center(&self, node: &Node, port_id: PortId) -> Option> { - let node_pos = node.point(); - let offset = self.port_offset_cached(&node.id(), &port_id)?; - Some(self.viewport.world_to_screen(node_pos + offset)) - } - - /// Like [`Self::port_screen_center`], resolving the port from [`Graph::ports`]. - /// *warning*: this is using port offset cache, so it will not be accurate if the port offset is not cached. - pub fn port_screen_center_by_port_id(&self, port_id: PortId) -> Option> { - let port = self.graph.get_port(&port_id)?; - let node = self.get_node(&port.node_id())?; - self.port_screen_center(node, port_id) - } - - /// Full port layout for custom [`NodeRenderer::port_render`]. - /// *warning*: this is using port offset cache, so it will not be accurate if the port offset is not cached. - pub fn port_screen_frame(&self, node: &Node, port: &Port) -> Option { - Some(PortScreenFrame { - center: self.port_screen_center(node, port.id())?, - size: *port.size_ref(), - zoom: self.viewport.zoom(), - port_id: port.id(), - }) - } - - pub fn cache_all_node_port_offset(&mut self) { - self.port_offset_cache - .ensure_all_nodes_ports(self.graph, self.renderers); - } - - pub fn cache_port_offset_with_nodes(&mut self, node_ids: &[NodeId]) { - for node_id in node_ids { - self.cache_node_port_offset(node_id); - } - } - - pub fn cache_port_offset_with_edge(&mut self, edge_id: &EdgeId) { - self.port_offset_cache - .ensure_edge_ports(self.graph, self.renderers, edge_id); - } - - pub fn cache_port_offset_with_port(&mut self, port_id: &PortId) { - self.port_offset_cache - .ensure_node_ports_for_port(self.graph, self.renderers, port_id); - } - - fn cache_node_port_offset(&mut self, node_id: &NodeId) { - self.port_offset_cache - .ensure_node_ports(self.graph, self.renderers, node_id); - } - - pub fn get_shared_state(&self) -> Option<&T> { - self.shared_state.get::() - } - - pub fn contains_shared_state(&self) -> bool { - self.shared_state.contains::() - } -} - -#[derive(Debug, PartialEq, Eq, Clone, Copy)] -pub enum RenderLayer { - Background, - Edges, - Nodes, - Selection, - Interaction, - Overlay, -} - -impl RenderLayer { - pub const ALL: [RenderLayer; 6] = [ - RenderLayer::Background, - RenderLayer::Edges, - RenderLayer::Nodes, - RenderLayer::Selection, - RenderLayer::Interaction, - RenderLayer::Overlay, - ]; - pub fn index(self) -> usize { - match self { - RenderLayer::Background => 0, - RenderLayer::Edges => 1, - RenderLayer::Nodes => 2, - RenderLayer::Selection => 3, - RenderLayer::Interaction => 4, - RenderLayer::Overlay => 5, - } - } -} - -pub struct PluginRegistry { - plugins: Vec>, -} - -impl PluginRegistry { - pub(crate) fn new() -> Self { - Self { plugins: vec![] } - } - - pub fn add(mut self, plugin: impl Plugin + 'static) -> Self { - self.plugins.push(Box::new(plugin)); - self - } - - pub fn extend_boxed(&mut self, plugins: impl IntoIterator>) { - self.plugins.extend(plugins); - } - - pub fn sort_by_priority_desc(&mut self) { - self.plugins.sort_by_key(|p| -p.priority()); - } - - pub fn iter_mut(&mut self) -> std::slice::IterMut<'_, Box> { - self.plugins.iter_mut() - } - - pub fn iter(&self) -> std::slice::Iter<'_, Box> { - self.plugins.iter() - } -} diff --git a/crates/ferrum-flow/src/plugin/sync.rs b/crates/ferrum-flow/src/plugin/sync.rs deleted file mode 100644 index 4d455bbdd5..0000000000 --- a/crates/ferrum-flow/src/plugin/sync.rs +++ /dev/null @@ -1,88 +0,0 @@ -//! Collaboration / replication hooks for the canvas graph. -//! -//! A [`SyncPlugin`] sits **beside** the local [`crate::Graph`]: the canvas still applies -//! [`crate::GraphOp`] through commands and history, while the plugin mirrors those intents into a -//! shared model (CRDT, document store, network sync, etc.) and pushes updates back through -//! [`GraphChange`] so the UI stays consistent with peers and with undo/redo semantics. -//! -//! Implementations typically: -//! - In [`SyncPlugin::setup`], subscribe to the shared model and forward diffs on -//! [`UnboundedSender`], setting [`crate::ChangeSource`] (`Local`, `Remote`, `Undo`, -//! …) so the host can tell operator-driven edits from replay or remote merges. -//! - In [`SyncPlugin::process_intent`], apply each [`GraphOp`] produced locally (after a command -//! runs or history replays) into that model, using whatever metadata your stack needs to avoid -//! mis-classifying those writes when your subscription fires again. -//! - In [`SyncPlugin::undo`] / [`SyncPlugin::redo`], advance **your** backend undo manager if the -//! sync layer owns a stack separate from the canvas history. -//! -//! The concrete backend (Yjs, operational transform, file append, etc.) is up to the plugin; this -//! trait only defines the integration surface with the canvas. - -use futures::channel::mpsc::UnboundedSender; -use gpui::{AnyElement, Pixels, Point}; - -use crate::{FlowEvent, GraphChange, GraphOp, RenderContext, Viewport}; - -/// Bridges local graph edits to a replicated or external graph model, and streams model changes -/// back into the canvas. -/// -/// **Data flow (intended pattern)** -/// 1. User action → canvas runs a command → [`GraphOp`]s are applied to the local graph. -/// 2. The host forwards those ops to [`SyncPlugin::process_intent`] so the plugin updates its -/// shared state. -/// 3. Shared state emits updates (local echo, remote peer, or undo replay) → plugin sends -/// [`GraphChange`] on the channel passed to [`SyncPlugin::setup`]. -/// 4. Canvas applies those changes and refreshes; [`GraphChange::source`] distinguishes how each -/// change should be treated (e.g. skip re-broadcasting remote edits). -/// -/// Keep [`process_intent`](SyncPlugin::process_intent) idempotent with respect to your own -/// observers where possible: the same logical op may be reflected back through your subscription; -/// tagging “local intent” vs “remote” vs “undo” origins is the usual way to stay consistent. -pub trait SyncPlugin { - fn name(&self) -> &'static str; - - /// One-time wiring: subscribe to the shared model, retain subscriptions for the plugin - /// lifetime, and send [`GraphChange`] values on `change_sender` whenever the model moves. - /// - /// The host owns the receiver; do not block the UI thread on long-running I/O—spawn a task or - /// use non-blocking channels as appropriate. - fn setup(&mut self, change_sender: UnboundedSender); - - /// Apply a single local [`GraphOp`] (or a batch already decomposed by the host) into your - /// backend. This is invoked for operator-driven edits after they hit the local graph, not as - /// a replacement for the canvas command pipeline. - fn process_intent(&self, op: GraphOp); - - /// Step the sync-layer undo stack backward, if your backend maintains one in addition to (or - /// instead of) mirroring canvas history. - fn undo(&mut self); - /// Step the sync-layer undo stack forward. - fn redo(&mut self); - - /// Optional: handle canvas [`FlowEvent`]s for awareness, presence, or other non-[`GraphOp`] - /// signals. Use [`SyncPluginContext`] for coordinate transforms when needed. - fn on_event(&mut self, _event: &FlowEvent, _ctx: &mut SyncPluginContext); - - /// Optional overlay (e.g. remote pointers) drawn with normal canvas [`RenderContext`]. - fn render(&mut self, _ctx: &mut RenderContext) -> Vec { - vec![] - } -} - -pub struct SyncPluginContext<'a> { - viewport: &'a Viewport, -} - -impl<'a> SyncPluginContext<'a> { - pub(crate) fn new(viewport: &'a Viewport) -> Self { - Self { viewport } - } - - pub fn screen_to_world(&self, screen: Point) -> Point { - self.viewport.screen_to_world(screen) - } - - pub fn world_to_screen(&self, world: Point) -> Point { - self.viewport.world_to_screen(world) - } -} diff --git a/crates/ferrum-flow/src/plugin/utils.rs b/crates/ferrum-flow/src/plugin/utils.rs deleted file mode 100644 index 73116173b1..0000000000 --- a/crates/ferrum-flow/src/plugin/utils.rs +++ /dev/null @@ -1,85 +0,0 @@ -use gpui::KeyDownEvent; - -use crate::{Edge, Graph, GraphChangeKind, NodeId, Viewport, canvas::PortLayoutCache}; - -/// Clears [`PortLayoutCache`] entries affected by an incoming graph change. Call **before** -/// [`Graph::apply`](crate::graph::Graph::apply) so `PortRemoved` can still resolve `node_id`. -pub fn invalidate_port_layout_cache_for_graph_change( - cache: &mut PortLayoutCache, - graph: &Graph, - kind: &GraphChangeKind, -) { - match kind { - GraphChangeKind::NodeRemoved { id } => cache.clear_node(id), - GraphChangeKind::NodeAdded(node) => cache.clear_node(&node.id()), - GraphChangeKind::NodeSetWidthed { id, .. } - | GraphChangeKind::NodeSetHeighted { id, .. } - | GraphChangeKind::NodeDataUpdated { id, .. } => cache.clear_node(id), - GraphChangeKind::PortAdded(port) => cache.clear_node(&port.node_id()), - GraphChangeKind::PortRemoved { id } => { - if let Some(p) = graph.get_port(id) { - cache.clear_node(&p.node_id()); - } - } - GraphChangeKind::NodeMoved { .. } - | GraphChangeKind::NodeOrderUpdate(_) - | GraphChangeKind::EdgeAdded(_) - | GraphChangeKind::EdgeRemoved { .. } - | GraphChangeKind::RedrawRequested => {} - GraphChangeKind::Batch(changes) => { - for c in changes { - invalidate_port_layout_cache_for_graph_change(cache, graph, c); - } - } - } -} - -/// Primary shortcut modifier: ⌘ on macOS, Ctrl on other platforms. -pub fn primary_platform_modifier(ev: &KeyDownEvent) -> bool { - #[cfg(target_os = "macos")] - { - ev.keystroke.modifiers.platform - } - #[cfg(not(target_os = "macos"))] - { - ev.keystroke.modifiers.control - } -} - -pub fn is_node_visible(graph: &Graph, viewport: &Viewport, node_id: &NodeId) -> bool { - let Some(node) = graph.get_node(node_id) else { - return false; - }; - - viewport.is_node_visible(node) -} - -pub fn is_edge_visible(graph: &Graph, viewport: &Viewport, edge: &Edge) -> bool { - let Edge { - source_port, - target_port, - .. - } = edge; - - let Some(port) = graph.get_port(source_port) else { - return false; - }; - let n1 = port.node_id(); - - let Some(port) = graph.get_port(target_port) else { - return false; - }; - let n2 = port.node_id(); - - let node1_visible = graph - .get_node(&n1) - .map(|n| viewport.is_node_visible(n)) - .unwrap_or(false); - - let node2_visible = graph - .get_node(&n2) - .map(|n| viewport.is_node_visible(n)) - .unwrap_or(false); - - node1_visible || node2_visible -} diff --git a/crates/ferrum-flow/src/plugin_testing.rs b/crates/ferrum-flow/src/plugin_testing.rs deleted file mode 100644 index ae5161217f..0000000000 --- a/crates/ferrum-flow/src/plugin_testing.rs +++ /dev/null @@ -1,142 +0,0 @@ -//! Helpers for testing [`Plugin`](crate::Plugin) implementations. -//! -//! Enable the **`testing`** Cargo feature on `ferrum-flow` to use this module: -//! -//! ```toml -//! ferrum-flow = { version = "…", features = ["testing"] } -//! ``` -//! -//! This harness is intended for plugin unit/integration tests in downstream crates where -//! [`InitPluginContext`], [`PluginContext`] and [`RenderContext`] constructors are intentionally -//! not public. - -use gpui::{AnyElement, Context, Pixels, Size, Window, px}; -use std::time::Duration; - -use crate::{ - EventResult, FlowCanvas, FlowEvent, FlowTheme, Graph, LocalHistory, Plugin, PluginContext, - RenderContext, RendererRegistry, SharedState, SyncPlugin, Viewport, - canvas::{InteractionState, PortLayoutCache}, - plugin::InitPluginContext, -}; - -/// Test harness that can drive plugin `setup`, `on_event`, and `render` with realistic internal -/// contexts. -pub struct PluginTestHarness { - pub graph: Graph, - pub port_offset_cache: PortLayoutCache, - pub viewport: Viewport, - pub interaction: InteractionState, - pub renderers: RendererRegistry, - pub history: LocalHistory, - pub theme: FlowTheme, - pub shared_state: SharedState, - sync_plugin: Option>, - emitted_events: Vec, - notify_count: usize, -} - -impl PluginTestHarness { - pub fn new(graph: Graph) -> Self { - Self { - graph, - port_offset_cache: PortLayoutCache::new(), - viewport: Viewport::new(), - interaction: InteractionState::new(), - renderers: RendererRegistry::new(), - history: LocalHistory::new(), - theme: FlowTheme::default(), - shared_state: SharedState::new(), - sync_plugin: None, - emitted_events: Vec::new(), - notify_count: 0, - } - } - - /// Runs `Plugin::setup`. - /// - /// Call this only in tests that already have a GPUI context/window. - pub fn run_setup<'a, 'b>( - &mut self, - plugin: &mut dyn Plugin, - gpui_ctx: &'a Context<'b, FlowCanvas>, - drawable_size: Size, - ) { - let mut ctx = InitPluginContext::new( - &mut self.graph, - &mut self.port_offset_cache, - &mut self.viewport, - &mut self.renderers, - gpui_ctx, - drawable_size, - &mut self.theme, - &mut self.shared_state, - ); - plugin.setup(&mut ctx); - } - - /// Runs `Plugin::on_event` once and captures emitted events / notify calls. - pub fn run_event(&mut self, plugin: &mut dyn Plugin, event: FlowEvent) -> EventResult { - let emitted_events = &mut self.emitted_events; - let notify_count = &mut self.notify_count; - let mut emit = |e: FlowEvent| { - emitted_events.push(e); - }; - let mut notify = || { - *notify_count += 1; - }; - let mut schedule_after = |_delay: Duration| {}; - let mut ctx = PluginContext::new( - &mut self.graph, - &mut self.port_offset_cache, - &mut self.viewport, - &mut self.interaction, - &mut self.renderers, - &mut self.sync_plugin, - &mut self.history, - &mut self.theme, - &mut self.shared_state, - &mut emit, - &mut notify, - &mut schedule_after, - ); - plugin.on_event(&event, &mut ctx) - } - - /// Runs `Plugin::render` once. - /// - /// Call this only in tests that already have a GPUI window. - pub fn run_render(&mut self, plugin: &mut dyn Plugin, window: &Window) -> Option { - let mut ctx = RenderContext::new( - &mut self.graph, - &mut self.port_offset_cache, - &self.viewport, - &self.renderers, - window, - &self.theme, - &self.shared_state, - ); - plugin.render(&mut ctx) - } - - /// Returns number of times `ctx.notify()` was called during `run_event`. - pub fn notify_count(&self) -> usize { - self.notify_count - } - - /// Drains and returns custom/input events emitted via `ctx.emit(...)`. - pub fn drain_emitted_events(&mut self) -> Vec { - std::mem::take(&mut self.emitted_events) - } -} - -impl Default for PluginTestHarness { - fn default() -> Self { - let mut harness = Self::new(Graph::new()); - harness.viewport.set_window_bounds(Some(gpui::Bounds::new( - gpui::Point::new(px(0.0), px(0.0)), - gpui::Size::new(px(800.0), px(600.0)), - ))); - harness - } -} diff --git a/crates/ferrum-flow/src/plugins/align.rs b/crates/ferrum-flow/src/plugins/align.rs deleted file mode 100644 index 699ce0c38f..0000000000 --- a/crates/ferrum-flow/src/plugins/align.rs +++ /dev/null @@ -1,166 +0,0 @@ -use gpui::{Pixels, Point, px}; - -use crate::{ - NodeId, - plugin::{FlowEvent, Plugin, PluginContext, primary_platform_modifier}, - plugins::node::DragNodesCommand, -}; - -/// Align selected nodes to their shared bounding box (⌘⇧L/R/T/B/H/V or Ctrl⇧…). -pub struct AlignPlugin; - -#[derive(Clone, Copy)] -enum AlignKind { - Left, - Right, - Top, - Bottom, - CenterH, - CenterV, -} - -type NodePositions = Vec<(NodeId, Point)>; -type AlignFromTo = (NodePositions, NodePositions); - -impl AlignPlugin { - pub fn new() -> Self { - Self - } -} - -impl Default for AlignPlugin { - fn default() -> Self { - Self::new() - } -} - -fn align_shortcut(ev: &gpui::KeyDownEvent) -> bool { - primary_platform_modifier(ev) && ev.keystroke.modifiers.shift -} - -fn px_to_f32(p: Pixels) -> f32 { - p.into() -} - -fn f32_neq(a: f32, b: f32) -> bool { - (a - b).abs() > 0.01 -} - -fn selected_nodes_ordered(ctx: &PluginContext) -> Vec { - ctx.graph - .node_order() - .iter() - .filter(|id| ctx.graph.selected_node().contains(id)) - .copied() - .collect() -} - -fn build_aligned_positions(ctx: &PluginContext, kind: AlignKind) -> Option { - let ids = selected_nodes_ordered(ctx); - if ids.len() < 2 { - return None; - } - - let mut min_left = f32::INFINITY; - let mut max_right = f32::NEG_INFINITY; - let mut min_top = f32::INFINITY; - let mut max_bottom = f32::NEG_INFINITY; - - for id in &ids { - let n = ctx.get_node(id)?; - let (nx, ny) = n.position(); - let size = *n.size_ref(); - let x = px_to_f32(nx); - let y = px_to_f32(ny); - let w = px_to_f32(size.width); - let h = px_to_f32(size.height); - min_left = min_left.min(x); - max_right = max_right.max(x + w); - min_top = min_top.min(y); - max_bottom = max_bottom.max(y + h); - } - - let center_x = (min_left + max_right) / 2.0; - let center_y = (min_top + max_bottom) / 2.0; - - let mut from = Vec::with_capacity(ids.len()); - let mut to = Vec::with_capacity(ids.len()); - - for id in ids { - let n = ctx.get_node(&id)?; - let p = n.point(); - from.push((id, p)); - - let (nx, ny) = n.position(); - let size = *n.size_ref(); - let x = px_to_f32(nx); - let y = px_to_f32(ny); - let w = px_to_f32(size.width); - let h = px_to_f32(size.height); - - let (nx, ny) = match kind { - AlignKind::Left => (min_left, y), - AlignKind::Right => (max_right - w, y), - AlignKind::Top => (x, min_top), - AlignKind::Bottom => (x, max_bottom - h), - AlignKind::CenterH => (center_x - w / 2.0, y), - AlignKind::CenterV => (x, center_y - h / 2.0), - }; - to.push((id, Point::new(px(nx), px(ny)))); - } - - let changed = from.iter().zip(to.iter()).any(|((_, pf), (_, pt))| { - f32_neq(px_to_f32(pf.x), px_to_f32(pt.x)) || f32_neq(px_to_f32(pf.y), px_to_f32(pt.y)) - }); - if !changed { - return None; - } - - Some((from, to)) -} - -fn apply_align(ctx: &mut PluginContext, kind: AlignKind) { - let Some((from, to)) = build_aligned_positions(ctx, kind) else { - return; - }; - ctx.execute_command(DragNodesCommand::from_positions(from, to)); - ctx.cache_all_node_port_offset(); -} - -impl Plugin for AlignPlugin { - fn name(&self) -> &'static str { - "align" - } - - fn setup(&mut self, _ctx: &mut crate::plugin::InitPluginContext) {} - - fn priority(&self) -> i32 { - 91 - } - - fn on_event( - &mut self, - event: &FlowEvent, - ctx: &mut PluginContext, - ) -> crate::plugin::EventResult { - if let FlowEvent::Input(crate::plugin::InputEvent::KeyDown(ev)) = event { - if !align_shortcut(ev) { - return crate::plugin::EventResult::Continue; - } - let kind = match ev.keystroke.key.as_str() { - "l" => Some(AlignKind::Left), - "r" => Some(AlignKind::Right), - "t" => Some(AlignKind::Top), - "b" => Some(AlignKind::Bottom), - "h" => Some(AlignKind::CenterH), - "v" => Some(AlignKind::CenterV), - _ => None, - }; - if let Some(kind) = kind { - apply_align(ctx, kind); - return crate::plugin::EventResult::Stop; - } - } - crate::plugin::EventResult::Continue - } -} diff --git a/crates/ferrum-flow/src/plugins/background.rs b/crates/ferrum-flow/src/plugins/background.rs deleted file mode 100644 index 8ae2d002d3..0000000000 --- a/crates/ferrum-flow/src/plugins/background.rs +++ /dev/null @@ -1,206 +0,0 @@ -use crate::plugin::Plugin; -use gpui::{ - Bounds, Corners, Element as _, InteractiveElement as _, ParentElement, RenderImage, Size, - Styled, canvas, div, px, -}; -use image::{Frame, RgbaImage}; -use smallvec::smallvec; -use std::sync::Arc; - -const BASE_GRID: f32 = 40.0; - -#[derive(Clone, Copy, PartialEq)] -struct BitmapKey { - offset_x_mod: i32, // (offset_x % grid * 1000) as i32 - offset_y_mod: i32, - grid_i: i32, // (grid * 1000) as i32 - width: u32, - height: u32, - bg_color: u32, - dot_color: u32, -} - -fn generate_fullscreen_bitmap( - width: u32, - height: u32, - grid: f32, - start_x: f32, - start_y: f32, - bg_color: u32, - dot_color: u32, -) -> Arc { - let w = width as usize; - let h = height as usize; - - let bg = [ - ((bg_color >> 16) & 0xFF) as u8, - ((bg_color >> 8) & 0xFF) as u8, - (bg_color & 0xFF) as u8, - 255u8, - ]; - let dot = [ - ((dot_color >> 16) & 0xFF) as u8, - ((dot_color >> 8) & 0xFF) as u8, - (dot_color & 0xFF) as u8, - 255u8, - ]; - - let mut data = vec![0u8; w * h * 4]; - for i in 0..w * h { - let p = i * 4; - data[p] = bg[0]; - data[p + 1] = bg[1]; - data[p + 2] = bg[2]; - data[p + 3] = 255; - } - - let mut x = start_x; - while x < width as f32 { - let mut y = start_y; - while y < height as f32 { - for dy in 0..2i32 { - for dx in 0..2i32 { - let px = (x - 1.0 + dx as f32).floor() as isize; - let py = (y - 1.0 + dy as f32).floor() as isize; - if px >= 0 && py >= 0 && (px as usize) < w && (py as usize) < h { - let i = ((py as usize) * w + (px as usize)) * 4; - data[i] = dot[0]; - data[i + 1] = dot[1]; - data[i + 2] = dot[2]; - data[i + 3] = 255; - } - } - } - y += grid; - } - x += grid; - } - - for chunk in data.chunks_exact_mut(4) { - chunk.swap(0, 2); - } - - let img = RgbaImage::from_raw(width, height, data).unwrap(); - Arc::new(RenderImage::new(smallvec![Frame::new(img)])) -} - -pub struct BackgroundPlugin { - bitmap_key: Option, - bitmap: Option>, -} - -impl Default for BackgroundPlugin { - fn default() -> Self { - Self::new() - } -} - -impl BackgroundPlugin { - pub fn new() -> Self { - Self { - bitmap_key: None, - bitmap: None, - } - } - - fn sync_bitmap(&mut self, ctx: &crate::plugin::RenderContext) { - let zoom = ctx.zoom(); - let grid = BASE_GRID * zoom; - let offset = ctx.offset(); - let offset_x = f32::from(offset.x); - let offset_y = f32::from(offset.y); - let bounds = ctx.window.bounds(); - let width = f32::from(bounds.size.width) as u32; - let height = f32::from(bounds.size.height) as u32; - - if grid <= 0.0 || width == 0 || height == 0 { - return; - } - - let ox_mod = offset_x % grid; - let oy_mod = offset_y % grid; - - let key = BitmapKey { - offset_x_mod: (ox_mod * 1000.0) as i32, - offset_y_mod: (oy_mod * 1000.0) as i32, - grid_i: (grid * 1000.0) as i32, - width, - height, - bg_color: ctx.theme.background, - dot_color: ctx.theme.background_grid_dot, - }; - - if self.bitmap_key == Some(key) { - return; - } - - self.bitmap_key = Some(key); - self.bitmap = Some(generate_fullscreen_bitmap( - width, - height, - grid, - ox_mod, - oy_mod, - ctx.theme.background, - ctx.theme.background_grid_dot, - )); - } -} - -impl Plugin for BackgroundPlugin { - fn name(&self) -> &'static str { - "background" - } - fn priority(&self) -> i32 { - 0 - } - fn render_layer(&self) -> crate::plugin::RenderLayer { - crate::plugin::RenderLayer::Background - } - - fn render(&mut self, ctx: &mut crate::plugin::RenderContext) -> Option { - self.sync_bitmap(ctx); - - let Some(bitmap) = self.bitmap.as_ref().map(Arc::clone) else { - return Some( - div() - .id("background") - .absolute() - .size_full() - .bg(gpui::rgb(ctx.theme.background)) - .into_any(), - ); - }; - - let bounds = ctx.window.bounds(); - let width = f32::from(bounds.size.width); - let height = f32::from(bounds.size.height); - - let el = canvas( - move |_, _, _| bitmap, - move |bounds, bitmap, window, _cx| { - let _ = window.paint_image( - Bounds { - origin: bounds.origin, - size: Size::new(px(width), px(height)), - }, - Corners::default(), - Arc::clone(&bitmap), - 0, - false, - ); - }, - ) - .absolute() - .size_full(); - - Some( - div() - .id("background") - .absolute() - .size_full() - .child(el) - .into_any(), - ) - } -} diff --git a/crates/ferrum-flow/src/plugins/clipboard/clipboard_ops.rs b/crates/ferrum-flow/src/plugins/clipboard/clipboard_ops.rs deleted file mode 100644 index 6437aa857e..0000000000 --- a/crates/ferrum-flow/src/plugins/clipboard/clipboard_ops.rs +++ /dev/null @@ -1,172 +0,0 @@ -use std::collections::{HashMap, HashSet}; - -use gpui::{Pixels, Point, px}; - -use crate::{CompositeCommand, Edge, Graph, Node, Port, plugin::PluginContext}; - -use super::copied_subgraph::CopiedSubgraph; -use crate::plugins::{CreateEdge, CreateNode, CreatePort}; - -#[derive(Clone)] -pub(crate) struct ClipboardShared(pub CopiedSubgraph); - -pub(crate) fn set_clipboard_subgraph(ctx: &mut PluginContext, sub: CopiedSubgraph) { - ctx.shared_state.insert(ClipboardShared(sub)); -} - -pub(crate) fn get_clipboard_subgraph(ctx: &PluginContext) -> Option { - ctx.shared_state - .get::() - .map(|s| s.0.clone()) -} - -pub(crate) fn has_clipboard_subgraph(ctx: &PluginContext) -> bool { - ctx.shared_state.contains::() -} - -pub(crate) fn extract_subgraph(graph: &Graph) -> Option { - if graph.selected_node_is_empty() { - return None; - } - let node_ids = graph.selected_node(); - if node_ids.is_empty() { - return None; - } - - let mut port_ids = HashSet::new(); - let mut nodes = Vec::with_capacity(node_ids.len()); - let mut ports = Vec::new(); - for nid in node_ids { - let n = graph.get_node(nid)?; - for pid in n.inputs().iter().chain(n.outputs().iter()) { - port_ids.insert(*pid); - if let Some(p) = graph.get_port(pid) { - ports.push(p.clone()); - } - } - nodes.push(n.clone()); - } - - let edges = graph - .edges_values() - .filter(|e| port_ids.contains(&e.source_port) && port_ids.contains(&e.target_port)) - .cloned() - .collect(); - - Some(CopiedSubgraph { - nodes, - ports, - edges, - }) -} - -/// Top-left of the axis-aligned bounding box of copied node positions (world space). -fn subgraph_bounds_top_left(sub: &CopiedSubgraph) -> Point { - let (mut min_x, mut min_y) = (f32::INFINITY, f32::INFINITY); - for n in &sub.nodes { - let (x, y) = n.position(); - min_x = min_x.min(x.into()); - min_y = min_y.min(y.into()); - } - Point::new(px(min_x), px(min_y)) -} - -/// Paste with the subgraph's bounding-box top-left placed at `anchor_world`. -pub(crate) fn paste_subgraph_at_world( - ctx: &mut PluginContext, - sub: &CopiedSubgraph, - anchor_world: Point, -) { - paste_subgraph_with_anchor(ctx, sub, anchor_world); -} - -/// Paste offset from the copied layout (keyboard paste): bbox top-left moves by (40, 40) in world space. -pub(crate) fn paste_subgraph(ctx: &mut PluginContext, sub: &CopiedSubgraph) { - const NUDGE: f32 = 40.0; - let origin = subgraph_bounds_top_left(sub); - let anchor = Point::new(origin.x + px(NUDGE), origin.y + px(NUDGE)); - paste_subgraph_with_anchor(ctx, sub, anchor); -} - -fn paste_subgraph_with_anchor( - ctx: &mut PluginContext, - sub: &CopiedSubgraph, - anchor_world: Point, -) { - if sub.nodes.is_empty() { - return; - } - - let origin = subgraph_bounds_top_left(sub); - let ox: f32 = origin.x.into(); - let oy: f32 = origin.y.into(); - let ax: f32 = anchor_world.x.into(); - let ay: f32 = anchor_world.y.into(); - - let mut node_map = HashMap::new(); - for n in &sub.nodes { - node_map.insert(n.id(), ctx.graph.next_node_id()); - } - let mut port_map = HashMap::new(); - for p in &sub.ports { - port_map.insert(p.id(), ctx.graph.next_port_id()); - } - - let mut composite = CompositeCommand::new(); - - let mut new_node_ids = Vec::new(); - - for old in &sub.nodes { - let new_id = node_map[&old.id()]; - let (x, y) = old.position(); - let nx = ax + f32::from(x) - ox; - let ny = ay + f32::from(y) - oy; - let mut node = Node::new(nx, ny); - node.set_renderer_key(old.renderer_key()); - node.set_execute_type(old.execute_type_ref()); - node.set_size_mut(*old.size_ref()); - node.set_data(old.data_ref().clone()); - node.set_id(new_id); - - for pid in old.inputs() { - node.push_input(port_map[pid]); - } - for pid in old.outputs() { - node.push_output(port_map[pid]); - } - new_node_ids.push(new_id); - composite.push(CreateNode::new(node)); - } - - for old in &sub.ports { - let port = Port::new( - port_map[&old.id()], - old.kind(), - old.index(), - node_map[&old.node_id()], - old.position(), - *old.size_ref(), - old.port_type_ref().clone(), - ); - composite.push(CreatePort::new(port)); - } - - for old in &sub.edges { - let edge = Edge { - id: ctx.graph.next_edge_id(), - source_port: port_map[&old.source_port], - target_port: port_map[&old.target_port], - }; - composite.push(CreateEdge::new(edge)); - } - - let pasted_ids = sub.nodes.iter().map(|n| node_map[&n.id()]); - - ctx.execute_command(composite); - ctx.clear_selected_edge(); - ctx.clear_selected_node(); - for nid in pasted_ids { - ctx.add_selected_node(nid, true); - } - ctx.cache_port_offset_with_node(&new_node_ids); -} diff --git a/crates/ferrum-flow/src/plugins/clipboard/copied_subgraph.rs b/crates/ferrum-flow/src/plugins/clipboard/copied_subgraph.rs deleted file mode 100644 index f0deae69a2..0000000000 --- a/crates/ferrum-flow/src/plugins/clipboard/copied_subgraph.rs +++ /dev/null @@ -1,8 +0,0 @@ -use crate::{Edge, Node, Port}; - -#[derive(Clone)] -pub struct CopiedSubgraph { - pub(crate) nodes: Vec, - pub(crate) ports: Vec, - pub(crate) edges: Vec, -} diff --git a/crates/ferrum-flow/src/plugins/clipboard/mod.rs b/crates/ferrum-flow/src/plugins/clipboard/mod.rs deleted file mode 100644 index a0e9c75c2e..0000000000 --- a/crates/ferrum-flow/src/plugins/clipboard/mod.rs +++ /dev/null @@ -1,10 +0,0 @@ -mod clipboard_ops; -mod copied_subgraph; -mod plugin; - -pub use plugin::ClipboardPlugin; - -pub(crate) use clipboard_ops::{ - extract_subgraph, get_clipboard_subgraph, has_clipboard_subgraph, paste_subgraph_at_world, - set_clipboard_subgraph, -}; diff --git a/crates/ferrum-flow/src/plugins/clipboard/plugin.rs b/crates/ferrum-flow/src/plugins/clipboard/plugin.rs deleted file mode 100644 index 8deb1eb5f9..0000000000 --- a/crates/ferrum-flow/src/plugins/clipboard/plugin.rs +++ /dev/null @@ -1,58 +0,0 @@ -use crate::plugin::{FlowEvent, Plugin, PluginContext, primary_platform_modifier}; - -use super::clipboard_ops::{ - extract_subgraph, get_clipboard_subgraph, paste_subgraph, set_clipboard_subgraph, -}; - -/// Copy / paste selected nodes, their ports, and edges **between** those ports (one undo on paste). -pub struct ClipboardPlugin; - -impl ClipboardPlugin { - pub fn new() -> Self { - Self - } -} - -impl Default for ClipboardPlugin { - fn default() -> Self { - Self::new() - } -} - -impl Plugin for ClipboardPlugin { - fn name(&self) -> &'static str { - "clipboard" - } - - fn priority(&self) -> i32 { - 95 - } - - fn on_event( - &mut self, - event: &FlowEvent, - ctx: &mut PluginContext, - ) -> crate::plugin::EventResult { - if let FlowEvent::Input(crate::plugin::InputEvent::KeyDown(ev)) = event { - if !primary_platform_modifier(ev) { - return crate::plugin::EventResult::Continue; - } - match ev.keystroke.key.as_str() { - "c" => { - if let Some(sub) = extract_subgraph(ctx.graph) { - set_clipboard_subgraph(ctx, sub); - } - return crate::plugin::EventResult::Stop; - } - "v" => { - if let Some(sub) = get_clipboard_subgraph(ctx) { - paste_subgraph(ctx, &sub); - } - return crate::plugin::EventResult::Stop; - } - _ => {} - } - } - crate::plugin::EventResult::Continue - } -} diff --git a/crates/ferrum-flow/src/plugins/context_menu.rs b/crates/ferrum-flow/src/plugins/context_menu.rs deleted file mode 100644 index bc656632f8..0000000000 --- a/crates/ferrum-flow/src/plugins/context_menu.rs +++ /dev/null @@ -1,455 +0,0 @@ -use std::sync::Arc; - -use gpui::{ - IntoElement as _, MouseButton, ParentElement as _, Pixels, Point, SharedString, Styled as _, - div, px, rgb, -}; - -use crate::{ - NodeId, - plugin::{ - EventResult, FlowEvent, InputEvent, Plugin, PluginContext, RenderContext, RenderLayer, - }, -}; - -use super::{ - clipboard::{ - extract_subgraph, get_clipboard_subgraph, has_clipboard_subgraph, paste_subgraph_at_world, - set_clipboard_subgraph, - }, - delete::delete_selection, - fit_all::fit_entire_graph, - focus_selection::focus_viewport_on_selection, - select_all_viewport::select_all_in_viewport, -}; - -const MENU_W: f32 = 228.0; -const ROW_H: f32 = 26.0; -const SEP_H: f32 = 9.0; -const MENU_PAD: f32 = 4.0; - -/// Callback invoked when the user picks a custom canvas menu row (e.g. open an input dialog in the app). -/// -/// The second argument is the **world-space** point under the initial right-click that opened this menu -/// (same as [`PluginContext::screen_to_world`] applied to that click). -type ContextMenuActionFn = dyn for<'a> Fn(&mut PluginContext<'a>, Point) + Send + Sync; - -#[derive(Clone)] -pub struct ContextMenuCustomAction(Arc); - -impl ContextMenuCustomAction { - pub fn new( - f: impl for<'a> Fn(&mut PluginContext<'a>, Point) + Send + Sync + 'static, - ) -> Self { - Self(Arc::new(f)) - } - - fn call(&self, ctx: &mut PluginContext<'_>, menu_world: Point) { - (self.0)(ctx, menu_world); - } -} - -/// One extra row on the **canvas background** context menu (after built-in items). -#[derive(Clone)] -pub struct ContextMenuCanvasExtra { - pub label: SharedString, - pub shortcut: Option, - pub on_select: ContextMenuCustomAction, -} - -impl ContextMenuCanvasExtra { - pub fn new( - label: impl Into, - on_select: impl for<'a> Fn(&mut PluginContext<'a>, Point) + Send + Sync + 'static, - ) -> Self { - Self { - label: label.into(), - shortcut: None, - on_select: ContextMenuCustomAction::new(on_select), - } - } - - pub fn with_shortcut( - label: impl Into, - shortcut: impl Into, - on_select: impl for<'a> Fn(&mut PluginContext<'a>, Point) + Send + Sync + 'static, - ) -> Self { - Self { - label: label.into(), - shortcut: Some(shortcut.into()), - on_select: ContextMenuCustomAction::new(on_select), - } - } -} - -/// Right-click menu on the canvas (empty area) or on a node. Optional [`ContextMenuCanvasExtra`] rows -/// are appended after built-in canvas actions. -pub struct ContextMenuPlugin { - open: Option, - canvas_extras: Vec, -} - -#[derive(Clone, Copy)] -enum MenuBuiltin { - FitAllGraph, - Paste, - SelectAllViewport, - FocusSelection, - Copy, - Delete, - BringToFront(NodeId), -} - -#[derive(Clone)] -enum MenuItem { - Separator, - Builtin(MenuBuiltin), - Custom { - label: SharedString, - shortcut: Option, - action: ContextMenuCustomAction, - }, -} - -#[derive(Clone)] -struct OpenMenu { - anchor: Point, - /// World position of the right-click that opened this menu. - anchor_world: Point, - actions: Vec, -} - -impl Default for ContextMenuPlugin { - fn default() -> Self { - Self::new() - } -} - -impl ContextMenuPlugin { - pub fn new() -> Self { - Self { - open: None, - canvas_extras: Vec::new(), - } - } - - pub fn with_canvas_extras(canvas_extras: Vec) -> Self { - Self { - open: None, - canvas_extras, - } - } - - /// Append a canvas-background row with a custom label (e.g. “Add node…” → show input in meili). - pub fn canvas_row( - mut self, - label: impl Into, - on_select: impl for<'a> Fn(&mut PluginContext<'a>, Point) + Send + Sync + 'static, - ) -> Self { - self.canvas_extras - .push(ContextMenuCanvasExtra::new(label, on_select)); - self - } - - /// Same as [`Self::canvas_row`] but with a shortcut hint string shown on the right. - pub fn canvas_row_with_shortcut( - mut self, - label: impl Into, - shortcut: impl Into, - on_select: impl for<'a> Fn(&mut PluginContext<'a>, Point) + Send + Sync + 'static, - ) -> Self { - self.canvas_extras - .push(ContextMenuCanvasExtra::with_shortcut( - label, shortcut, on_select, - )); - self - } - - fn row_height(action: &MenuItem) -> f32 { - match action { - MenuItem::Separator => SEP_H, - _ => ROW_H, - } - } - - fn content_height(actions: &[MenuItem]) -> f32 { - actions.iter().map(Self::row_height).sum() - } - - fn menu_bounds(anchor: Point, actions: &[MenuItem]) -> gpui::Bounds { - let h = Self::content_height(actions) + MENU_PAD * 2.0; - gpui::Bounds::new(anchor, gpui::Size::new(px(MENU_W), px(h))) - } - - fn label_builtin(b: MenuBuiltin) -> &'static str { - match b { - MenuBuiltin::FitAllGraph => "Fit entire graph", - MenuBuiltin::Paste => "Paste", - MenuBuiltin::SelectAllViewport => "Select all in view", - MenuBuiltin::FocusSelection => "Focus selection", - MenuBuiltin::Copy => "Copy", - MenuBuiltin::Delete => "Delete", - MenuBuiltin::BringToFront(_) => "Bring to front", - } - } - - fn shortcut_hint_builtin(b: MenuBuiltin) -> Option<&'static str> { - #[cfg(target_os = "macos")] - { - match b { - MenuBuiltin::FitAllGraph => Some("⌘0"), - MenuBuiltin::Paste => Some("⌘V"), - MenuBuiltin::SelectAllViewport => Some("⌘A"), - MenuBuiltin::FocusSelection => Some("⌘⇧F"), - MenuBuiltin::Copy => Some("⌘C"), - MenuBuiltin::Delete => Some("⌫"), - MenuBuiltin::BringToFront(_) => None, - } - } - #[cfg(not(target_os = "macos"))] - { - match b { - MenuBuiltin::FitAllGraph => Some("Ctrl+0"), - MenuBuiltin::Paste => Some("Ctrl+V"), - MenuBuiltin::SelectAllViewport => Some("Ctrl+A"), - MenuBuiltin::FocusSelection => Some("Ctrl+Shift+F"), - MenuBuiltin::Copy => Some("Ctrl+C"), - MenuBuiltin::Delete => Some("Del"), - MenuBuiltin::BringToFront(_) => None, - } - } - } - - fn canvas_actions(&self, ctx: &PluginContext) -> Vec { - let mut v = Vec::new(); - v.push(MenuItem::Builtin(MenuBuiltin::FitAllGraph)); - v.push(MenuItem::Separator); - if has_clipboard_subgraph(ctx) { - v.push(MenuItem::Builtin(MenuBuiltin::Paste)); - v.push(MenuItem::Separator); - } - v.push(MenuItem::Builtin(MenuBuiltin::SelectAllViewport)); - v.push(MenuItem::Separator); - v.push(MenuItem::Builtin(MenuBuiltin::FocusSelection)); - for e in &self.canvas_extras { - v.push(MenuItem::Separator); - v.push(MenuItem::Custom { - label: e.label.clone(), - shortcut: e.shortcut.clone(), - action: e.on_select.clone(), - }); - } - v - } - - fn node_actions(nid: NodeId) -> Vec { - vec![ - MenuItem::Builtin(MenuBuiltin::Copy), - MenuItem::Separator, - MenuItem::Builtin(MenuBuiltin::Delete), - MenuItem::Builtin(MenuBuiltin::BringToFront(nid)), - MenuItem::Separator, - MenuItem::Builtin(MenuBuiltin::FocusSelection), - ] - } - - fn run_action(ctx: &mut PluginContext, action: &MenuItem, menu_world: Point) { - match action { - MenuItem::Separator => {} - MenuItem::Builtin(b) => match b { - MenuBuiltin::FitAllGraph => fit_entire_graph(ctx), - MenuBuiltin::Paste => { - if let Some(sub) = get_clipboard_subgraph(ctx) { - paste_subgraph_at_world(ctx, &sub, menu_world); - } - } - MenuBuiltin::SelectAllViewport => select_all_in_viewport(ctx), - MenuBuiltin::FocusSelection => focus_viewport_on_selection(ctx), - MenuBuiltin::Copy => { - if let Some(s) = extract_subgraph(ctx.graph) { - set_clipboard_subgraph(ctx, s); - } - } - MenuBuiltin::Delete => delete_selection(ctx), - MenuBuiltin::BringToFront(id) => ctx.bring_node_to_front(*id), - }, - MenuItem::Custom { action, .. } => action.call(ctx, menu_world), - } - ctx.notify(); - } - - fn row_at_dy(actions: &[MenuItem], dy: f32) -> Option { - if dy < 0.0 { - return None; - } - let mut y = 0.0; - for (i, a) in actions.iter().enumerate() { - let h = Self::row_height(a); - if dy < y + h { - return Some(i); - } - y += h; - } - None - } -} - -impl Plugin for ContextMenuPlugin { - fn name(&self) -> &'static str { - "context_menu" - } - - fn priority(&self) -> i32 { - 132 - } - - fn render_layer(&self) -> RenderLayer { - RenderLayer::Overlay - } - - fn render(&mut self, ctx: &mut RenderContext) -> Option { - let open = self.open.as_ref()?; - let panel_bg = ctx.theme.context_menu_background; - let panel_border = ctx.theme.context_menu_border; - let row_text = ctx.theme.context_menu_text; - let shortcut_text = ctx.theme.context_menu_shortcut_text; - let separator = ctx.theme.context_menu_separator; - - let rows = open.actions.iter().map(|a| match a { - MenuItem::Separator => div() - .w_full() - .h(px(SEP_H)) - .flex() - .items_center() - .px_2() - .child(div().w_full().h(px(1.0)).bg(rgb(separator))), - MenuItem::Builtin(b) => { - let label = div() - .flex_1() - .min_w(px(0.)) - .overflow_hidden() - .text_ellipsis() - .child(ContextMenuPlugin::label_builtin(*b)); - let shortcut = ContextMenuPlugin::shortcut_hint_builtin(*b).map(|h| { - div() - .flex_shrink_0() - .ml_2() - .text_xs() - .text_color(rgb(shortcut_text)) - .child(h) - }); - div() - .w_full() - .h(px(ROW_H)) - .flex() - .flex_row() - .items_center() - .px_2() - .text_sm() - .text_color(rgb(row_text)) - .child(label) - .children(shortcut) - } - MenuItem::Custom { - label, shortcut, .. - } => { - let label_el = div() - .flex_1() - .min_w(px(0.)) - .overflow_hidden() - .text_ellipsis() - .child(label.clone()); - let shortcut_el = shortcut.as_ref().map(|h| { - div() - .flex_shrink_0() - .ml_2() - .text_xs() - .text_color(rgb(shortcut_text)) - .child(h.clone()) - }); - div() - .w_full() - .h(px(ROW_H)) - .flex() - .flex_row() - .items_center() - .px_2() - .text_sm() - .text_color(rgb(row_text)) - .child(label_el) - .children(shortcut_el) - } - }); - - Some( - div() - .absolute() - .left(open.anchor.x) - .top(open.anchor.y) - .w(px(MENU_W)) - .p_1() - .bg(rgb(panel_bg)) - .border_1() - .border_color(rgb(panel_border)) - .rounded(px(6.0)) - .shadow_sm() - .children(rows) - .into_any_element(), - ) - } - - fn on_event( - &mut self, - event: &FlowEvent, - ctx: &mut PluginContext, - ) -> crate::plugin::EventResult { - if let FlowEvent::Input(InputEvent::MouseDown(ev)) = event { - if ev.button == MouseButton::Left { - if let Some(open) = self.open.take() { - let menu_world = open.anchor_world; - let b = Self::menu_bounds(open.anchor, &open.actions); - if b.contains(&ev.position) { - let dy: f32 = (ev.position.y - open.anchor.y).into(); - let inner_y = dy - MENU_PAD; - if let Some(row) = Self::row_at_dy(&open.actions, inner_y) { - let a = &open.actions[row]; - if !matches!(a, MenuItem::Separator) { - Self::run_action(ctx, a, menu_world); - } else { - ctx.notify(); - } - } else { - ctx.notify(); - } - return EventResult::Stop; - } - ctx.notify(); - return EventResult::Continue; - } - return EventResult::Continue; - } - - if ev.button == MouseButton::Right { - let world = ctx.screen_to_world(ev.position); - let actions = if let Some(nid) = ctx.hit_node(world) { - if !ctx.graph.selected_node().contains(&nid) { - ctx.clear_selected_edge(); - ctx.clear_selected_node(); - ctx.add_selected_node(nid, false); - } - Self::node_actions(nid) - } else { - self.canvas_actions(ctx) - }; - self.open = Some(OpenMenu { - anchor: ev.position, - anchor_world: world, - actions, - }); - ctx.notify(); - return EventResult::Stop; - } - } - EventResult::Continue - } -} diff --git a/crates/ferrum-flow/src/plugins/delete.rs b/crates/ferrum-flow/src/plugins/delete.rs deleted file mode 100644 index 1e567d3ec1..0000000000 --- a/crates/ferrum-flow/src/plugins/delete.rs +++ /dev/null @@ -1,278 +0,0 @@ -use crate::{ - Edge, EdgeId, GraphOp, Node, Port, - canvas::Command, - plugin::{FlowEvent, Plugin}, -}; -use std::collections::HashSet; - -pub struct DeletePlugin; - -impl DeletePlugin { - pub fn new() -> Self { - Self {} - } -} - -impl Default for DeletePlugin { - fn default() -> Self { - Self::new() - } -} - -pub(crate) fn delete_selection(ctx: &mut crate::plugin::PluginContext) { - ctx.execute_command(DeleteCommand::new(ctx)); -} - -impl Plugin for DeletePlugin { - fn name(&self) -> &'static str { - "delete" - } - - fn on_event( - &mut self, - event: &FlowEvent, - ctx: &mut crate::plugin::PluginContext, - ) -> crate::plugin::EventResult { - if let FlowEvent::Input(crate::plugin::InputEvent::KeyDown(ev)) = event - && (ev.keystroke.key == "delete" || ev.keystroke.key == "backspace") - { - ctx.execute_command(DeleteCommand::new(ctx)); - return crate::plugin::EventResult::Stop; - } - crate::plugin::EventResult::Continue - } -} - -struct DeleteCommand { - selected_edge: Vec, - originally_selected_edge_ids: HashSet, - selected_node: Vec, - selected_port: Vec, -} - -impl DeleteCommand { - fn collect_edges_for_selected_nodes( - graph: &crate::Graph, - selected_nodes: &[Node], - ) -> Vec { - let mut edge_ids = HashSet::new(); - let mut edges = Vec::new(); - - for node in selected_nodes { - for port_id in node.inputs().iter().chain(node.outputs().iter()) { - for edge in graph.edges().values() { - if (edge.source_port == *port_id || edge.target_port == *port_id) - && edge_ids.insert(edge.id) - { - edges.push(edge.clone()); - } - } - } - } - - edges - } - - fn new(ctx: &crate::plugin::PluginContext) -> Self { - let selected_node: Vec = ctx - .graph - .selected_node() - .iter() - .filter_map(|id| ctx.get_node(id).cloned()) - .collect(); - let mut selected_edge: Vec = ctx - .graph - .selected_edge() - .iter() - .filter_map(|id| ctx.graph.get_edge(id).cloned()) - .collect(); - let originally_selected_edge_ids: HashSet<_> = selected_edge.iter().map(|e| e.id).collect(); - let mut seen_edge_ids: HashSet<_> = selected_edge.iter().map(|e| e.id).collect(); - for edge in Self::collect_edges_for_selected_nodes(ctx.graph, &selected_node) { - if seen_edge_ids.insert(edge.id) { - selected_edge.push(edge); - } - } - - Self { - selected_edge, - originally_selected_edge_ids, - selected_port: selected_node - .iter() - .flat_map(|node| node.inputs().iter().chain(node.outputs().iter())) - .filter_map(|port_id| ctx.graph.get_port(port_id).cloned()) - .collect(), - selected_node, - } - } -} - -impl Command for DeleteCommand { - fn name(&self) -> &'static str { - "delete" - } - fn execute(&mut self, ctx: &mut crate::canvas::CommandContext) { - ctx.remove_selected_edge(); - ctx.remove_selected_node(); - } - fn undo(&mut self, ctx: &mut crate::canvas::CommandContext) { - for node in &self.selected_node { - ctx.add_node(node.clone()); - ctx.add_selected_node(node.id(), true); - } - - for port in &self.selected_port { - ctx.add_port(port.clone()); - } - - for edge in &self.selected_edge { - ctx.add_edge(edge.clone()); - if self.originally_selected_edge_ids.contains(&edge.id) { - ctx.add_selected_edge(edge.id, true); - } - } - } - - fn to_ops(&self, ctx: &mut crate::CommandContext) -> Vec { - let mut list = vec![]; - let mut removed_edges = HashSet::new(); - for node in &self.selected_node { - list.push(GraphOp::RemoveNode { id: node.id() }); - - let index = ctx.graph.node_order().iter().position(|v| *v == node.id()); - if let Some(index) = index { - list.push(GraphOp::NodeOrderRemove { index }) - } - } - - for port in &self.selected_port { - list.push(GraphOp::RemovePort(port.id())); - } - - for edge in &self.selected_edge { - if removed_edges.insert(edge.id) { - list.push(GraphOp::RemoveEdge(edge.id)); - } - } - - vec![GraphOp::Batch(list)] - } -} - -#[cfg(test)] -mod command_interop_tests { - use std::collections::HashSet; - - use crate::{Graph, command_interop::assert_command_interop}; - - use super::DeleteCommand; - - fn delete_command_like_new(graph: &Graph) -> DeleteCommand { - let selected_node: Vec = graph - .selected_node() - .iter() - .filter_map(|id| graph.get_node(id).cloned()) - .collect(); - let mut selected_edge: Vec = graph - .selected_edge() - .iter() - .filter_map(|id| graph.get_edge(id).cloned()) - .collect(); - let originally_selected_edge_ids: HashSet<_> = selected_edge.iter().map(|e| e.id).collect(); - let mut seen_edge_ids: HashSet<_> = selected_edge.iter().map(|e| e.id).collect(); - for edge in DeleteCommand::collect_edges_for_selected_nodes(graph, &selected_node) { - if seen_edge_ids.insert(edge.id) { - selected_edge.push(edge); - } - } - let selected_port: Vec = graph - .selected_node() - .iter() - .filter_map(|node_id| graph.get_node(node_id)) - .flat_map(|node| node.inputs().iter().chain(node.outputs().iter())) - .filter_map(|port_id| graph.get_port(port_id).cloned()) - .collect(); - DeleteCommand { - selected_edge, - originally_selected_edge_ids, - selected_node, - selected_port, - } - } - - #[test] - fn delete_command_interop_single_node_with_port() { - let mut base = Graph::new(); - let src_id = base - .create_node("x") - .position(-220.0, 0.0) - .output() - .build() - .unwrap(); - let dst_id = base - .create_node("x") - .position(220.0, 0.0) - .input() - .output() - .build() - .unwrap(); - let other_id = base - .create_node("x") - .position(440.0, 0.0) - .input() - .build() - .unwrap(); - // Put selected node at the end so execute+undo preserves node_order with current command behavior. - let selected_id = base - .create_node("x") - .position(0.0, 0.0) - .input() - .output() - .build() - .unwrap(); - - let selected_node = base.get_node(&selected_id).expect("selected node").clone(); - let src_node = base.get_node(&src_id).expect("src node").clone(); - let dst_node = base.get_node(&dst_id).expect("dst node").clone(); - let other_node = base.get_node(&other_id).expect("other node").clone(); - - // This edge is NOT selected, but should be deleted via node-cascade. - let _cascade_in = base - .create_edge() - .source(src_node.outputs()[0]) - .target(selected_node.inputs()[0]) - .build() - .expect("cascade in edge"); - // This edge IS selected and also touches selected node. - let selected_edge = base - .create_edge() - .source(selected_node.outputs()[0]) - .target(dst_node.inputs()[0]) - .build() - .expect("selected edge"); - // Unrelated edge should remain untouched. - let _unrelated = base - .create_edge() - .source(dst_node.outputs()[0]) - .target(other_node.inputs()[0]) - .build() - .expect("unrelated edge"); - - base.add_selected_node(selected_id, false); - base.add_selected_edge(selected_edge, true); - - let cmd = delete_command_like_new(&base); - assert_command_interop( - &base, - || { - Box::new(DeleteCommand { - selected_edge: cmd.selected_edge.clone(), - originally_selected_edge_ids: cmd.originally_selected_edge_ids.clone(), - selected_node: cmd.selected_node.clone(), - selected_port: cmd.selected_port.clone(), - }) - }, - "DeleteCommand", - ); - } -} diff --git a/crates/ferrum-flow/src/plugins/edge/command.rs b/crates/ferrum-flow/src/plugins/edge/command.rs deleted file mode 100644 index c46d98a084..0000000000 --- a/crates/ferrum-flow/src/plugins/edge/command.rs +++ /dev/null @@ -1,163 +0,0 @@ -use std::{collections::HashSet, vec}; - -use crate::{EdgeId, NodeId, canvas::Command, plugin::PluginContext}; - -pub(super) struct SelectEdgeCommand { - edge_id: EdgeId, - shift: bool, - old_selected_edge: HashSet, - old_selected_node: HashSet, -} - -impl SelectEdgeCommand { - pub(super) fn new(edge_id: EdgeId, shift: bool, ctx: &PluginContext) -> Self { - Self { - edge_id, - shift, - old_selected_edge: ctx.graph.selected_edge().clone(), - old_selected_node: ctx.graph.selected_node().clone(), - } - } -} - -impl Command for SelectEdgeCommand { - fn name(&self) -> &'static str { - "select_edge" - } - fn execute(&mut self, ctx: &mut crate::canvas::CommandContext) { - if !self.shift { - ctx.clear_selected_node(); - } - ctx.add_selected_edge(self.edge_id, self.shift); - } - fn undo(&mut self, ctx: &mut crate::canvas::CommandContext) { - ctx.graph.set_selected_node(self.old_selected_node.clone()); - ctx.graph.set_selected_edge(self.old_selected_edge.clone()); - } - fn to_ops(&self, ctx: &mut crate::CommandContext) -> Vec { - if !self.shift { - ctx.clear_selected_node(); - } - ctx.add_selected_edge(self.edge_id, self.shift); - vec![] - } -} - -pub(super) struct ClearEdgeCommand { - old_selected_edge: HashSet, -} - -impl ClearEdgeCommand { - pub(super) fn new(ctx: &PluginContext) -> Self { - Self { - old_selected_edge: ctx.graph.selected_edge().clone(), - } - } -} - -impl Command for ClearEdgeCommand { - fn name(&self) -> &'static str { - "clear_edge" - } - fn execute(&mut self, ctx: &mut crate::canvas::CommandContext) { - ctx.clear_selected_edge(); - } - fn undo(&mut self, ctx: &mut crate::canvas::CommandContext) { - ctx.graph.set_selected_edge(self.old_selected_edge.clone()); - } - - fn to_ops(&self, ctx: &mut crate::CommandContext) -> Vec { - ctx.clear_selected_edge(); - vec![] - } -} - -#[cfg(test)] -mod command_interop_tests { - use crate::{Graph, command_interop::assert_command_interop}; - - use super::{ClearEdgeCommand, SelectEdgeCommand}; - - #[test] - fn select_edge_command_interop() { - let mut base = Graph::new(); - let n1 = base - .create_node("a") - .position(0.0, 0.0) - .output() - .build() - .unwrap(); - let n2 = base - .create_node("b") - .position(100.0, 0.0) - .input() - .build() - .unwrap(); - let n1_node = base.get_node(&n1).expect("n1"); - let n2_node = base.get_node(&n2).expect("n2"); - let source_port = n1_node.outputs()[0]; - let target_port = n2_node.inputs()[0]; - let edge_id = base - .create_edge() - .source(source_port) - .target(target_port) - .build() - .expect("edge"); - - let old_selected_edge = base.selected_edge().clone(); - let old_selected_node = base.selected_node().clone(); - - assert_command_interop( - &base, - || { - Box::new(SelectEdgeCommand { - edge_id, - shift: false, - old_selected_edge: old_selected_edge.clone(), - old_selected_node: old_selected_node.clone(), - }) - }, - "SelectEdgeCommand", - ); - } - - #[test] - fn clear_edge_command_interop() { - let mut base = Graph::new(); - let n1 = base - .create_node("a") - .position(0.0, 0.0) - .output() - .build() - .unwrap(); - let n2 = base - .create_node("b") - .position(100.0, 0.0) - .input() - .build() - .unwrap(); - let n1_node = base.get_node(&n1).expect("n1"); - let n2_node = base.get_node(&n2).expect("n2"); - let source_port = n1_node.outputs()[0]; - let target_port = n2_node.inputs()[0]; - let edge_id = base - .create_edge() - .source(source_port) - .target(target_port) - .build() - .expect("edge"); - base.add_selected_edge(edge_id, false); - - let old_selected_edge = base.selected_edge().clone(); - - assert_command_interop( - &base, - || { - Box::new(ClearEdgeCommand { - old_selected_edge: old_selected_edge.clone(), - }) - }, - "ClearEdgeCommand", - ); - } -} diff --git a/crates/ferrum-flow/src/plugins/edge/mod.rs b/crates/ferrum-flow/src/plugins/edge/mod.rs deleted file mode 100644 index 66758c7a7d..0000000000 --- a/crates/ferrum-flow/src/plugins/edge/mod.rs +++ /dev/null @@ -1,286 +0,0 @@ -use std::collections::HashSet; - -use gpui::{ - Bounds, Element, MouseButton, PathBuilder, Pixels, Point, Styled as _, canvas, px, rgb, -}; - -use crate::{ - Edge, EdgeId, RenderContext, - plugin::{FlowEvent, Plugin, PluginContext}, - plugins::edge::command::ClearEdgeCommand, -}; - -mod command; - -use command::SelectEdgeCommand; - -pub struct EdgePlugin {} - -impl EdgePlugin { - pub fn new() -> Self { - Self {} - } -} - -impl Default for EdgePlugin { - fn default() -> Self { - Self::new() - } -} - -impl Plugin for EdgePlugin { - fn name(&self) -> &'static str { - "edge" - } - fn on_event( - &mut self, - event: &FlowEvent, - ctx: &mut crate::plugin::PluginContext, - ) -> crate::plugin::EventResult { - if let FlowEvent::Input(crate::plugin::InputEvent::MouseDown(ev)) = event { - if ev.button != MouseButton::Left { - return crate::plugin::EventResult::Continue; - } - let shift = ev.modifiers.shift; - if let Some(id) = hit_test_get_edge(ev.position, ctx) { - ctx.cache_port_offset_with_edge(&id); - ctx.execute_command(SelectEdgeCommand::new(id, shift, ctx)); - return crate::plugin::EventResult::Stop; - } else if !shift { - ctx.execute_command(ClearEdgeCommand::new(ctx)); - } - } - crate::plugin::EventResult::Continue - } - fn priority(&self) -> i32 { - 120 - } - fn render_layer(&self) -> crate::plugin::RenderLayer { - crate::plugin::RenderLayer::Edges - } - fn render(&mut self, ctx: &mut crate::RenderContext) -> Option { - let visible_nodes: HashSet<_> = ctx - .graph - .nodes() - .iter() - .filter(|(_, node)| ctx.is_node_visible_node(node)) - .map(|(id, _)| *id) - .collect(); - - let edges: Vec<_> = ctx - .graph - .edges() - .iter() - .filter(|(_, edge)| { - let Some(source_port) = ctx.graph.get_port(&edge.source_port) else { - return false; - }; - let Some(target_port) = ctx.graph.get_port(&edge.target_port) else { - return false; - }; - - visible_nodes.contains(&source_port.node_id()) - || visible_nodes.contains(&target_port.node_id()) - }) - .map(|(k, v)| (*k, edge_geometry2(v, ctx))) - .collect(); - - let edge_ids = edges.iter().map(|(id, _)| *id); - for edge_id in edge_ids { - ctx.cache_port_offset_with_edge(&edge_id); - } - - let selected_edges = ctx.graph.selected_edge().clone(); - let stroke = ctx.theme.edge_stroke; - let stroke_sel = ctx.theme.edge_stroke_selected; - - Some( - canvas( - move |_, _, _| (edges, selected_edges, stroke, stroke_sel), - move |bounds, (edges, selected_edges, stroke, stroke_sel), win, _| { - let origin = bounds.origin; - for (id, geometry) in edges.iter() { - let Some(EdgeGeometry { start, c1, c2, end }) = geometry else { - return; - }; - let mut line = PathBuilder::stroke(px(1.0)); - line.move_to(*start + origin); - line.cubic_bezier_to(*end + origin, *c1 + origin, *c2 + origin); - - let selected = selected_edges.iter().any(|i| *i == *id); - - if let Ok(line) = line.build() { - win.paint_path(line, rgb(if selected { stroke_sel } else { stroke })); - } - } - }, - ) - .absolute() - .size_full() - .into_any(), - ) - } -} - -pub struct EdgeGeometry { - pub start: Point, - pub c1: Point, - pub c2: Point, - pub end: Point, -} - -fn edge_geometry(edge: &Edge, ctx: &PluginContext) -> Option { - let Edge { - source_port: source_id, - target_port: target_id, - .. - } = edge; - - let start = ctx.port_screen_center_by_port_id(*source_id)?; - let end = ctx.port_screen_center_by_port_id(*target_id)?; - - let source_port = ctx.graph.get_port(source_id)?; - let target_port = ctx.graph.get_port(target_id)?; - - let c1 = ctx.edge_control_point(start, source_port.position()); - let c2 = ctx.edge_control_point(end, target_port.position()); - - Some(EdgeGeometry { start, c1, c2, end }) -} - -fn edge_geometry2(edge: &Edge, ctx: &RenderContext) -> Option { - let Edge { - source_port: source_id, - target_port: target_id, - .. - } = edge; - - let start = ctx.port_screen_center_by_port_id(*source_id)?; - let end = ctx.port_screen_center_by_port_id(*target_id)?; - - let source_port = ctx.graph.get_port(source_id)?; - let target_port = ctx.graph.get_port(target_id)?; - - let c1 = ctx.edge_control_point(start, source_port.position()); - let c2 = ctx.edge_control_point(end, target_port.position()); - - Some(EdgeGeometry { start, c1, c2, end }) -} - -fn hit_test_get_edge(mouse: Point, ctx: &PluginContext) -> Option { - let visible_nodes: HashSet<_> = ctx - .graph - .nodes() - .iter() - .filter(|(_, node)| ctx.is_node_visible_node(node)) - .map(|(id, _)| *id) - .collect(); - - let edges = ctx.graph.edges_values().filter(|edge| { - let Some(source_port) = ctx.graph.get_port(&edge.source_port) else { - return false; - }; - let Some(target_port) = ctx.graph.get_port(&edge.target_port) else { - return false; - }; - - visible_nodes.contains(&source_port.node_id()) - || visible_nodes.contains(&target_port.node_id()) - }); - for edge in edges { - let Some(geom) = edge_geometry(edge, ctx) else { - continue; - }; - - let bound = edge_bounds(&geom); - if !bound.contains(&mouse) { - continue; - } - - if hit_test_edge(mouse, &geom) { - return Some(edge.id); - } - } - - None -} - -pub fn edge_bounds(geom: &EdgeGeometry) -> Bounds { - let min_x = geom.start.x.min(geom.end.x).min(geom.c1.x).min(geom.c2.x); - let max_x = geom.start.x.max(geom.end.x).max(geom.c1.x).max(geom.c2.x); - - let min_y = geom.start.y.min(geom.end.y).min(geom.c1.y).min(geom.c2.y); - let max_y = geom.start.y.max(geom.end.y).max(geom.c1.y).max(geom.c2.y); - - Bounds::from_corners( - Point::new(min_x - px(10.0), min_y - px(10.0)), - Point::new(max_x + px(10.0), max_y + px(10.0)), - ) -} - -fn hit_test_edge(mouse: Point, geom: &EdgeGeometry) -> bool { - let points = sample_bezier(geom, 20); - - for segment in points.windows(2) { - let d = distance_to_segment(mouse, segment[0], segment[1]); - - if d < 8.0 { - return true; - } - } - - false -} - -fn sample_bezier(geom: &EdgeGeometry, steps: usize) -> Vec> { - let mut points = Vec::new(); - - for i in 0..=steps { - let t = i as f32 / steps as f32; - - let x = (1.0 - t).powi(3) * geom.start.x - + 3.0 * (1.0 - t).powi(2) * t * geom.c1.x - + 3.0 * (1.0 - t) * t * t * geom.c2.x - + t.powi(3) * geom.end.x; - - let y = (1.0 - t).powi(3) * geom.start.y - + 3.0 * (1.0 - t).powi(2) * t * geom.c1.y - + 3.0 * (1.0 - t) * t * t * geom.c2.y - + t.powi(3) * geom.end.y; - - points.push(Point::new(x, y)); - } - - points -} -pub fn distance_to_segment(p: Point, a: Point, b: Point) -> f32 { - let ap = vec_sub(p, a); - let ab = vec_sub(b, a); - - let ab_len2 = ab.0 * ab.0 + ab.1 * ab.1; - - if ab_len2 == 0.0 { - return vec_length(ap); - } - - let t = (vec_dot(ap, ab) / ab_len2).clamp(0.0, 1.0); - - let closest = Point::new(f32::from(a.x) + ab.0 * t, f32::from(a.y) + ab.1 * t); - - let dx = f32::from(p.x) - closest.x; - let dy = f32::from(p.y) - closest.y; - - (dx * dx + dy * dy).sqrt() -} - -fn vec_sub(a: Point, b: Point) -> (f32, f32) { - (f32::from(a.x - b.x), f32::from(a.y - b.y)) -} - -fn vec_dot(a: (f32, f32), b: (f32, f32)) -> f32 { - a.0 * b.0 + a.1 * b.1 -} - -fn vec_length(v: (f32, f32)) -> f32 { - (v.0 * v.0 + v.1 * v.1).sqrt() -} diff --git a/crates/ferrum-flow/src/plugins/fit_all.rs b/crates/ferrum-flow/src/plugins/fit_all.rs deleted file mode 100644 index c07f8ab0bb..0000000000 --- a/crates/ferrum-flow/src/plugins/fit_all.rs +++ /dev/null @@ -1,114 +0,0 @@ -use gpui::{Bounds, Point, px}; - -use crate::{ - Node, - plugin::{FlowEvent, InitPluginContext, Plugin, PluginContext, primary_platform_modifier}, - plugins::viewport_frame::{apply_frame_world_rect_direct, frame_world_rect}, -}; - -/// Zoom and pan so **all** nodes fit in the window (⌘0 / Ctrl+0). Undo restores the previous view. -/// -/// On [`Plugin::setup`], fits once using [`InitPluginContext::drawable_size`] (does not push an undo -/// entry; the initial view is not recorded as a command). -pub struct FitAllGraphPlugin; - -impl FitAllGraphPlugin { - pub fn new() -> Self { - Self - } -} - -impl Default for FitAllGraphPlugin { - fn default() -> Self { - Self::new() - } -} - -fn graph_world_bounds_graph<'a>( - nodes: impl Iterator + 'a, -) -> Option<(f32, f32, f32, f32)> { - let mut min_x = f32::MAX; - let mut min_y = f32::MAX; - let mut max_x = f32::MIN; - let mut max_y = f32::MIN; - let mut any = false; - - for n in nodes { - let (nx, ny) = n.position(); - let size = *n.size_ref(); - let x: f32 = nx.into(); - let y: f32 = ny.into(); - let w: f32 = size.width.into(); - let h: f32 = size.height.into(); - min_x = min_x.min(x); - min_y = min_y.min(y); - max_x = max_x.max(x + w); - max_y = max_y.max(y + h); - any = true; - } - - if !any { - return None; - } - - Some(( - min_x, - min_y, - (max_x - min_x).max(1.0), - (max_y - min_y).max(1.0), - )) -} - -fn graph_world_bounds(ctx: &PluginContext) -> Option<(f32, f32, f32, f32)> { - graph_world_bounds_graph(ctx.nodes().values()) -} - -fn fit_all(ctx: &mut PluginContext) { - let Some((bx, by, bw, bh)) = graph_world_bounds(ctx) else { - return; - }; - frame_world_rect(ctx, bx, by, bw, bh); -} - -pub(crate) fn fit_entire_graph(ctx: &mut PluginContext) { - fit_all(ctx); -} - -impl Plugin for FitAllGraphPlugin { - fn name(&self) -> &'static str { - "fit_all_graph" - } - - fn setup(&mut self, ctx: &mut InitPluginContext) { - let Some((bx, by, bw, bh)) = graph_world_bounds_graph(ctx.nodes().values()) else { - return; - }; - let win_w: f32 = ctx.drawable_size.width.into(); - let win_h: f32 = ctx.drawable_size.height.into(); - apply_frame_world_rect_direct(ctx, win_w, win_h, bx, by, bw, bh); - ctx.set_window_bounds(Some(Bounds::new( - Point::new(px(0.0), px(0.0)), - ctx.drawable_size, - ))); - } - - fn priority(&self) -> i32 { - 88 - } - - fn on_event( - &mut self, - event: &FlowEvent, - ctx: &mut PluginContext, - ) -> crate::plugin::EventResult { - if let FlowEvent::Input(crate::plugin::InputEvent::KeyDown(ev)) = event - && primary_platform_modifier(ev) - && !ev.keystroke.modifiers.shift - && ev.keystroke.key == "0" - { - fit_all(ctx); - return crate::plugin::EventResult::Stop; - } - crate::plugin::EventResult::Continue - } -} diff --git a/crates/ferrum-flow/src/plugins/focus_selection.rs b/crates/ferrum-flow/src/plugins/focus_selection.rs deleted file mode 100644 index 5cc2f6bddd..0000000000 --- a/crates/ferrum-flow/src/plugins/focus_selection.rs +++ /dev/null @@ -1,63 +0,0 @@ -use crate::{ - plugin::{FlowEvent, Plugin, PluginContext, primary_platform_modifier}, - plugins::viewport_frame::frame_world_rect, -}; - -/// Pan + zoom the viewport so selected nodes fit the window (⌘⇧F / Ctrl⇧F). Undo restores prior view. -pub struct FocusSelectionPlugin; - -impl FocusSelectionPlugin { - pub fn new() -> Self { - Self - } -} - -impl Default for FocusSelectionPlugin { - fn default() -> Self { - Self::new() - } -} - -fn focus_shortcut(ev: &gpui::KeyDownEvent) -> bool { - primary_platform_modifier(ev) && ev.keystroke.modifiers.shift -} - -fn focus_selected(ctx: &mut PluginContext) { - let Some(bounds) = ctx.graph.selection_bounds() else { - return; - }; - let bx: f32 = bounds.origin.x.into(); - let by: f32 = bounds.origin.y.into(); - let bw: f32 = bounds.size.width.into(); - let bh: f32 = bounds.size.height.into(); - frame_world_rect(ctx, bx, by, bw, bh); -} - -pub(crate) fn focus_viewport_on_selection(ctx: &mut PluginContext) { - focus_selected(ctx); -} - -impl Plugin for FocusSelectionPlugin { - fn name(&self) -> &'static str { - "focus_selection" - } - - fn priority(&self) -> i32 { - 90 - } - - fn on_event( - &mut self, - event: &FlowEvent, - ctx: &mut PluginContext, - ) -> crate::plugin::EventResult { - if let FlowEvent::Input(crate::plugin::InputEvent::KeyDown(ev)) = event - && focus_shortcut(ev) - && ev.keystroke.key == "f" - { - focus_selected(ctx); - return crate::plugin::EventResult::Stop; - } - crate::plugin::EventResult::Continue - } -} diff --git a/crates/ferrum-flow/src/plugins/history.rs b/crates/ferrum-flow/src/plugins/history.rs deleted file mode 100644 index a88ec5ce9c..0000000000 --- a/crates/ferrum-flow/src/plugins/history.rs +++ /dev/null @@ -1,39 +0,0 @@ -use crate::plugin::{FlowEvent, Plugin, primary_platform_modifier}; - -pub struct HistoryPlugin; - -impl HistoryPlugin { - pub fn new() -> Self { - Self {} - } -} - -impl Default for HistoryPlugin { - fn default() -> Self { - Self::new() - } -} - -impl Plugin for HistoryPlugin { - fn name(&self) -> &'static str { - "history" - } - - fn on_event( - &mut self, - event: &FlowEvent, - ctx: &mut crate::plugin::PluginContext, - ) -> crate::plugin::EventResult { - if let FlowEvent::Input(crate::plugin::InputEvent::KeyDown(ev)) = event { - let primary = primary_platform_modifier(ev); - if ev.keystroke.key == "z" && primary && ev.keystroke.modifiers.shift { - ctx.redo(); - return crate::plugin::EventResult::Stop; - } else if ev.keystroke.key == "z" && primary { - ctx.undo(); - return crate::plugin::EventResult::Stop; - } - } - crate::plugin::EventResult::Continue - } -} diff --git a/crates/ferrum-flow/src/plugins/minimap.rs b/crates/ferrum-flow/src/plugins/minimap.rs deleted file mode 100644 index 9d41a735b2..0000000000 --- a/crates/ferrum-flow/src/plugins/minimap.rs +++ /dev/null @@ -1,443 +0,0 @@ -//! Overview minimap: full-graph bounds in world space, current viewport indicator, click-to-center. - -use std::collections::HashMap; - -use gpui::{ - Bounds, Element, MouseButton, PathBuilder, Pixels, Point, Size, Styled as _, canvas, px, rgb, -}; - -use crate::{ - NodeId, Viewport, - canvas::{Command, CommandContext}, - plugin::{ - EventResult, FlowEvent, InputEvent, Plugin, PluginContext, RenderContext, RenderLayer, - }, -}; - -const MAP_W: f32 = 200.0; -const MAP_H: f32 = 140.0; -const OUTER_MARGIN: f32 = 16.0; -const INNER_INSET: f32 = 3.0; -const WORLD_PAD: f32 = 96.0; - -/// Warm-start capacity for the viewport-visible node map; typical sessions stay in low hundreds. -/// Capped by total node count so tiny graphs do not over-allocate. -const VISIBLE_NODE_MAP_CAPACITY_HINT: usize = 128; - -/// Last-computed layout for hit-testing (updated each [`MinimapPlugin::render`]). -#[derive(Clone)] -struct MinimapLayout { - chrome: Bounds, - inner: Bounds, - world_x0: f32, - world_y0: f32, - world_w: f32, - world_h: f32, -} - -impl MinimapLayout { - fn contains_chrome(&self, p: Point) -> bool { - self.chrome.contains(&p) - } - - /// Maps a screen position inside the chrome to world coordinates (clamped to the mapped extent). - fn screen_to_world(&self, screen: Point) -> Point { - let ix: f32 = self.inner.origin.x.into(); - let iy: f32 = self.inner.origin.y.into(); - let iw: f32 = self.inner.size.width.into(); - let ih: f32 = self.inner.size.height.into(); - let sx: f32 = screen.x.into(); - let sy: f32 = screen.y.into(); - let u = ((sx - ix) / iw.max(1.0)).clamp(0.0, 1.0); - let v = ((sy - iy) / ih.max(1.0)).clamp(0.0, 1.0); - let wx = self.world_x0 + u * self.world_w; - let wy = self.world_y0 + v * self.world_h; - Point::new(px(wx), px(wy)) - } -} - -fn graph_world_extent(ctx: &RenderContext) -> (f32, f32, f32, f32) { - let nodes: Vec<_> = ctx - .graph - .nodes() - .values() - .filter(|n| ctx.is_node_visible_node(n)) - .collect(); - if nodes.is_empty() { - return (0.0, 0.0, 640.0, 480.0); - } - let mut min_x = f32::MAX; - let mut min_y = f32::MAX; - let mut max_x = f32::MIN; - let mut max_y = f32::MIN; - for n in nodes { - let (nx, ny) = n.position(); - let size = *n.size_ref(); - let x: f32 = nx.into(); - let y: f32 = ny.into(); - let w: f32 = size.width.into(); - let h: f32 = size.height.into(); - min_x = min_x.min(x); - min_y = min_y.min(y); - max_x = max_x.max(x + w); - max_y = max_y.max(y + h); - } - let w = (max_x - min_x + 2.0 * WORLD_PAD).max(120.0); - let h = (max_y - min_y + 2.0 * WORLD_PAD).max(120.0); - (min_x - WORLD_PAD, min_y - WORLD_PAD, w, h) -} - -fn visible_world_aabb(viewport: &Viewport, win: &Bounds) -> (f32, f32, f32, f32) { - let w: f32 = win.size.width.into(); - let h: f32 = win.size.height.into(); - let corners = [ - viewport.screen_to_world(Point::new(px(0.0), px(0.0))), - viewport.screen_to_world(Point::new(px(w), px(0.0))), - viewport.screen_to_world(Point::new(px(w), px(h))), - viewport.screen_to_world(Point::new(px(0.0), px(h))), - ]; - let mut min_x = f32::MAX; - let mut min_y = f32::MAX; - let mut max_x = f32::MIN; - let mut max_y = f32::MIN; - for c in corners { - let x: f32 = c.x.into(); - let y: f32 = c.y.into(); - min_x = min_x.min(x); - min_y = min_y.min(y); - max_x = max_x.max(x); - max_y = max_y.max(y); - } - ( - min_x, - min_y, - (max_x - min_x).max(1.0), - (max_y - min_y).max(1.0), - ) -} - -fn build_layout(ctx: &RenderContext) -> Option { - let win = ctx.window_bounds()?; - let ww: f32 = win.size.width.into(); - let wh: f32 = win.size.height.into(); - if ww < MAP_W + OUTER_MARGIN || wh < MAP_H + OUTER_MARGIN { - return None; - } - - let map_w = px(MAP_W); - let map_h = px(MAP_H); - let ox = win.size.width - map_w - px(OUTER_MARGIN); - let oy = win.size.height - map_h - px(OUTER_MARGIN); - let chrome = Bounds::new(Point::new(ox, oy), Size::new(map_w, map_h)); - - let inset = px(INNER_INSET); - let inner = Bounds::new( - chrome.origin + Point::new(inset, inset), - Size::new( - chrome.size.width - inset * 2.0, - chrome.size.height - inset * 2.0, - ), - ); - - let (wx0, wy0, ww, wh) = graph_world_extent(ctx); - - Some(MinimapLayout { - chrome, - inner, - world_x0: wx0, - world_y0: wy0, - world_w: ww.max(1.0), - world_h: wh.max(1.0), - }) -} - -fn world_to_inner_pt(wx: f32, wy: f32, layout: &MinimapLayout) -> Point { - let u = ((wx - layout.world_x0) / layout.world_w).clamp(0.0, 1.0); - let v = ((wy - layout.world_y0) / layout.world_h).clamp(0.0, 1.0); - let ix: f32 = layout.inner.origin.x.into(); - let iy: f32 = layout.inner.origin.y.into(); - let iw: f32 = layout.inner.size.width.into(); - let ih: f32 = layout.inner.size.height.into(); - Point::new(px(ix + u * iw), px(iy + v * ih)) -} - -fn center_viewport_on_world(ctx: &mut PluginContext, world: Point) { - let Some(wb) = ctx.window_bounds() else { - return; - }; - let cx: f32 = (wb.size.width / 2.0).into(); - let cy: f32 = (wb.size.height / 2.0).into(); - let z = ctx.zoom(); - let wx: f32 = world.x.into(); - let wy: f32 = world.y.into(); - let from = ctx.offset(); - ctx.set_offset_xy(px(cx - wx * z), px(cy - wy * z)); - let to = ctx.offset(); - ctx.execute_command(MinimapPanCommand { from, to }); -} - -struct MinimapPanCommand { - from: Point, - to: Point, -} - -impl Command for MinimapPanCommand { - fn name(&self) -> &'static str { - "minimap_pan" - } - - fn execute(&mut self, ctx: &mut CommandContext) { - ctx.set_offset(self.to); - } - - fn undo(&mut self, ctx: &mut CommandContext) { - ctx.set_offset(self.from); - } - - fn to_ops(&self, _ctx: &mut crate::CommandContext) -> Vec { - vec![] - } -} - -/// Renders a bottom-right overview map and pans the viewport when the user clicks it. -/// -/// Uses priority **135** so clicks hit the minimap before [`crate::plugins::SelectionPlugin`] (100) -/// starts a canvas selection. -pub struct MinimapPlugin { - last_layout: Option, -} - -impl MinimapPlugin { - pub fn new() -> Self { - Self { last_layout: None } - } -} - -impl Default for MinimapPlugin { - fn default() -> Self { - Self::new() - } -} - -impl Plugin for MinimapPlugin { - fn name(&self) -> &'static str { - "minimap" - } - - fn on_event(&mut self, event: &FlowEvent, ctx: &mut PluginContext) -> EventResult { - if let FlowEvent::Input(InputEvent::MouseDown(ev)) = event - && let Some(ref layout) = self.last_layout - && layout.contains_chrome(ev.position) - { - if ev.button == MouseButton::Right { - return EventResult::Stop; - } else if ev.button == MouseButton::Left { - let world = layout.screen_to_world(ev.position); - center_viewport_on_world(ctx, world); - ctx.notify(); - return EventResult::Stop; - } - } - EventResult::Continue - } - - fn priority(&self) -> i32 { - 135 - } - - fn render_layer(&self) -> RenderLayer { - RenderLayer::Overlay - } - - fn render(&mut self, ctx: &mut RenderContext) -> Option { - let layout = build_layout(ctx)?; - self.last_layout = Some(layout.clone()); - - let inner = layout.inner; - - // One visibility pass: rects for node quads, and world-space centers for edges (no second - // `get_node` per endpoint). - let map_cap = VISIBLE_NODE_MAP_CAPACITY_HINT.min(ctx.graph.nodes().len()); - let mut visible_centers = HashMap::::with_capacity(map_cap); - let nodes: Vec<_> = ctx - .graph - .nodes() - .values() - .filter_map(|n| { - if !ctx.is_node_visible(&n.id()) { - return None; - } - let (nx, ny) = n.position(); - let size = *n.size_ref(); - let x: f32 = nx.into(); - let y: f32 = ny.into(); - let w: f32 = size.width.into(); - let h: f32 = size.height.into(); - visible_centers.insert(n.id(), (x + w * 0.5, y + h * 0.5)); - Some((x, y, w, h)) - }) - .collect(); - - let edges: Vec<_> = ctx - .graph - .edges_values() - .filter_map(|e| { - let s = ctx.graph.get_port(&e.source_port)?; - let t = ctx.graph.get_port(&e.target_port)?; - let (sx, sy) = visible_centers.get(&s.node_id())?; - let (tx, ty) = visible_centers.get(&t.node_id())?; - Some((*sx, *sy, *tx, *ty)) - }) - .collect(); - - let win_bounds = ctx.window_bounds()?; - let (vx0, vy0, vw, vh) = visible_world_aabb(ctx.viewport(), &win_bounds); - let v_tl = world_to_inner_pt(vx0, vy0, &layout); - let v_br = world_to_inner_pt(vx0 + vw, vy0 + vh, &layout); - - let minimap_background = ctx.theme.minimap_background; - let minimap_border = ctx.theme.minimap_border; - let minimap_edge = ctx.theme.minimap_edge; - let minimap_node_fill = ctx.theme.minimap_node_fill; - let minimap_node_stroke = ctx.theme.minimap_node_stroke; - let minimap_viewport_stroke = ctx.theme.minimap_viewport_stroke; - - Some( - canvas( - move |_, _, _| (), - move |bounds, _, win, _| { - let origin = bounds.origin; - // Inner background - if let Ok(p) = rect_fill_path(offset_bounds(inner, origin)) { - win.paint_path(p, rgb(minimap_background)); - } - if let Ok(p) = rect_stroke_path(offset_bounds(inner, origin), px(1.0)) { - win.paint_path(p, rgb(minimap_border)); - } - - // Edges (straight segments between node centers) - for (sx, sy, tx, ty) in edges { - let a = world_to_inner_pt(sx, sy, &layout); - let b = world_to_inner_pt(tx, ty, &layout); - let mut line = PathBuilder::stroke(px(1.0)); - line.move_to(a + origin); - line.line_to(b + origin); - if let Ok(p) = line.build() { - win.paint_path(p, rgb(minimap_edge)); - } - } - - for (x, y, nw, nh) in nodes { - let p0 = world_to_inner_pt(x, y, &layout); - let p1 = world_to_inner_pt(x + nw, y + nh, &layout); - let min_x = f32::min(f32::from(p0.x), f32::from(p1.x)); - let max_x = f32::max(f32::from(p0.x), f32::from(p1.x)); - let min_y = f32::min(f32::from(p0.y), f32::from(p1.y)); - let max_y = f32::max(f32::from(p0.y), f32::from(p1.y)); - let rw = (max_x - min_x).max(2.0); - let rh = (max_y - min_y).max(2.0); - let o = Point::new(px(min_x), px(min_y)) + origin; - let s = Size::new(px(rw), px(rh)); - if let Ok(p) = rect_fill_bounds(o, s) { - win.paint_path(p, rgb(minimap_node_fill)); - } - if let Ok(p) = rect_stroke_bounds(o, s, px(1.0)) { - win.paint_path(p, rgb(minimap_node_stroke)); - } - } - - // Viewport frame - let min_x = f32::min(f32::from(v_tl.x), f32::from(v_br.x)); - let max_x = f32::max(f32::from(v_tl.x), f32::from(v_br.x)); - let min_y = f32::min(f32::from(v_tl.y), f32::from(v_br.y)); - let max_y = f32::max(f32::from(v_tl.y), f32::from(v_br.y)); - let vo = Point::new(px(min_x), px(min_y)) + origin; - let vs = Size::new(px((max_x - min_x).max(2.0)), px((max_y - min_y).max(2.0))); - if let Ok(p) = rect_stroke_bounds(vo, vs, px(1.5)) { - win.paint_path(p, rgb(minimap_viewport_stroke)); - } - }, - ) - .absolute() - .size_full() - .into_any(), - ) - } -} - -fn offset_bounds(bounds: Bounds, offset: Point) -> Bounds { - Bounds::new(bounds.origin + offset, bounds.size) -} - -fn rect_fill_path(b: Bounds) -> Result, anyhow::Error> { - rect_fill_bounds(b.origin, b.size) -} - -fn rect_fill_bounds( - o: Point, - s: Size, -) -> Result, anyhow::Error> { - let x0: f32 = o.x.into(); - let y0: f32 = o.y.into(); - let w: f32 = s.width.into(); - let h: f32 = s.height.into(); - let pts = [ - Point::new(px(x0), px(y0)), - Point::new(px(x0 + w), px(y0)), - Point::new(px(x0 + w), px(y0 + h)), - Point::new(px(x0), px(y0 + h)), - ]; - let mut pb = PathBuilder::fill(); - pb.add_polygon(&pts, true); - pb.build() -} - -fn rect_stroke_path(b: Bounds, width: Pixels) -> Result, anyhow::Error> { - rect_stroke_bounds(b.origin, b.size, width) -} - -fn rect_stroke_bounds( - o: Point, - s: Size, - width: Pixels, -) -> Result, anyhow::Error> { - let x0: f32 = o.x.into(); - let y0: f32 = o.y.into(); - let w: f32 = s.width.into(); - let h: f32 = s.height.into(); - let mut line = PathBuilder::stroke(width); - line.move_to(Point::new(px(x0), px(y0))); - line.line_to(Point::new(px(x0 + w), px(y0))); - line.line_to(Point::new(px(x0 + w), px(y0 + h))); - line.line_to(Point::new(px(x0), px(y0 + h))); - line.close(); - line.build() -} - -#[cfg(test)] -mod command_interop_tests { - use gpui::{Point, px}; - - use crate::{Graph, command_interop::assert_command_interop}; - - use super::MinimapPanCommand; - - #[test] - fn minimap_pan_command_interop() { - let base = Graph::new(); - let cmd = MinimapPanCommand { - from: Point::new(px(1.0), px(2.0)), - to: Point::new(px(10.0), px(20.0)), - }; - assert_command_interop( - &base, - || { - Box::new(MinimapPanCommand { - from: cmd.from, - to: cmd.to, - }) - }, - "MinimapPanCommand", - ); - } -} diff --git a/crates/ferrum-flow/src/plugins/mod.rs b/crates/ferrum-flow/src/plugins/mod.rs deleted file mode 100644 index 12e43dd563..0000000000 --- a/crates/ferrum-flow/src/plugins/mod.rs +++ /dev/null @@ -1,44 +0,0 @@ -mod align; -mod background; -mod clipboard; -mod context_menu; -mod delete; -mod edge; -mod fit_all; -mod focus_selection; -mod history; -mod minimap; -mod node; -mod port; -mod select_all_viewport; -mod selection; -mod snap_guides; -mod toast; -mod viewport; -mod viewport_frame; -mod zoom_controls; - -pub use align::AlignPlugin; -pub use background::BackgroundPlugin; -pub use clipboard::ClipboardPlugin; -pub use context_menu::{ContextMenuCanvasExtra, ContextMenuCustomAction, ContextMenuPlugin}; -pub use delete::DeletePlugin; -pub use edge::EdgePlugin; -pub use fit_all::FitAllGraphPlugin; -pub use focus_selection::FocusSelectionPlugin; -pub use history::HistoryPlugin; -pub use minimap::MinimapPlugin; -pub use node::{ - ActiveNodeDrag, NODE_DRAG_TICK_INTERVAL, NodeDragEvent, NodeInteractionPlugin, NodePlugin, -}; -pub use port::{ - CreateEdge, CreateNode, CreatePort, DefaultEdgeValidator, EdgeValidationError, - EdgeValidationErrorCode, EdgeValidator, PortInteractionPlugin, edge_bezier, filled_disc_path, - port_screen_big_bounds, port_screen_bounds, -}; -pub use select_all_viewport::SelectAllViewportPlugin; -pub use selection::SelectionPlugin; -pub use snap_guides::SnapGuidesPlugin; -pub use toast::{ToastLevel, ToastMessage, ToastPlugin}; -pub use viewport::ViewportPlugin; -pub use zoom_controls::ZoomControlsPlugin; diff --git a/crates/ferrum-flow/src/plugins/node/command.rs b/crates/ferrum-flow/src/plugins/node/command.rs deleted file mode 100644 index 38e64dc0b2..0000000000 --- a/crates/ferrum-flow/src/plugins/node/command.rs +++ /dev/null @@ -1,178 +0,0 @@ -use std::collections::HashSet; - -use gpui::{Pixels, Point}; - -use crate::{EdgeId, GraphOp, NodeId, canvas::Command, plugin::PluginContext}; - -pub struct SelecteNodeCommand { - node_id: NodeId, - shift: bool, - old_node_order: Vec, - old_selected_edge: HashSet, - old_selected_node: HashSet, -} - -impl SelecteNodeCommand { - pub fn new(node_id: NodeId, shift: bool, ctx: &PluginContext) -> Self { - Self { - node_id, - shift, - old_node_order: ctx.graph.node_order().clone(), - old_selected_edge: ctx.graph.selected_edge().clone(), - old_selected_node: ctx.graph.selected_node().clone(), - } - } -} - -impl Command for SelecteNodeCommand { - fn name(&self) -> &'static str { - "select_node" - } - fn execute(&mut self, ctx: &mut crate::canvas::CommandContext) { - if !self.shift { - ctx.clear_selected_edge(); - } - ctx.add_selected_node(self.node_id, self.shift); - ctx.bring_node_to_front(self.node_id); - } - fn undo(&mut self, ctx: &mut crate::canvas::CommandContext) { - ctx.graph.set_selected_node(self.old_selected_node.clone()); - ctx.graph.set_selected_edge(self.old_selected_edge.clone()); - let a = ctx.graph.node_order_mut(); - *a = self.old_node_order.clone(); - } - - fn to_ops(&self, ctx: &mut crate::CommandContext) -> Vec { - if !self.shift { - ctx.clear_selected_edge(); - } - ctx.add_selected_node(self.node_id, self.shift); - - let mut list = vec![]; - let index = ctx - .graph - .node_order() - .iter() - .position(|v| *v == self.node_id); - if let Some(index) = index { - list.push(GraphOp::NodeOrderRemove { index }) - } - list.push(GraphOp::NodeOrderInsert { id: self.node_id }); - list - } -} - -pub struct DragNodesCommand { - from: Vec<(NodeId, Point)>, - to: Vec<(NodeId, Point)>, -} - -impl DragNodesCommand { - pub fn new(start_positions: &[(NodeId, Point)], ctx: &PluginContext) -> Self { - let mut to = Vec::new(); - for (node_id, _) in start_positions { - if let Some(node) = ctx.get_node(node_id) { - to.push((*node_id, node.point())); - } - } - Self { - from: start_positions.to_vec(), - to, - } - } - - /// Explicit before/after positions (same node order, same length). Use for align / distribute. - pub fn from_positions( - from: Vec<(NodeId, Point)>, - to: Vec<(NodeId, Point)>, - ) -> Self { - Self { from, to } - } -} - -impl Command for DragNodesCommand { - fn name(&self) -> &'static str { - "drag_nodes" - } - fn execute(&mut self, ctx: &mut crate::canvas::CommandContext) { - for (id, point) in self.to.iter() { - if let Some(node) = ctx.get_node_mut(id) { - node.set_position_with_point(*point); - } - } - } - fn undo(&mut self, ctx: &mut crate::canvas::CommandContext) { - for (id, point) in self.from.iter() { - if let Some(node) = ctx.get_node_mut(id) { - node.set_position_with_point(*point); - } - } - } - - fn to_ops(&self, _ctx: &mut crate::CommandContext) -> Vec { - let mut list = vec![]; - for (id, point) in self.to.iter() { - list.push(GraphOp::MoveNode { - id: *id, - x: Into::::into(point.x), - y: Into::::into(point.y), - }) - } - - vec![GraphOp::Batch(list)] - } -} - -#[cfg(test)] -mod command_interop_tests { - use gpui::{Point, px}; - - use crate::{Graph, command_interop::assert_command_interop}; - - use super::{DragNodesCommand, SelecteNodeCommand}; - - #[test] - fn select_node_command_interop() { - let mut base = Graph::new(); - let n1 = base.create_node("a").position(0.0, 0.0).build().unwrap(); - let _n2 = base.create_node("b").position(50.0, 0.0).build().unwrap(); - - let old_node_order = base.node_order().to_vec(); - let old_selected_edge = base.selected_edge().clone(); - let old_selected_node = base.selected_node().clone(); - - assert_command_interop( - &base, - || { - Box::new(SelecteNodeCommand { - node_id: n1, - shift: false, - old_node_order: old_node_order.clone(), - old_selected_edge: old_selected_edge.clone(), - old_selected_node: old_selected_node.clone(), - }) - }, - "SelecteNodeCommand", - ); - } - - #[test] - fn drag_nodes_command_interop() { - let mut base = Graph::new(); - let n = base.create_node("n").position(0.0, 0.0).build().unwrap(); - let from = vec![(n, Point::new(px(0.0), px(0.0)))]; - let to = vec![(n, Point::new(px(30.0), px(40.0)))]; - let cmd = DragNodesCommand::from_positions(from, to); - - assert_command_interop( - &base, - || { - Box::new(DragNodesCommand::from_positions( - cmd.from.clone(), - cmd.to.clone(), - )) - }, - "DragNodesCommand", - ); - } -} diff --git a/crates/ferrum-flow/src/plugins/node/drag_events.rs b/crates/ferrum-flow/src/plugins/node/drag_events.rs deleted file mode 100644 index 6bee31a6d2..0000000000 --- a/crates/ferrum-flow/src/plugins/node/drag_events.rs +++ /dev/null @@ -1,31 +0,0 @@ -//! Custom [`FlowEvent`](crate::plugin::FlowEvent) payloads for primary (left-button) node dragging. -//! Emitted by [`super::interaction::NodeDragInteraction`]. Other plugins (e.g. snap guides) may -//! subscribe via [`FlowEvent::as_custom`](crate::plugin::FlowEvent::as_custom). -//! -//! [`NodeDragEvent::Tick`] carries [`std::sync::Arc`] so the emitter can share the same id list across -//! ticks without reallocating (custom events cannot borrow interaction state). - -use std::sync::Arc; -use std::time::Duration; - -use crate::NodeId; - -/// Stored in [`crate::SharedState`] while [`super::interaction::NodeDragInteraction`] is active in -/// the dragging phase: these node ids are rendered on the interaction layer only; [`super::NodePlugin`] -/// skips them in the static nodes layer to cut work per frame. -#[derive(Clone, Debug)] -pub struct ActiveNodeDrag(pub Arc<[NodeId]>); - -/// Default throttle for [`NodeDragEvent::Tick`] ([`crate::plugins::NodeInteractionPlugin::new`]). -/// Use [`crate::plugins::NodeInteractionPlugin::with_drag_tick_interval`] to change it. -pub const NODE_DRAG_TICK_INTERVAL: Duration = Duration::from_millis(50); - -/// Primary node drag lifecycle on the canvas (left-button drag from [`super::NodeInteractionPlugin`]). -#[derive(Debug, Clone)] -pub enum NodeDragEvent { - /// Throttled while dragging; [`crate::Graph`] already holds updated positions for these nodes. - /// Same slice is reused for the whole drag (cheap [`Arc::clone`] per tick). - Tick(Arc<[NodeId]>), - /// Drag finished: click without move, or pointer released after a drag. - End, -} diff --git a/crates/ferrum-flow/src/plugins/node/interaction.rs b/crates/ferrum-flow/src/plugins/node/interaction.rs deleted file mode 100644 index 3fe9dc69a4..0000000000 --- a/crates/ferrum-flow/src/plugins/node/interaction.rs +++ /dev/null @@ -1,230 +0,0 @@ -use std::sync::Arc; -use std::time::{Duration, Instant}; - -use gpui::{MouseButton, Pixels, Point, px}; - -use crate::{ - NodeId, - canvas::{Interaction, InteractionResult}, - plugin::{EventResult, FlowEvent, InputEvent, Plugin, PluginContext}, - plugins::node::{ - ActiveNodeDrag, NODE_DRAG_TICK_INTERVAL, NodeDragEvent, - command::{DragNodesCommand, SelecteNodeCommand}, - }, -}; - -const DRAG_THRESHOLD: Pixels = px(2.0); -const DRAG_COMMAND_INTERVAL: Duration = Duration::from_millis(50); - -/// Configures [`NodeDragInteraction`] sampling for [`NodeDragEvent::Tick`]. -pub struct NodeInteractionPlugin { - drag_tick_interval: Duration, -} - -impl NodeInteractionPlugin { - pub fn new() -> Self { - Self { - drag_tick_interval: NODE_DRAG_TICK_INTERVAL, - } - } - - /// Override the drag tick interval (e.g. lower for snappier alignment feedback, higher to reduce load). - pub fn with_drag_tick_interval(interval: Duration) -> Self { - Self { - drag_tick_interval: interval, - } - } -} - -impl Default for NodeInteractionPlugin { - fn default() -> Self { - Self::new() - } -} - -impl Plugin for NodeInteractionPlugin { - fn name(&self) -> &'static str { - "node_interaction" - } - - fn on_event(&mut self, event: &FlowEvent, ctx: &mut PluginContext) -> EventResult { - if let FlowEvent::Input(InputEvent::MouseDown(ev)) = event { - if ev.button != MouseButton::Left { - return EventResult::Continue; - } - let mouse_world = ctx.screen_to_world(ev.position); - - if let Some(node_id) = ctx.hit_node(mouse_world) { - ctx.start_interaction(NodeDragInteraction::start( - node_id, - mouse_world, - ev.modifiers.shift, - self.drag_tick_interval, - )); - - return EventResult::Stop; - } else { - ctx.clear_selected_node(); - } - } - - EventResult::Continue - } - - fn priority(&self) -> i32 { - 120 - } -} - -pub struct NodeDragInteraction { - state: NodeDragState, - drag_tick_interval: Duration, - last_drag_command_at: Option, - last_node_drag_tick_at: Option, -} - -enum NodeDragState { - Pending { - node_id: NodeId, - start_mouse: Point, - shift: bool, - }, - Draging { - start_mouse: Point, - start_positions: Vec<(NodeId, Point)>, - /// Stable for this drag; cheap to [`Arc::clone`] into each [`NodeDragEvent::Tick`]. - dragged_ids: Arc<[NodeId]>, - }, -} - -impl NodeDragInteraction { - fn start( - node_id: NodeId, - start_mouse: Point, - shift: bool, - drag_tick_interval: Duration, - ) -> Self { - Self { - state: NodeDragState::Pending { - node_id, - start_mouse, - shift, - }, - drag_tick_interval, - last_drag_command_at: None, - last_node_drag_tick_at: None, - } - } -} - -impl Interaction for NodeDragInteraction { - fn on_mouse_move( - &mut self, - ev: &gpui::MouseMoveEvent, - ctx: &mut PluginContext, - ) -> crate::canvas::InteractionResult { - match &self.state { - NodeDragState::Pending { - node_id, - start_mouse, - .. - } => { - let delta = ctx.screen_to_world(ev.position) - *start_mouse; - if delta.x.abs() > DRAG_THRESHOLD || delta.y.abs() > DRAG_THRESHOLD { - let mut nodes = vec![]; - - if ctx.graph.selected_node().contains(node_id) { - for id in ctx.graph.selected_node() { - if let Some(node) = ctx.nodes().get(id) { - nodes.push((*id, node.point())); - } - } - } else if let Some(node) = ctx.nodes().get(node_id) { - nodes.push((*node_id, node.point())); - } - let dragged_ids: Arc<[NodeId]> = - nodes.iter().map(|(id, _)| *id).collect::>().into(); - self.state = NodeDragState::Draging { - start_mouse: ev.position, - start_positions: nodes, - dragged_ids: Arc::clone(&dragged_ids), - }; - ctx.shared_state.insert(ActiveNodeDrag(dragged_ids)); - - ctx.notify(); - } - } - NodeDragState::Draging { - start_mouse, - start_positions, - dragged_ids, - } => { - let dx = ctx.screen_length_to_world(ev.position.x - start_mouse.x); - let dy = ctx.screen_length_to_world(ev.position.y - start_mouse.y); - for (id, point) in start_positions.iter() { - if let Some(node) = ctx.get_node_mut(id) { - node.set_position(point.x + dx, point.y + dy); - } - } - - let now = Instant::now(); - - if ctx.has_sync_plugin() { - let should_command = self - .last_drag_command_at - .map(|t| now.duration_since(t) >= DRAG_COMMAND_INTERVAL) - .unwrap_or(true); - if should_command { - ctx.execute_command(DragNodesCommand::new(start_positions, ctx)); - self.last_drag_command_at = Some(now); - } - } - - let should_tick = self - .last_node_drag_tick_at - .map(|t| now.duration_since(t) >= self.drag_tick_interval) - .unwrap_or(true); - if should_tick { - self.last_node_drag_tick_at = Some(now); - ctx.emit(FlowEvent::custom(NodeDragEvent::Tick(Arc::clone( - dragged_ids, - )))); - } else { - ctx.notify(); - } - } - } - InteractionResult::Continue - } - fn on_mouse_up( - &mut self, - _ev: &gpui::MouseUpEvent, - ctx: &mut PluginContext, - ) -> crate::canvas::InteractionResult { - ctx.shared_state.remove::(); - match &self.state { - NodeDragState::Pending { node_id, shift, .. } => { - ctx.emit(FlowEvent::custom(NodeDragEvent::End)); - ctx.execute_command(SelecteNodeCommand::new(*node_id, *shift, ctx)); - InteractionResult::End - } - NodeDragState::Draging { - start_positions, .. - } => { - ctx.emit(FlowEvent::custom(NodeDragEvent::End)); - ctx.execute_command(DragNodesCommand::new(start_positions, ctx)); - InteractionResult::End - } - } - } - fn render(&self, ctx: &mut crate::plugin::RenderContext) -> Option { - match &self.state { - NodeDragState::Draging { dragged_ids, .. } => Some(super::render_node_cards( - ctx, - dragged_ids.as_ref(), - "draging-node-cards", - )), - NodeDragState::Pending { .. } => None, - } - } -} diff --git a/crates/ferrum-flow/src/plugins/node/mod.rs b/crates/ferrum-flow/src/plugins/node/mod.rs deleted file mode 100644 index 0bfc77d29f..0000000000 --- a/crates/ferrum-flow/src/plugins/node/mod.rs +++ /dev/null @@ -1,131 +0,0 @@ -mod command; -mod drag_events; -mod interaction; - -pub use command::DragNodesCommand; -pub use drag_events::{ActiveNodeDrag, NODE_DRAG_TICK_INTERVAL, NodeDragEvent}; -use gpui::{Element as _, ElementId, InteractiveElement as _, ParentElement, div}; -pub use interaction::NodeInteractionPlugin; - -/// Renders the given nodes (and their ports) like [`NodePlugin`], for use on the interaction overlay. -pub(super) fn render_node_cards( - ctx: &mut RenderContext, - node_ids: &[crate::NodeId], - id: &'static str, -) -> gpui::AnyElement { - ctx.cache_port_offset_with_nodes(node_ids); - let list = node_ids.iter().filter_map(|node_id| { - let node = ctx.graph.nodes().get(node_id)?; - let render = ctx.renderers.get(node.renderer_key()); - - let node_render = render.render(node, ctx); - - let port_ids: Vec = ctx.cached_port_ids_for_node(node_id).collect(); - let ports = port_ids.iter().filter_map(|port_id| { - let port = ctx.graph.get_port(port_id)?; - render.port_render(node, port, ctx) - }); - - Some( - div() - .id(ElementId::Uuid(*node_id.as_uuid())) - .child(node_render) - .children(ports), - ) - }); - - div().id(id).children(list).into_any() -} - -use std::sync::Arc; - -use crate::NodeId; -use crate::plugin::{Plugin, RenderContext}; -use crate::viewport::ViewportVisibilityCacheKey; - -/// Invalidates [`NodePlugin::static_layer_node_ids`] when the viewport changes **or** the active -/// node-drag overlay set changes ([`ActiveNodeDrag`] `Arc` identity + length). -#[derive(Clone, Copy, Debug, PartialEq)] -struct NodeStaticLayerCacheKey { - viewport: ViewportVisibilityCacheKey, - nodes_len: usize, - node_order_len: usize, - node_order_tail: Option, - /// `None` when not dragging; else [`Arc::as_ptr`] + len of the shared drag id list. - drag_arc: Option<(usize, usize)>, -} - -impl NodeStaticLayerCacheKey { - fn from_render_ctx(ctx: &RenderContext) -> Self { - let drag = ctx.get_shared_state::(); - Self { - viewport: ctx.viewport().visibility_cache_key(), - nodes_len: ctx.graph.nodes().len(), - node_order_len: ctx.graph.node_order().len(), - node_order_tail: ctx - .graph - .node_order() - .last() - .map(|id| id.as_uuid().as_u128()), - drag_arc: drag.map(|d| { - let p = Arc::as_ptr(&d.0); - (p.cast::() as usize, d.0.len()) - }), - } - } -} - -pub struct NodePlugin { - static_layer_cache_key: Option, - /// Viewport-visible nodes for the static [`RenderLayer::Nodes`] layer, already excluding - /// [`ActiveNodeDrag`] ids (those render on the interaction overlay). - static_layer_node_ids: Vec, -} - -impl NodePlugin { - pub fn new() -> Self { - Self { - static_layer_cache_key: None, - static_layer_node_ids: Vec::new(), - } - } -} - -impl Default for NodePlugin { - fn default() -> Self { - Self::new() - } -} - -impl Plugin for NodePlugin { - fn name(&self) -> &'static str { - "node" - } - fn priority(&self) -> i32 { - 60 - } - fn render_layer(&self) -> crate::plugin::RenderLayer { - crate::plugin::RenderLayer::Nodes - } - fn render(&mut self, ctx: &mut RenderContext) -> Option { - let key = NodeStaticLayerCacheKey::from_render_ctx(ctx); - if self.static_layer_cache_key != Some(key) { - self.static_layer_cache_key = Some(key); - let active = ctx.get_shared_state::(); - self.static_layer_node_ids = ctx - .graph - .node_order() - .iter() - .filter(|node_id| ctx.is_node_visible(node_id)) - .filter(|node_id| !active.is_some_and(|d| d.0.contains(node_id))) - .copied() - .collect(); - } - - Some(render_node_cards( - ctx, - &self.static_layer_node_ids, - "static-layer-node-cards", - )) - } -} diff --git a/crates/ferrum-flow/src/plugins/port/command.rs b/crates/ferrum-flow/src/plugins/port/command.rs deleted file mode 100644 index fed682bfec..0000000000 --- a/crates/ferrum-flow/src/plugins/port/command.rs +++ /dev/null @@ -1,158 +0,0 @@ -use crate::{Edge, GraphOp, Node, Port, canvas::Command}; - -pub struct CreateEdge { - edge: Edge, -} - -impl CreateEdge { - pub fn new(edge: Edge) -> Self { - Self { edge } - } -} - -impl Command for CreateEdge { - fn name(&self) -> &'static str { - "create_edge" - } - fn execute(&mut self, ctx: &mut crate::canvas::CommandContext) { - ctx.add_edge(self.edge.clone()); - } - fn undo(&mut self, ctx: &mut crate::canvas::CommandContext) { - ctx.remove_edge(&self.edge.id); - } - - fn to_ops(&self, _ctx: &mut crate::CommandContext) -> Vec { - vec![GraphOp::AddEdge(self.edge.clone())] - } -} - -pub struct CreateNode { - node: Node, -} - -impl CreateNode { - pub fn new(node: Node) -> Self { - Self { node } - } -} - -impl Command for CreateNode { - fn name(&self) -> &'static str { - "create_node" - } - fn execute(&mut self, ctx: &mut crate::canvas::CommandContext) { - ctx.add_node(self.node.clone()); - } - fn to_ops(&self, _ctx: &mut crate::CommandContext) -> Vec { - vec![ - GraphOp::AddNode(self.node.clone()), - GraphOp::NodeOrderInsert { id: self.node.id() }, - ] - } - fn undo(&mut self, ctx: &mut crate::canvas::CommandContext) { - ctx.remove_node(&self.node.id()); - } -} - -pub struct CreatePort { - port: Port, -} - -impl CreatePort { - pub fn new(port: Port) -> Self { - Self { port } - } -} - -impl Command for CreatePort { - fn name(&self) -> &'static str { - "create_port" - } - fn execute(&mut self, ctx: &mut crate::canvas::CommandContext) { - ctx.add_port(self.port.clone()); - ctx.port_offset_cache.clear_node(&self.port.node_id()); - } - fn to_ops(&self, _ctx: &mut crate::CommandContext) -> Vec { - vec![GraphOp::AddPort(self.port.clone())] - } - fn undo(&mut self, ctx: &mut crate::canvas::CommandContext) { - let node_id = self.port.node_id(); - ctx.remove_port(&self.port.id()); - ctx.port_offset_cache.clear_node(&node_id); - } -} - -#[cfg(test)] -mod command_interop_tests { - use serde_json::json; - - use crate::{ - CreateEdge, CreateNode, CreatePort, Graph, PortBuilder, PortKind, PortPosition, PortType, - command_interop::assert_command_interop, - }; - - #[test] - fn create_node_command_interop() { - let mut base = Graph::new(); - let (node, _ports, _) = base - .create_node("x") - .position(100.0, 80.0) - .data(json!({ "k": "v" })) - .build_raw(); - - assert_command_interop( - &base, - || Box::new(CreateNode::new(node.clone())), - "CreateNode", - ); - } - - #[test] - fn create_port_command_interop() { - let mut base = Graph::new(); - let node_id = base.create_node("x").position(0.0, 0.0).build().unwrap(); - let port = PortBuilder::new(base.next_port_id()) - .kind(PortKind::Output) - .node_id(node_id) - .index(0) - .position(PortPosition::Right) - .size(12.0, 12.0) - .port_type(PortType::Any) - .build(); - - assert_command_interop( - &base, - || Box::new(CreatePort::new(port.clone())), - "CreatePort", - ); - } - - #[test] - fn create_edge_command_interop() { - let mut base = Graph::new(); - let n1 = base - .create_node("a") - .position(0.0, 0.0) - .output() - .build() - .unwrap(); - let n2 = base - .create_node("b") - .position(100.0, 0.0) - .input() - .build() - .unwrap(); - let n1_node = base.get_node(&n1).expect("source node exists"); - let n2_node = base.get_node(&n2).expect("target node exists"); - let edge = base - .new_edge() - .source(n1_node.outputs()[0]) - .target(n2_node.inputs()[0]); - - assert_command_interop( - &base, - || Box::new(CreateEdge::new(edge.clone())), - "CreateEdge", - ); - } -} diff --git a/crates/ferrum-flow/src/plugins/port/interaction.rs b/crates/ferrum-flow/src/plugins/port/interaction.rs deleted file mode 100644 index 880eb3659e..0000000000 --- a/crates/ferrum-flow/src/plugins/port/interaction.rs +++ /dev/null @@ -1,458 +0,0 @@ -use std::{collections::HashSet, sync::Arc}; - -use gpui::{Bounds, Element, MouseButton, Pixels, Point, Styled as _, canvas, px, rgb}; - -use crate::{ - DefaultEdgeValidator, EdgeValidator, Graph, PortId, PortKind, PortPosition, ToastMessage, - canvas::Interaction, - plugin::{FlowEvent, InputEvent, Plugin, RenderContext}, - plugins::port::{edge_bezier, filled_disc_path, port_screen_big_bounds, port_screen_bounds}, -}; - -use super::command::CreateEdge; - -/// Dangling link from a port to a world-space endpoint (shown with a dot until the user clicks it). -#[derive(Clone, Copy)] -struct PendingPortLink { - source_port: PortId, - end_world: Point, -} - -/// Internal: interaction finished on empty canvas — queue for [`PortInteractionPlugin`]. -#[derive(Clone, Copy)] -struct PendingLinkCommitted { - source_port: PortId, - end_world: Point, -} - -pub struct PortInteractionPlugin { - pending: Option, - validator: Arc, -} - -impl Default for PortInteractionPlugin { - fn default() -> Self { - Self::new() - } -} - -impl PortInteractionPlugin { - pub fn new() -> Self { - Self { - pending: None, - validator: Arc::new(DefaultEdgeValidator), - } - } - - pub fn validator(mut self, validator: impl EdgeValidator + 'static) -> Self { - self.validator = Arc::new(validator); - self - } - - fn facing_position(p: PortPosition) -> PortPosition { - match p { - PortPosition::Left => PortPosition::Right, - PortPosition::Right => PortPosition::Left, - PortPosition::Top => PortPosition::Bottom, - PortPosition::Bottom => PortPosition::Top, - } - } - - fn pending_dot_contains_screen( - ctx: &crate::plugin::PluginContext, - end_world: Point, - screen: Point, - ) -> bool { - let c = ctx.world_to_screen(end_world); - let dx: f32 = (screen.x - c.x).into(); - let dy: f32 = (screen.y - c.y).into(); - let rf: f32 = px(10.0).into(); - dx * dx + dy * dy <= rf * rf - } - - fn finish_pending_link(&mut self, ctx: &mut crate::plugin::PluginContext, p: PendingPortLink) { - let Some(source) = ctx.graph.get_port(&p.source_port).cloned() else { - return; - }; - - let mut builder = ctx.create_node(""); - builder = match source.kind() { - PortKind::Output => builder.input(), - PortKind::Input => builder.output(), - }; - - let (mut new_node, new_ports, _) = builder.build_raw(); - - let Some(connect_port) = (match source.kind() { - PortKind::Output => new_ports.iter().find(|p| p.kind() == PortKind::Input), - PortKind::Input => new_ports.iter().find(|p| p.kind() == PortKind::Output), - }) else { - return; - }; - - let mut scratch = Graph::new(); - scratch.add_node(new_node.clone()); - for port in &new_ports { - scratch.add_port(port.clone()); - } - let offset = ctx.port_world_offset_relative(&scratch, &new_node, connect_port); - new_node.set_position_with_point(Point::new( - p.end_world.x - offset.x, - p.end_world.y - offset.y, - )); - - let edge = match source.kind() { - PortKind::Output => { - let Some(in_port) = new_node.inputs().first().copied() else { - return; - }; - ctx.new_edge().source(p.source_port).target(in_port) - } - PortKind::Input => { - let Some(out_port) = new_node.outputs().first().copied() else { - return; - }; - ctx.new_edge().source(out_port).target(p.source_port) - } - }; - - ctx.execute_command(super::command::CreateNode::new(new_node)); - for port in new_ports { - ctx.execute_command(super::command::CreatePort::new(port)); - } - - ctx.execute_command(CreateEdge::new(edge)); - } - - #[allow(clippy::too_many_arguments)] - fn paint_wire_and_dot( - win: &mut gpui::Window, - origin: Point, - start: Point, - end: Point, - start_position: PortPosition, - target_position: PortPosition, - viewport: &crate::Viewport, - line_rgb: u32, - dot_rgb: u32, - ) { - if let Ok(path) = edge_bezier( - start + origin, - start_position, - target_position, - end + origin, - viewport, - ) { - win.paint_path(path, rgb(line_rgb)); - } - if let Ok(dot) = filled_disc_path(end + origin, px(6.0)) { - win.paint_path(dot, rgb(dot_rgb)); - } - } -} - -impl Plugin for PortInteractionPlugin { - fn name(&self) -> &'static str { - "port_interaction" - } - - fn on_event( - &mut self, - event: &FlowEvent, - ctx: &mut crate::plugin::PluginContext, - ) -> crate::plugin::EventResult { - if let Some(p) = event.as_custom::() { - self.pending = Some(PendingPortLink { - source_port: p.source_port, - end_world: p.end_world, - }); - return crate::plugin::EventResult::Stop; - } - - if let FlowEvent::Input(InputEvent::MouseDown(ev)) = event { - if ev.button != MouseButton::Left { - return crate::plugin::EventResult::Continue; - } - if let Some(pend) = self.pending - && Self::pending_dot_contains_screen(ctx, pend.end_world, ev.position) - { - self.pending = None; - self.finish_pending_link(ctx, pend); - return crate::plugin::EventResult::Stop; - } - - let visible_nodes: HashSet<_> = ctx - .graph - .nodes() - .iter() - .filter(|(_, node)| ctx.is_node_visible_node(node)) - .map(|(id, _)| *id) - .collect(); - let candidate_ports: Vec = ctx - .graph - .ports() - .iter() - .filter(|(_, port)| visible_nodes.contains(&port.node_id())) - .map(|(_, port)| (port.id(), port.position())) - .filter_map(|(id, position)| { - let bounds = port_screen_bounds(id, ctx)?; - let big_bounds = port_screen_big_bounds(id, ctx)?; - Some(PortHitCandidate { - id, - position, - bounds, - big_bounds, - }) - }) - .collect(); - - let mouse_world = ctx.screen_to_world(ev.position); - let port_hit = candidate_ports - .iter() - .find(|c| c.bounds.contains(&mouse_world)) - .map(|c| (c.id, c.position)); - - if let Some((port_id, position)) = port_hit { - self.pending = None; - ctx.start_interaction(PortConnecting { - port_id, - position, - target_position: PortPosition::Left, - candidate_ports, - mouse: Some(ev.position), - validator: self.validator.clone(), - validation_error: None, - hovered_port: None, - }); - return crate::plugin::EventResult::Stop; - } - - if self.pending.take().is_some() { - ctx.notify(); - } - } - - crate::plugin::EventResult::Continue - } - - fn priority(&self) -> i32 { - 125 - } - - fn render(&mut self, ctx: &mut RenderContext) -> Option { - let p = self.pending.as_ref()?; - let start = ctx.port_screen_center_by_port_id(p.source_port)?; - let end = ctx.world_to_screen(p.end_world); - let source_port = ctx.graph.get_port(&p.source_port)?; - let start_position = source_port.position(); - let target_position = Self::facing_position(start_position); - let viewport = ctx.viewport().clone(); - let line_rgb = ctx.theme.port_preview_line; - let dot_rgb = ctx.theme.port_preview_dot; - - Some( - canvas( - move |_, _, _| (start_position, target_position, viewport, line_rgb, dot_rgb), - move |bounds, (sp, tp, vp, lr, dr), win, _| { - Self::paint_wire_and_dot(win, bounds.origin, start, end, sp, tp, &vp, lr, dr); - }, - ) - .absolute() - .size_full() - .into_any(), - ) - } - - fn render_layer(&self) -> crate::plugin::RenderLayer { - crate::plugin::RenderLayer::Interaction - } -} - -struct PortConnecting { - port_id: PortId, - position: PortPosition, - target_position: PortPosition, - /// Visible port candidates captured when the interaction starts with precomputed hit bounds. - candidate_ports: Vec, - /// Cursor in **screen** space (matches port screen center / bezier end). - mouse: Option>, - validator: Arc, - /// Validation state for current drag target. `Some(Err)` means invalid link preview. - validation_error: Option<()>, - /// Candidate port currently hovered by cursor (if any). - hovered_port: Option, -} - -#[derive(Clone, Copy)] -struct PortHitCandidate { - id: PortId, - position: PortPosition, - bounds: Bounds, - big_bounds: Bounds, -} - -impl Interaction for PortConnecting { - fn on_mouse_move( - &mut self, - event: &gpui::MouseMoveEvent, - ctx: &mut crate::plugin::PluginContext, - ) -> crate::canvas::InteractionResult { - self.mouse = Some(event.position); - let mouse_world = ctx.screen_to_world(event.position); - self.validation_error = None; - self.hovered_port = None; - if let Some(candidate) = self - .candidate_ports - .iter() - .find(|c| c.big_bounds.contains(&mouse_world)) - { - let port_id = candidate.id; - if port_id != self.port_id { - self.target_position = candidate.position; - self.hovered_port = Some(port_id); - - let Some(source_port) = ctx.graph.get_port(&self.port_id) else { - ctx.notify(); - return crate::canvas::InteractionResult::Continue; - }; - let Some(target_port) = ctx.graph.get_port(&port_id) else { - ctx.notify(); - return crate::canvas::InteractionResult::Continue; - }; - - let (source_port, target_port) = match (source_port.kind(), target_port.kind()) { - (PortKind::Input, PortKind::Output) => (target_port, source_port), - _ => (source_port, target_port), - }; - - if self - .validator - .validate(source_port, target_port, ctx) - .is_err() - { - self.validation_error = Some(()); - } - } - } - ctx.notify(); - crate::canvas::InteractionResult::Continue - } - - fn on_mouse_up( - &mut self, - ev: &gpui::MouseUpEvent, - ctx: &mut crate::plugin::PluginContext, - ) -> crate::canvas::InteractionResult { - let mouse_world = ctx.screen_to_world(ev.position); - if let Some(candidate) = self - .candidate_ports - .iter() - .find(|c| c.bounds.contains(&mouse_world)) - { - let port_id = candidate.id; - let Some(target_port) = ctx.graph.get_port(&port_id) else { - return crate::canvas::InteractionResult::End; - }; - let Some(source_port) = ctx.graph.get_port(&self.port_id) else { - return crate::canvas::InteractionResult::End; - }; - - let (source_port, target_port) = match (source_port.kind(), target_port.kind()) { - (PortKind::Input, PortKind::Output) => (target_port, source_port), - _ => (source_port, target_port), - }; - - match self.validator.validate(source_port, target_port, ctx) { - Ok(_) => { - let edge = ctx - .new_edge() - .source(source_port.id()) - .target(target_port.id()); - ctx.execute_command(CreateEdge::new(edge)); - } - Err(err) => { - ctx.emit(FlowEvent::custom(ToastMessage::error( - err.message().to_string(), - ))); - } - } - - return crate::canvas::InteractionResult::End; - } - - ctx.emit(FlowEvent::custom(PendingLinkCommitted { - source_port: self.port_id, - end_world: mouse_world, - })); - crate::canvas::InteractionResult::End - } - - fn render(&self, ctx: &mut RenderContext) -> Option { - let mouse = self.mouse?; - let start = ctx.port_screen_center_by_port_id(self.port_id)?; - let position = self.position; - let target_position = self.target_position; - let viewport = ctx.viewport().clone(); - let has_validation_error = self.validation_error.is_some(); - let line_rgb = if has_validation_error { - ctx.theme.error - } else { - ctx.theme.port_preview_line - }; - let dot_rgb = if has_validation_error { - ctx.theme.error - } else { - ctx.theme.port_preview_dot - }; - let target_highlight = if has_validation_error { - self.hovered_port.and_then(|port_id| { - let port = ctx.graph.get_port(&port_id)?; - let center = ctx.port_screen_center_by_port_id(port_id)?; - let size = *port.size_ref(); - let width: f32 = (size.width * ctx.viewport().zoom()).into(); - let height: f32 = (size.height * ctx.viewport().zoom()).into(); - let radius = px(width.min(height) / 2.0); - Some((center, radius)) - }) - } else { - None - }; - - Some( - canvas( - move |_, _, _| { - ( - position, - target_position, - viewport, - line_rgb, - dot_rgb, - target_highlight, - ) - }, - move |bounds, (position, target_position, viewport, lr, dr, th), win, _| { - let origin = bounds.origin; - PortInteractionPlugin::paint_wire_and_dot( - win, - origin, - start, - mouse, - position, - target_position, - &viewport, - lr, - dr, - ); - if let Some((center, radius)) = th - && let Ok(dot) = filled_disc_path(center + origin, radius) - { - win.paint_path(dot, rgb(lr)); - } - }, - ) - .absolute() - .size_full() - .into_any(), - ) - } -} diff --git a/crates/ferrum-flow/src/plugins/port/mod.rs b/crates/ferrum-flow/src/plugins/port/mod.rs deleted file mode 100644 index 193403abad..0000000000 --- a/crates/ferrum-flow/src/plugins/port/mod.rs +++ /dev/null @@ -1,16 +0,0 @@ -mod interaction; -mod utils; - -pub use interaction::PortInteractionPlugin; - -mod command; -mod validator; - -pub use command::{CreateEdge, CreateNode, CreatePort}; -pub use validator::{ - DefaultEdgeValidator, EdgeValidationError, EdgeValidationErrorCode, EdgeValidator, -}; - -#[allow(deprecated, unused_imports)] -pub use utils::port_screen_position; -pub use utils::{edge_bezier, filled_disc_path, port_screen_big_bounds, port_screen_bounds}; diff --git a/crates/ferrum-flow/src/plugins/port/utils.rs b/crates/ferrum-flow/src/plugins/port/utils.rs deleted file mode 100644 index 973a8f787b..0000000000 --- a/crates/ferrum-flow/src/plugins/port/utils.rs +++ /dev/null @@ -1,81 +0,0 @@ -use gpui::{Bounds, Path, PathBuilder, Pixels, Point, Size, px}; - -use crate::{PortId, PortPosition, RenderContext, Viewport}; - -#[deprecated(note = "use `ctx.port_screen_center_by_port_id(port_id)`")] -#[allow(dead_code)] // kept for re-export; callers should migrate to `RenderContext` methods -pub fn port_screen_position(port_id: PortId, ctx: &RenderContext) -> Option> { - ctx.port_screen_center_by_port_id(port_id) -} - -pub fn port_screen_bounds( - port_id: PortId, - ctx: &crate::plugin::PluginContext, -) -> Option> { - let port = &ctx.graph.get_port(&port_id)?; - let node = &ctx.nodes().get(&port.node_id())?; - - let node_pos = node.point(); - - let offset = ctx.port_offset_cached(&port.node_id(), &port_id)?; - let size = *port.size_ref(); - - Some(Bounds::new( - node_pos + offset - Point::new(size.width / 2.0, size.height / 2.0), - size, - )) -} - -pub fn port_screen_big_bounds( - port_id: PortId, - ctx: &crate::plugin::PluginContext, -) -> Option> { - let mut bounds = port_screen_bounds(port_id, ctx)?; - - let offset_width = px(15.0) - bounds.size.width / 2.0; - let offset_height = px(15.0) - bounds.size.height / 2.0; - - bounds.origin -= Point::new(offset_width, offset_height); - - bounds.size = Size { - width: px(30.0), - height: px(30.0), - }; - - Some(bounds) -} - -/// Filled circle in screen space (for dangling-connection endpoint marker). -pub fn filled_disc_path( - center: Point, - radius: Pixels, -) -> Result, anyhow::Error> { - let r: f32 = radius.into(); - let cx: f32 = center.x.into(); - let cy: f32 = center.y.into(); - const SEGMENTS: usize = 28; - let mut pts: Vec> = Vec::with_capacity(SEGMENTS); - for i in 0..SEGMENTS { - let t = i as f32 / SEGMENTS as f32 * std::f32::consts::TAU; - pts.push(Point::new(px(cx + r * t.cos()), px(cy + r * t.sin()))); - } - let mut pb = PathBuilder::fill(); - pb.add_polygon(&pts, true); - pb.build() -} - -pub fn edge_bezier( - start: Point, - start_position: PortPosition, - end_poisition: PortPosition, - end: Point, - viewport: &Viewport, -) -> Result, anyhow::Error> { - let control_a = viewport.edge_control_point(start, start_position); - let control_b = viewport.edge_control_point(end, end_poisition); - let mut line = PathBuilder::stroke(px(1.0)); - line.move_to(start); - line.cubic_bezier_to(end, control_a, control_b); - - line.build() -} diff --git a/crates/ferrum-flow/src/plugins/port/validator.rs b/crates/ferrum-flow/src/plugins/port/validator.rs deleted file mode 100644 index 75f9126463..0000000000 --- a/crates/ferrum-flow/src/plugins/port/validator.rs +++ /dev/null @@ -1,111 +0,0 @@ -use std::fmt::Display; - -use crate::{PluginContext, Port, PortKind}; - -/// Validates whether an edge may be created between two ports. -pub trait EdgeValidator: Send + Sync { - fn validate( - &self, - from: &Port, - to: &Port, - ctx: &PluginContext, - ) -> Result<(), EdgeValidationError>; -} - -#[derive(Debug, Clone)] -pub struct EdgeValidationError { - code: EdgeValidationErrorCode, - message: String, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum EdgeValidationErrorCode { - /// The two ports are not one output and one input. - KindMismatch, - /// Both ports belong to the same node. - SameNode, - /// Port types are incompatible (reserved for stricter validators). - TypeMismatch, - /// Target input already has a connection (reserved for stricter validators). - AlreadyConnected, - /// Plugin-specific failure reason. - Custom(String), -} - -impl EdgeValidationError { - pub fn new(code: EdgeValidationErrorCode, message: String) -> Self { - Self { code, message } - } - - pub fn kind_mismatch(message: String) -> Self { - Self::new(EdgeValidationErrorCode::KindMismatch, message) - } - - pub fn same_node(message: String) -> Self { - Self::new(EdgeValidationErrorCode::SameNode, message) - } - - pub fn type_mismatch(message: String) -> Self { - Self::new(EdgeValidationErrorCode::TypeMismatch, message) - } - - pub fn already_connected(message: String) -> Self { - Self::new(EdgeValidationErrorCode::AlreadyConnected, message) - } - - pub fn custom(ty: String, message: String) -> Self { - Self::new(EdgeValidationErrorCode::Custom(ty), message) - } - - pub fn code(&self) -> &EdgeValidationErrorCode { - &self.code - } - - pub fn message(&self) -> &str { - &self.message - } -} - -impl Display for EdgeValidationErrorCode { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - EdgeValidationErrorCode::KindMismatch => write!(f, "KindMismatch"), - EdgeValidationErrorCode::SameNode => write!(f, "SameNode"), - EdgeValidationErrorCode::TypeMismatch => write!(f, "TypeMismatch"), - EdgeValidationErrorCode::AlreadyConnected => write!(f, "AlreadyConnected"), - EdgeValidationErrorCode::Custom(ty) => write!(f, "Custom({})", ty), - } - } -} - -/// Permissive default: requires one output and one input on different nodes; ignores -/// `port_type` and does not check for duplicate edges. -#[derive(Debug, Default, Clone, Copy)] -pub struct DefaultEdgeValidator; - -impl EdgeValidator for DefaultEdgeValidator { - fn validate( - &self, - from: &Port, - to: &Port, - _ctx: &PluginContext, - ) -> Result<(), EdgeValidationError> { - if from.node_id() == to.node_id() { - return Err(EdgeValidationError::same_node( - "Cannot connect two ports on the same node.".into(), - )); - } - - let one_output_one_input = matches!( - (from.kind(), to.kind()), - (PortKind::Output, PortKind::Input) | (PortKind::Input, PortKind::Output) - ); - if !one_output_one_input { - return Err(EdgeValidationError::kind_mismatch( - "A connection must be between an output port and an input port.".into(), - )); - } - - Ok(()) - } -} diff --git a/crates/ferrum-flow/src/plugins/select_all_viewport.rs b/crates/ferrum-flow/src/plugins/select_all_viewport.rs deleted file mode 100644 index 3e8dbab2a2..0000000000 --- a/crates/ferrum-flow/src/plugins/select_all_viewport.rs +++ /dev/null @@ -1,68 +0,0 @@ -use std::collections::HashSet; - -use crate::plugin::{FlowEvent, Plugin, PluginContext, primary_platform_modifier}; - -/// Select every node and edge that intersects the current window viewport (⌘A / Ctrl+A). -pub struct SelectAllViewportPlugin; - -impl SelectAllViewportPlugin { - pub fn new() -> Self { - Self - } -} - -impl Default for SelectAllViewportPlugin { - fn default() -> Self { - Self::new() - } -} - -fn select_visible(ctx: &mut PluginContext) { - let visible_nodes: HashSet<_> = ctx - .graph - .node_order() - .iter() - .filter(|id| ctx.is_node_visible(id)) - .copied() - .collect(); - - let visible_edges: HashSet<_> = ctx - .graph - .edges_values() - .filter(|e| ctx.is_edge_visible(e)) - .map(|e| e.id) - .collect(); - - ctx.graph.set_selected_node(visible_nodes); - ctx.graph.set_selected_edge(visible_edges); -} - -pub(crate) fn select_all_in_viewport(ctx: &mut PluginContext) { - select_visible(ctx); -} - -impl Plugin for SelectAllViewportPlugin { - fn name(&self) -> &'static str { - "select_all_viewport" - } - - fn priority(&self) -> i32 { - 93 - } - - fn on_event( - &mut self, - event: &FlowEvent, - ctx: &mut PluginContext, - ) -> crate::plugin::EventResult { - if let FlowEvent::Input(crate::plugin::InputEvent::KeyDown(ev)) = event - && primary_platform_modifier(ev) - && ev.keystroke.key == "a" - { - select_visible(ctx); - ctx.notify(); - return crate::plugin::EventResult::Stop; - } - crate::plugin::EventResult::Continue - } -} diff --git a/crates/ferrum-flow/src/plugins/selection/mod.rs b/crates/ferrum-flow/src/plugins/selection/mod.rs deleted file mode 100644 index b21ea004fb..0000000000 --- a/crates/ferrum-flow/src/plugins/selection/mod.rs +++ /dev/null @@ -1,340 +0,0 @@ -use std::collections::HashMap; -use std::time::{Duration, Instant}; - -use gpui::{ - AnyElement, Bounds, Element, MouseButton, MouseMoveEvent, MouseUpEvent, Pixels, Point, Size, - Styled, div, px, rgb, rgba, -}; - -use crate::{ - FlowTheme, NodeId, - canvas::{Interaction, InteractionResult}, - plugin::{ - EventResult, FlowEvent, InputEvent, Plugin, PluginContext, RenderContext, RenderLayer, - }, -}; - -const DRAG_THRESHOLD: Pixels = px(2.0); -const DRAG_COMMAND_INTERVAL: Duration = Duration::from_millis(50); - -pub struct SelectionPlugin { - selected: Option, -} - -struct Selected { - bounds: Bounds, - nodes: HashMap>, -} - -impl SelectionPlugin { - pub fn new() -> Self { - Self { selected: None } - } -} - -impl Default for SelectionPlugin { - fn default() -> Self { - Self::new() - } -} - -impl Plugin for SelectionPlugin { - fn name(&self) -> &'static str { - "selection" - } - - fn on_event( - &mut self, - event: &FlowEvent, - ctx: &mut crate::plugin::PluginContext, - ) -> EventResult { - if let FlowEvent::Input(InputEvent::MouseDown(ev)) = event { - if ev.button != MouseButton::Left { - return EventResult::Continue; - } - if !ev.modifiers.shift { - let start = ctx.screen_to_world(ev.position); - if let Some(Selected { bounds, nodes }) = self.selected.take() - && bounds.contains(&start) - { - ctx.start_interaction(SelectionInteraction::start_move(start, bounds, nodes)); - - return EventResult::Stop; - } - - ctx.start_interaction(SelectionInteraction::new(start)); - return EventResult::Stop; - } - } else if let Some(SelectedEvent { bounds, nodes }) = event.as_custom() { - self.selected = if nodes.is_empty() { - None - } else { - Some(Selected { - bounds: *bounds, - nodes: nodes.clone(), - }) - }; - return EventResult::Stop; - } else if let FlowEvent::Input(InputEvent::Hover(false)) = event { - self.selected = None; - } - EventResult::Continue - } - - fn priority(&self) -> i32 { - 100 - } - - fn render_layer(&self) -> RenderLayer { - RenderLayer::Selection - } - - fn render(&mut self, ctx: &mut RenderContext) -> Option { - self.selected.as_ref().map(|Selected { bounds, .. }| { - let top_left = ctx.world_to_screen(bounds.origin); - - let size = Size::new( - ctx.world_length_to_screen(bounds.size.width), - ctx.world_length_to_screen(bounds.size.height), - ); - render_rect(Bounds::new(top_left, size), ctx.theme) - }) - } -} - -pub struct SelectionInteraction { - state: SelectionState, - last_drag_command_at: Option, -} - -enum SelectionState { - Pending { - start: Point, - }, - Selecting { - start: Point, - end: Point, - }, - Moving { - start_mouse: Point, - start_bounds: Bounds, - bounds: Bounds, - nodes: HashMap>, - }, -} -struct SelectedEvent { - bounds: Bounds, - nodes: HashMap>, -} - -impl SelectionInteraction { - pub fn new(start: Point) -> Self { - Self { - state: SelectionState::Pending { start }, - last_drag_command_at: None, - } - } - pub fn start_move( - mouse: Point, - bounds: Bounds, - nodes: HashMap>, - ) -> Self { - Self { - state: SelectionState::Moving { - start_mouse: mouse, - start_bounds: bounds, - bounds, - nodes, - }, - last_drag_command_at: None, - } - } -} - -impl Interaction for SelectionInteraction { - fn on_mouse_move(&mut self, ev: &MouseMoveEvent, ctx: &mut PluginContext) -> InteractionResult { - let mouse_world = ctx.screen_to_world(ev.position); - match &mut self.state { - SelectionState::Pending { start } => { - let delta = mouse_world - *start; - - if delta.x.abs() > DRAG_THRESHOLD && delta.y.abs() > DRAG_THRESHOLD { - self.state = SelectionState::Selecting { - start: *start, - end: mouse_world, - }; - - ctx.notify(); - } - } - - SelectionState::Selecting { end, .. } => { - *end = mouse_world; - ctx.notify(); - } - - SelectionState::Moving { - start_mouse, - start_bounds, - bounds, - nodes, - } => { - let delta = mouse_world - *start_mouse; - - for (id, start_pos) in nodes.iter() { - if let Some(node) = ctx.get_node_mut(id) { - node.set_position(start_pos.x + delta.x, start_pos.y + delta.y); - } - } - *bounds = Bounds::new(start_bounds.origin + delta, start_bounds.size); - - if ctx.has_sync_plugin() { - let now = Instant::now(); - let should_command = self - .last_drag_command_at - .map(|t| now.duration_since(t) >= DRAG_COMMAND_INTERVAL) - .unwrap_or(true); - if should_command { - let start_position: Vec<_> = - nodes.iter().map(|(id, point)| (*id, *point)).collect(); - ctx.execute_command(super::node::DragNodesCommand::new( - &start_position, - ctx, - )); - self.last_drag_command_at = Some(now); - } - } - - ctx.notify(); - } - } - - InteractionResult::Continue - } - fn on_mouse_up(&mut self, _ev: &MouseUpEvent, ctx: &mut PluginContext) -> InteractionResult { - match &mut self.state { - SelectionState::Pending { .. } => InteractionResult::End, - - SelectionState::Selecting { start, end } => { - let rect = normalize_rect(*start, *end); - - ctx.clear_selected_node(); - - let mut nodes: HashMap> = HashMap::new(); - let mut min_x = f32::MAX; - let mut min_y = f32::MAX; - let mut max_x = f32::MIN; - let mut max_y = f32::MIN; - - for node in ctx - .graph - .nodes() - .values() - .filter(|node| ctx.is_node_visible_node(node)) - .filter(|node| rect.intersects(&node.bounds())) - { - let (x, y) = node.position(); - let size = *node.size_ref(); - nodes.insert(node.id(), node.point()); - min_x = min_x.min(x.into()); - min_y = min_y.min(y.into()); - max_x = max_x.max((x + size.width).into()); - max_y = max_y.max((y + size.height).into()); - } - - for id in nodes.keys().copied() { - ctx.add_selected_node(id, true); - } - - let bounds = if nodes.is_empty() { - rect - } else { - Bounds::new( - Point::new(px(min_x), px(min_y)), - Size::new(px(max_x - min_x), px(max_y - min_y)), - ) - }; - - ctx.cancel_interaction(); - ctx.emit(FlowEvent::custom(SelectedEvent { bounds, nodes })); - - InteractionResult::End - } - - SelectionState::Moving { bounds, nodes, .. } => { - let bounds = *bounds; - - let mut new_nodes = HashMap::new(); - for (id, _) in nodes.iter() { - ctx.add_selected_node(*id, true); - if let Some(node) = ctx.get_node(id) { - new_nodes.insert(*id, node.point()); - } - } - - let start_position: Vec<_> = - nodes.iter().map(|(id, point)| (*id, *point)).collect(); - - ctx.execute_command(super::node::DragNodesCommand::new(&start_position, ctx)); - - ctx.emit(FlowEvent::custom(SelectedEvent { - bounds, - nodes: new_nodes, - })); - - InteractionResult::End - } - } - } - fn render(&self, ctx: &mut RenderContext) -> Option { - match &self.state { - SelectionState::Selecting { start, end } => { - let rect = normalize_rect(*start, *end); - - let top_left = ctx.world_to_screen(rect.origin); - - let size = Size::new( - ctx.world_length_to_screen(rect.size.width), - ctx.world_length_to_screen(rect.size.height), - ); - - Some(render_rect(Bounds::new(top_left, size), ctx.theme)) - } - - SelectionState::Moving { bounds, .. } => { - let top_left = ctx.world_to_screen(bounds.origin); - - let size = Size::new( - ctx.world_length_to_screen(bounds.size.width), - ctx.world_length_to_screen(bounds.size.height), - ); - Some(render_rect(Bounds::new(top_left, size), ctx.theme)) - } - - _ => None, - } - } -} - -fn normalize_rect(start: Point, end: Point) -> Bounds { - let x = start.x.min(end.x); - let y = start.y.min(end.y); - - let w = (end.x - start.x).abs(); - let h = (end.y - start.y).abs(); - - Bounds::new(Point::new(x, y), Size::new(w, h)) -} - -fn render_rect(bounds: Bounds, theme: &FlowTheme) -> AnyElement { - div() - .absolute() - .left(bounds.origin.x) - .top(bounds.origin.y) - .w(bounds.size.width) - .h(bounds.size.height) - .border(px(1.0)) - .border_color(rgb(theme.selection_rect_border)) - .bg(rgba(theme.selection_rect_fill_rgba)) - .into_any() -} diff --git a/crates/ferrum-flow/src/plugins/snap_guides.rs b/crates/ferrum-flow/src/plugins/snap_guides.rs deleted file mode 100644 index 9630511f58..0000000000 --- a/crates/ferrum-flow/src/plugins/snap_guides.rs +++ /dev/null @@ -1,244 +0,0 @@ -//! Alignment guides while dragging nodes. -//! -//! Subscribes to [`NodeDragEvent`](crate::plugins::node::NodeDragEvent) from -//! [`crate::plugins::NodeInteractionPlugin`] and runs -//! [`compute_alignment_guides`] only here. -//! This keeps [`crate::canvas::InteractionState`] free of overlay-specific fields. - -use std::collections::HashSet; - -use gpui::{AnyElement, Div, Element, ParentElement, Pixels, Point, Styled, div, px, rgb}; - -use crate::{ - Graph, NodeId, - plugin::{EventResult, FlowEvent, Plugin, PluginContext, RenderContext, RenderLayer}, - plugins::node::NodeDragEvent, - theme::FlowTheme, -}; - -/// Screen-space snap distance, converted to world units via `threshold / zoom`. -const SNAP_SCREEN_PX: f32 = 4.0; - -/// World-space lines to draw as alignment guides (full width / height of the canvas view). -#[derive(Debug, Clone, Default)] -struct AlignmentGuides { - pub vertical_x: Vec, - pub horizontal_y: Vec, -} - -fn union_drag_bounds(graph: &Graph, dragged_ids: &[NodeId]) -> Option> { - let mut min_x = f32::MAX; - let mut min_y = f32::MAX; - let mut max_x = f32::MIN; - let mut max_y = f32::MIN; - let mut any = false; - - for id in dragged_ids { - let Some(n) = graph.get_node(id) else { - continue; - }; - any = true; - let b = n.bounds(); - let l: f32 = b.origin.x.into(); - let t: f32 = b.origin.y.into(); - let r: f32 = (b.origin.x + b.size.width).into(); - let bot: f32 = (b.origin.y + b.size.height).into(); - min_x = min_x.min(l); - min_y = min_y.min(t); - max_x = max_x.max(r); - max_y = max_y.max(bot); - } - - if !any { - return None; - } - - Some(gpui::Bounds::new( - Point::new(min_x.into(), min_y.into()), - gpui::Size::new((max_x - min_x).into(), (max_y - min_y).into()), - )) -} - -fn dedup_sorted_coords(mut v: Vec) -> Vec { - v.sort_by(|a, b| f32::total_cmp(&(*a).into(), &(*b).into())); - v.dedup_by(|a, b| { - let af: f32 = (*a).into(); - let bf: f32 = (*b).into(); - af == bf - }); - v -} - -/// Computes alignment guides for the current drag. Skips nodes that are not on-screen and uses an -/// AABB broadphase so distant nodes are not considered. -fn compute_alignment_guides( - ctx: &PluginContext, - dragged_ids: &[NodeId], -) -> Option { - let dragged_set: HashSet<&NodeId> = dragged_ids.iter().collect(); - let thr = ctx.screen_length_to_world(px(SNAP_SCREEN_PX)); - let union = union_drag_bounds(ctx.graph, dragged_ids)?; - let dl = union.origin.x; - let dr = union.origin.x + union.size.width; - let dcx = (dl + dr) * 0.5; - let dt = union.origin.y; - let db = union.origin.y + union.size.height; - let dcy = (dt + db) * 0.5; - - let drag_x_lo = dl - thr; - let drag_x_hi = dr + thr; - let drag_y_lo = dt - thr; - let drag_y_hi = db + thr; - - let drag_xs = [dl, dcx, dr]; - let drag_ys = [dt, dcy, db]; - - let mut ref_x: Vec = Vec::new(); - let mut ref_y: Vec = Vec::new(); - - for (id, node) in ctx.graph.nodes() { - if dragged_set.contains(id) { - continue; - } - if !ctx.is_node_visible_node(node) { - continue; - } - - let b = node.bounds(); - let rl = b.origin.x; - let rr = rl + b.size.width; - let rcx = (rl + rr) * 0.5; - let rt = b.origin.y; - let rb = rt + b.size.height; - let rcy = (rt + rb) * 0.5; - - let can_vertical = !(rr < drag_x_lo || rl > drag_x_hi); - let can_horizontal = !(rb < drag_y_lo || rt > drag_y_hi); - if !can_vertical && !can_horizontal { - continue; - } - - if can_vertical { - ref_x.extend([rl, rcx, rr]); - } - if can_horizontal { - ref_y.extend([rt, rcy, rb]); - } - } - - let mut vertical_x = Vec::new(); - for rx in ref_x { - if drag_xs.iter().any(|dx| (*dx - rx).abs() <= thr) { - vertical_x.push(rx); - } - } - - let mut horizontal_y = Vec::new(); - for ry in ref_y { - if drag_ys.iter().any(|dy| (*dy - ry).abs() <= thr) { - horizontal_y.push(ry); - } - } - - vertical_x = dedup_sorted_coords(vertical_x); - horizontal_y = dedup_sorted_coords(horizontal_y); - - if vertical_x.is_empty() && horizontal_y.is_empty() { - return None; - } - - Some(AlignmentGuides { - vertical_x, - horizontal_y, - }) -} - -pub struct SnapGuidesPlugin { - guides: Option, -} - -impl SnapGuidesPlugin { - pub fn new() -> Self { - Self { guides: None } - } -} - -impl Default for SnapGuidesPlugin { - fn default() -> Self { - Self::new() - } -} - -impl Plugin for SnapGuidesPlugin { - fn name(&self) -> &'static str { - "snap_guides" - } - - fn on_event(&mut self, event: &FlowEvent, ctx: &mut PluginContext) -> EventResult { - if let Some(evt) = event.as_custom::() { - match evt { - NodeDragEvent::Tick(ids) => { - self.guides = compute_alignment_guides(ctx, ids.as_ref()); - ctx.notify(); - } - NodeDragEvent::End => { - self.guides = None; - ctx.notify(); - } - } - } - EventResult::Continue - } - - fn render(&mut self, ctx: &mut RenderContext) -> Option { - let guides = self.guides.as_ref()?; - let wb = ctx.window_bounds()?; - let w = wb.size.width; - let h = wb.size.height; - let theme = ctx.theme; - - let vx = guides.vertical_x.iter().map(|wx| vline(*wx, h, ctx, theme)); - let hy = guides - .horizontal_y - .iter() - .map(|wy| hline(*wy, w, ctx, theme)); - - Some( - div() - .absolute() - .size_full() - .children(vx.chain(hy)) - .into_any(), - ) - } - - fn priority(&self) -> i32 { - 118 - } - - fn render_layer(&self) -> RenderLayer { - RenderLayer::Interaction - } -} - -fn vline(wx: Pixels, win_h: Pixels, ctx: &RenderContext<'_>, theme: &FlowTheme) -> Div { - let sx = ctx.world_to_screen(Point::new(wx, px(0.0))).x; - div() - .absolute() - .left(sx) - .top(px(0.0)) - .w(px(1.0)) - .h(win_h) - .bg(rgb(theme.selection_rect_border)) -} - -fn hline(wy: Pixels, win_w: Pixels, ctx: &RenderContext<'_>, theme: &FlowTheme) -> Div { - let sy = ctx.world_to_screen(Point::new(px(0.0), wy)).y; - div() - .absolute() - .left(px(0.0)) - .top(sy) - .w(win_w) - .h(px(1.0)) - .bg(rgb(theme.selection_rect_border)) -} diff --git a/crates/ferrum-flow/src/plugins/toast.rs b/crates/ferrum-flow/src/plugins/toast.rs deleted file mode 100644 index 71fc983065..0000000000 --- a/crates/ferrum-flow/src/plugins/toast.rs +++ /dev/null @@ -1,172 +0,0 @@ -use std::{ - collections::VecDeque, - time::{Duration, Instant}, -}; - -use gpui::{Element as _, ParentElement as _, Styled as _, div, px, rgb}; - -use crate::{ - FlowTheme, - plugin::{FlowEvent, Plugin, PluginContext, RenderContext}, -}; - -const DEFAULT_TOAST_DURATION: Duration = Duration::from_millis(3000); -const MAX_TOASTS: usize = 4; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ToastLevel { - Info, - Success, - Warning, - Error, -} - -#[derive(Debug, Clone)] -pub struct ToastMessage { - text: String, - level: ToastLevel, - duration: Duration, -} - -impl ToastMessage { - pub fn new(text: impl Into, level: ToastLevel) -> Self { - Self { - text: text.into(), - level, - duration: DEFAULT_TOAST_DURATION, - } - } - - pub fn info(text: impl Into) -> Self { - Self::new(text, ToastLevel::Info) - } - - pub fn success(text: impl Into) -> Self { - Self::new(text, ToastLevel::Success) - } - - pub fn warning(text: impl Into) -> Self { - Self::new(text, ToastLevel::Warning) - } - - pub fn error(text: impl Into) -> Self { - Self::new(text, ToastLevel::Error) - } - - pub fn with_duration(mut self, duration: Duration) -> Self { - self.duration = duration; - self - } -} - -#[derive(Debug, Clone)] -struct ToastItem { - text: String, - level: ToastLevel, - expires_at: Instant, -} - -pub struct ToastPlugin { - queue: VecDeque, -} - -impl Default for ToastPlugin { - fn default() -> Self { - Self::new() - } -} - -impl ToastPlugin { - pub fn new() -> Self { - Self { - queue: VecDeque::new(), - } - } - - fn gc_expired(&mut self) { - let now = Instant::now(); - self.queue.retain(|item| item.expires_at > now); - } - - fn push(&mut self, msg: ToastMessage) { - self.gc_expired(); - self.queue.push_back(ToastItem { - text: msg.text, - level: msg.level, - expires_at: Instant::now() + msg.duration, - }); - while self.queue.len() > MAX_TOASTS { - let _ = self.queue.pop_front(); - } - } - - fn bg_color(level: ToastLevel, theme: &FlowTheme) -> u32 { - match level { - ToastLevel::Info => theme.info, - ToastLevel::Success => theme.success, - ToastLevel::Warning => theme.warning, - ToastLevel::Error => theme.error, - } - } -} - -impl Plugin for ToastPlugin { - fn name(&self) -> &'static str { - "toast" - } - - fn on_event( - &mut self, - event: &FlowEvent, - ctx: &mut PluginContext, - ) -> crate::plugin::EventResult { - self.gc_expired(); - if let Some(msg) = event.as_custom::() { - let duration = msg.duration; - self.push(msg.clone()); - ctx.schedule_after(duration); - ctx.notify(); - } - crate::plugin::EventResult::Continue - } - - fn priority(&self) -> i32 { - 10 - } - - fn render_layer(&self) -> crate::plugin::RenderLayer { - crate::plugin::RenderLayer::Overlay - } - - fn render(&mut self, ctx: &mut RenderContext) -> Option { - self.gc_expired(); - if self.queue.is_empty() { - return None; - } - - let items = self.queue.iter().rev().map(|item| { - div() - .mb_2() - .max_w(px(360.0)) - .rounded(px(8.0)) - .bg(rgb(Self::bg_color(item.level, ctx.theme))) - .px_3() - .py_2() - .child( - div() - .text_sm() - .text_color(rgb(0x00FFFFFF)) - .child(item.text.clone()), - ) - }); - - Some( - div() - .absolute() - .right(px(12.0)) - .bottom(px(12.0)) - .children(items) - .into_any(), - ) - } -} diff --git a/crates/ferrum-flow/src/plugins/viewport.rs b/crates/ferrum-flow/src/plugins/viewport.rs deleted file mode 100644 index 1ad4017afe..0000000000 --- a/crates/ferrum-flow/src/plugins/viewport.rs +++ /dev/null @@ -1,152 +0,0 @@ -use gpui::{MouseButton, Pixels, Point, px}; - -use crate::{ - canvas::{Command, Interaction, InteractionResult}, - plugin::{EventResult, FlowEvent, InputEvent, Plugin}, -}; - -pub struct ViewportPlugin; - -impl ViewportPlugin { - pub fn new() -> Self { - Self {} - } -} - -impl Default for ViewportPlugin { - fn default() -> Self { - Self::new() - } -} - -impl Plugin for ViewportPlugin { - fn name(&self) -> &'static str { - "viewport" - } - - fn on_event( - &mut self, - event: &crate::plugin::FlowEvent, - ctx: &mut crate::plugin::PluginContext, - ) -> EventResult { - if let FlowEvent::Input(InputEvent::MouseDown(ev)) = event - && ((ev.button == MouseButton::Left && ev.modifiers.shift) - || ev.button == MouseButton::Middle) - { - ctx.start_interaction(Panning { - start_mouse: ev.position, - start_offset: ctx.offset(), - }); - return EventResult::Stop; - } else if let FlowEvent::Input(InputEvent::Wheel(ev)) = event { - let cursor = ev.position; - - let before = ctx.screen_to_world(cursor); - - let delta = f32::from(ev.delta.pixel_delta(px(1.0)).y); - if delta == 0.0 { - return EventResult::Continue; - } - - let zoom_delta = if delta > 0.0 { 0.9 } else { 1.1 }; - - ctx.set_zoom(ctx.zoom_scaled_by(zoom_delta).clamp(0.1, 3.0)); - - let after = ctx.world_to_screen(before); - - ctx.translate_offset(cursor.x - after.x, cursor.y - after.y); - ctx.notify(); - } - EventResult::Continue - } - - fn priority(&self) -> i32 { - 10 - } -} - -struct Panning { - start_mouse: Point, - start_offset: Point, -} - -impl Interaction for Panning { - fn on_mouse_move( - &mut self, - ev: &gpui::MouseMoveEvent, - ctx: &mut crate::plugin::PluginContext, - ) -> InteractionResult { - let dx = ev.position.x - self.start_mouse.x; - let dy = ev.position.y - self.start_mouse.y; - - ctx.set_offset(Point::new( - self.start_offset.x + dx, - self.start_offset.y + dy, - )); - ctx.notify(); - - InteractionResult::Continue - } - - fn on_mouse_up( - &mut self, - _event: &gpui::MouseUpEvent, - ctx: &mut crate::plugin::PluginContext, - ) -> crate::canvas::InteractionResult { - ctx.execute_command(PanningCommand { - from: self.start_offset, - to: ctx.offset(), - }); - ctx.cancel_interaction(); - InteractionResult::End - } -} - -struct PanningCommand { - from: Point, - to: Point, -} - -impl Command for PanningCommand { - fn name(&self) -> &'static str { - "panning" - } - fn execute(&mut self, ctx: &mut crate::canvas::CommandContext) { - ctx.set_offset(self.to); - } - fn undo(&mut self, ctx: &mut crate::canvas::CommandContext) { - ctx.set_offset(self.from); - } - - fn to_ops(&self, _ctx: &mut crate::CommandContext) -> Vec { - vec![] - } -} - -#[cfg(test)] -mod command_interop_tests { - use gpui::{Point, px}; - - use crate::{Graph, command_interop::assert_command_interop}; - - use super::PanningCommand; - - #[test] - fn panning_command_interop() { - let base = Graph::new(); - let cmd = PanningCommand { - from: Point::new(px(0.0), px(0.0)), - to: Point::new(px(12.0), px(34.0)), - }; - assert_command_interop( - &base, - || { - Box::new(PanningCommand { - from: cmd.from, - to: cmd.to, - }) - }, - "PanningCommand", - ); - } -} diff --git a/crates/ferrum-flow/src/plugins/viewport_frame.rs b/crates/ferrum-flow/src/plugins/viewport_frame.rs deleted file mode 100644 index 975eb72b49..0000000000 --- a/crates/ferrum-flow/src/plugins/viewport_frame.rs +++ /dev/null @@ -1,166 +0,0 @@ -use gpui::{Pixels, Point, px}; - -use crate::{ - InitPluginContext, - canvas::{Command, CommandContext}, - plugin::PluginContext, -}; - -/// Same zoom limits as [`crate::plugins::ViewportPlugin`] wheel zoom. -pub(crate) const ZOOM_MIN: f32 = 0.7; -pub(crate) const ZOOM_MAX: f32 = 3.0; -/// Inset from window edges (ratio per side). -pub(crate) const MARGIN_RATIO: f32 = 0.08; - -fn frame_params( - win_w: f32, - win_h: f32, - bx: f32, - by: f32, - bw: f32, - bh: f32, -) -> Option<(f32, Point)> { - if win_w <= 0.0 || win_h <= 0.0 { - return None; - } - - let bw_safe = bw.max(1.0); - let bh_safe = bh.max(1.0); - - let avail_w = win_w * (1.0 - 2.0 * MARGIN_RATIO); - let avail_h = win_h * (1.0 - 2.0 * MARGIN_RATIO); - let z = (avail_w / bw_safe) - .min(avail_h / bh_safe) - .clamp(ZOOM_MIN, ZOOM_MAX); - - let cx = bx + bw / 2.0; - let cy = by + bh / 2.0; - let center_x = win_w / 2.0; - let center_y = win_h / 2.0; - let new_offset = Point::new(px(center_x - cx * z), px(center_y - cy * z)); - - Some((z, new_offset)) -} - -pub(crate) struct ViewportFrameCommand { - pub from_zoom: f32, - pub from_offset: Point, - pub to_zoom: f32, - pub to_offset: Point, -} - -impl Command for ViewportFrameCommand { - fn name(&self) -> &'static str { - "viewport_frame" - } - - fn execute(&mut self, ctx: &mut CommandContext) { - ctx.set_zoom(self.to_zoom); - ctx.set_offset(self.to_offset); - } - - fn undo(&mut self, ctx: &mut CommandContext) { - ctx.set_zoom(self.from_zoom); - ctx.set_offset(self.from_offset); - } - - fn to_ops(&self, ctx: &mut CommandContext) -> Vec { - ctx.set_zoom(self.to_zoom); - ctx.set_offset(self.to_offset); - vec![] - } -} - -/// Pan + zoom so the given world-space axis-aligned box (position + size) fits the window. -pub(crate) fn frame_world_rect(ctx: &mut PluginContext, bx: f32, by: f32, bw: f32, bh: f32) { - let Some(wb) = ctx.window_bounds() else { - return; - }; - - let win_w: f32 = wb.size.width.into(); - let win_h: f32 = wb.size.height.into(); - let Some((z, new_offset)) = frame_params(win_w, win_h, bx, by, bw, bh) else { - return; - }; - - let from_zoom = ctx.zoom(); - let from_offset = ctx.offset(); - let zoom_changed = (from_zoom - z).abs() > 1e-4; - let ox: f32 = from_offset.x.into(); - let oy: f32 = from_offset.y.into(); - let nx: f32 = new_offset.x.into(); - let ny: f32 = new_offset.y.into(); - let offset_changed = (ox - nx).abs() > 0.5 || (oy - ny).abs() > 0.5; - if !zoom_changed && !offset_changed { - return; - } - - ctx.execute_command(ViewportFrameCommand { - from_zoom, - from_offset, - to_zoom: z, - to_offset: new_offset, - }); -} - -/// Same geometry as [`frame_world_rect`], but writes [`Viewport`] directly (no undo stack). -/// For [`crate::plugin::InitPluginContext`] / plugin [`Plugin::setup`]. -pub(crate) fn apply_frame_world_rect_direct( - ctx: &mut InitPluginContext, - win_w: f32, - win_h: f32, - bx: f32, - by: f32, - bw: f32, - bh: f32, -) { - let Some((z, new_offset)) = frame_params(win_w, win_h, bx, by, bw, bh) else { - return; - }; - - let zoom_changed = (ctx.zoom() - z).abs() > 1e-4; - let off = ctx.offset(); - let ox: f32 = off.x.into(); - let oy: f32 = off.y.into(); - let nx: f32 = new_offset.x.into(); - let ny: f32 = new_offset.y.into(); - let offset_changed = (ox - nx).abs() > 0.5 || (oy - ny).abs() > 0.5; - if !zoom_changed && !offset_changed { - return; - } - - ctx.set_zoom(z); - ctx.set_offset(new_offset); -} - -#[cfg(test)] -mod command_interop_tests { - use gpui::{Point, px}; - - use crate::{Graph, command_interop::assert_command_interop}; - - use super::ViewportFrameCommand; - - #[test] - fn viewport_frame_command_interop() { - let base = Graph::new(); - let cmd = ViewportFrameCommand { - from_zoom: 1.0, - from_offset: Point::new(px(0.0), px(0.0)), - to_zoom: 1.1, - to_offset: Point::new(px(8.0), px(9.0)), - }; - assert_command_interop( - &base, - || { - Box::new(ViewportFrameCommand { - from_zoom: cmd.from_zoom, - from_offset: cmd.from_offset, - to_zoom: cmd.to_zoom, - to_offset: cmd.to_offset, - }) - }, - "ViewportFrameCommand", - ); - } -} diff --git a/crates/ferrum-flow/src/plugins/zoom_controls.rs b/crates/ferrum-flow/src/plugins/zoom_controls.rs deleted file mode 100644 index 522933be78..0000000000 --- a/crates/ferrum-flow/src/plugins/zoom_controls.rs +++ /dev/null @@ -1,314 +0,0 @@ -//! Bottom-left zoom controls (left to right: **+ − ↺ ⛶**): zoom in, zoom out, reset scale, fit entire graph. - -use gpui::{ - Bounds, IntoElement as _, MouseButton, ParentElement as _, Pixels, Point, Size, Styled as _, - div, px, rgb, -}; - -/// Unicode minus sign (not ASCII hyphen). -const LABEL_ZOOM_OUT: &str = "\u{2212}"; -const LABEL_ZOOM_IN: &str = "+"; -/// Anticlockwise open circle arrow — common “reset view” symbol. -const LABEL_RESET_ZOOM: &str = "\u{21BA}"; -/// Square four corners — “frame / fit content” (same action as [`crate::plugins::FitAllGraphPlugin`]). -const LABEL_FIT_ENTIRE_GRAPH: &str = "\u{26F6}"; - -use crate::{ - canvas::{Command, CommandContext}, - plugin::{ - EventResult, FlowEvent, InputEvent, Plugin, PluginContext, RenderContext, RenderLayer, - }, -}; - -use super::fit_all::fit_entire_graph; -use super::viewport_frame::{ZOOM_MAX, ZOOM_MIN}; - -const MARGIN: f32 = 16.0; -/// Square control size (width = height). -const BTN: f32 = 36.0; -const GAP: f32 = 6.0; -/// Same step as [`crate::plugins::ViewportPlugin`] wheel zoom. -const ZOOM_STEP: f32 = 1.1; - -struct ZoomControlsLayout { - zoom_in: Bounds, - zoom_out: Bounds, - reset: Bounds, - fit_entire_graph: Bounds, -} - -impl ZoomControlsLayout { - fn hit(&self, p: Point) -> Option { - if self.zoom_in.contains(&p) { - Some(Hit::ZoomIn) - } else if self.zoom_out.contains(&p) { - Some(Hit::ZoomOut) - } else if self.reset.contains(&p) { - Some(Hit::ResetZoom) - } else if self.fit_entire_graph.contains(&p) { - Some(Hit::FitEntireGraph) - } else { - None - } - } -} - -#[derive(Copy, Clone)] -enum Hit { - ZoomIn, - ZoomOut, - ResetZoom, - FitEntireGraph, -} - -fn bar_outer_size() -> (f32, f32) { - let w = 4.0 * BTN + 3.0 * GAP; - (w, BTN) -} - -fn build_layout(window_bounds: Bounds) -> ZoomControlsLayout { - let wh: f32 = window_bounds.size.height.into(); - let (_, bar_h) = bar_outer_size(); - let s = px(BTN); - let m = px(MARGIN); - - let y0 = px(wh - MARGIN - bar_h); - let x0 = m; - - let zoom_in = Bounds::new(Point::new(x0, y0), Size::new(s, s)); - let zoom_out = Bounds::new( - Point::new(px(f32::from(x0) + BTN + GAP), y0), - Size::new(s, s), - ); - let reset = Bounds::new( - Point::new(px(f32::from(x0) + 2.0 * (BTN + GAP)), y0), - Size::new(s, s), - ); - let fit_entire_graph = Bounds::new( - Point::new(px(f32::from(x0) + 3.0 * (BTN + GAP)), y0), - Size::new(s, s), - ); - - ZoomControlsLayout { - zoom_in, - zoom_out, - reset, - fit_entire_graph, - } -} - -struct ViewportZoomCommand { - from_zoom: f32, - from_offset: Point, - to_zoom: f32, - to_offset: Point, -} - -impl Command for ViewportZoomCommand { - fn name(&self) -> &'static str { - "viewport_zoom" - } - - fn execute(&mut self, ctx: &mut CommandContext) { - ctx.set_zoom(self.to_zoom); - ctx.set_offset(self.to_offset); - } - - fn undo(&mut self, ctx: &mut CommandContext) { - ctx.set_zoom(self.from_zoom); - ctx.set_offset(self.from_offset); - } - - fn to_ops(&self, ctx: &mut crate::CommandContext) -> Vec { - ctx.set_zoom(self.to_zoom); - ctx.set_offset(self.to_offset); - vec![] - } -} - -fn apply_zoom(ctx: &mut PluginContext, anchor_screen: Point, to_zoom: f32) { - let to_zoom = to_zoom.clamp(ZOOM_MIN, ZOOM_MAX); - let from_zoom = ctx.zoom(); - let from_offset = ctx.offset(); - if (from_zoom - to_zoom).abs() < 1e-5 { - return; - } - let anchor_world = ctx.screen_to_world(anchor_screen); - let wx: f32 = anchor_world.x.into(); - let wy: f32 = anchor_world.y.into(); - let ax: f32 = anchor_screen.x.into(); - let ay: f32 = anchor_screen.y.into(); - let to_offset = Point::new(px(ax - wx * to_zoom), px(ay - wy * to_zoom)); - ctx.execute_command(ViewportZoomCommand { - from_zoom, - from_offset, - to_zoom, - to_offset, - }); -} - -fn window_center_screen(ctx: &PluginContext) -> Option> { - let wb = ctx.window_bounds()?; - let cx: f32 = (wb.size.width / 2.0).into(); - let cy: f32 = (wb.size.height / 2.0).into(); - Some(Point::new(px(cx), px(cy))) -} - -fn zoom_by_factor(ctx: &mut PluginContext, factor: f32) { - let Some(center) = window_center_screen(ctx) else { - return; - }; - apply_zoom(ctx, center, ctx.zoom_scaled_by(factor)); -} - -fn reset_zoom(ctx: &mut PluginContext) { - let Some(center) = window_center_screen(ctx) else { - return; - }; - apply_zoom(ctx, center, 1.0); -} - -/// Bottom-left **+** / **−** / **↺** / **⛶** (fit all); priority **128** so clicks beat canvas selection. -pub struct ZoomControlsPlugin { - last_layout: Option, -} - -impl Default for ZoomControlsPlugin { - fn default() -> Self { - Self::new() - } -} - -impl ZoomControlsPlugin { - pub fn new() -> Self { - Self { last_layout: None } - } -} - -impl Plugin for ZoomControlsPlugin { - fn name(&self) -> &'static str { - "zoom_controls" - } - - fn on_event(&mut self, event: &FlowEvent, ctx: &mut PluginContext) -> EventResult { - if let FlowEvent::Input(InputEvent::MouseDown(ev)) = event - && ev.button == MouseButton::Left - && let Some(ref layout) = self.last_layout - && let Some(hit) = layout.hit(ev.position) - { - match hit { - Hit::ZoomIn => zoom_by_factor(ctx, ZOOM_STEP), - Hit::ZoomOut => zoom_by_factor(ctx, 1.0 / ZOOM_STEP), - Hit::ResetZoom => reset_zoom(ctx), - Hit::FitEntireGraph => fit_entire_graph(ctx), - } - ctx.notify(); - return EventResult::Stop; - } - EventResult::Continue - } - - fn priority(&self) -> i32 { - 128 - } - - fn render_layer(&self) -> RenderLayer { - RenderLayer::Overlay - } - - fn render(&mut self, ctx: &mut RenderContext) -> Option { - let win = ctx.window_bounds().unwrap_or_else(|| { - let vs = ctx.window.viewport_size(); - Bounds::new(Point::new(px(0.0), px(0.0)), Size::new(vs.width, vs.height)) - }); - let wh: f32 = win.size.height.into(); - let (bar_w, bar_h) = bar_outer_size(); - if wh < MARGIN + bar_h + 1.0 { - self.last_layout = None; - return None; - } - - let layout = build_layout(win); - self.last_layout = Some(layout); - - let bar_w_px = px(bar_w); - - let btn_bg = ctx.theme.zoom_controls_background; - let btn_border = ctx.theme.zoom_controls_border; - let btn_text = ctx.theme.zoom_controls_text; - - let mk_btn = move |label: &'static str| { - div() - .w(px(BTN)) - .h(px(BTN)) - .flex() - .items_center() - .justify_center() - .rounded(px(6.0)) - .bg(rgb(btn_bg)) - .border_1() - .border_color(rgb(btn_border)) - .text_sm() - .font_weight(gpui::FontWeight::MEDIUM) - .text_color(rgb(btn_text)) - .child(label) - }; - - Some( - div() - .absolute() - .size_full() - .child( - div() - .absolute() - .bottom(px(MARGIN)) - .left(px(MARGIN)) - .w(bar_w_px) - .h(px(bar_h)) - .flex() - .flex_row() - .gap(px(GAP)) - .items_center() - .children(vec![ - mk_btn(LABEL_ZOOM_IN), - mk_btn(LABEL_ZOOM_OUT), - mk_btn(LABEL_RESET_ZOOM), - mk_btn(LABEL_FIT_ENTIRE_GRAPH), - ]), - ) - .into_any_element(), - ) - } -} - -#[cfg(test)] -mod command_interop_tests { - use gpui::{Point, px}; - - use crate::{Graph, command_interop::assert_command_interop}; - - use super::ViewportZoomCommand; - - #[test] - fn viewport_zoom_command_interop() { - let base = Graph::new(); - let cmd = ViewportZoomCommand { - from_zoom: 1.0, - from_offset: Point::new(px(0.0), px(0.0)), - to_zoom: 1.25, - to_offset: Point::new(px(5.0), px(6.0)), - }; - assert_command_interop( - &base, - || { - Box::new(ViewportZoomCommand { - from_zoom: cmd.from_zoom, - from_offset: cmd.from_offset, - to_zoom: cmd.to_zoom, - to_offset: cmd.to_offset, - }) - }, - "ViewportZoomCommand", - ); - } -} diff --git a/crates/ferrum-flow/src/port_screen.rs b/crates/ferrum-flow/src/port_screen.rs deleted file mode 100644 index c47ecff147..0000000000 --- a/crates/ferrum-flow/src/port_screen.rs +++ /dev/null @@ -1,60 +0,0 @@ -//! Screen-space port layout for canvas rendering ([`PortScreenFrame`]). - -use gpui::{ - Div, ElementId, InteractiveElement as _, Pixels, Point, Size, Stateful, Styled as _, div, -}; - -use crate::PortId; - -/// Screen-space layout for one port after the viewport transform. -/// -/// Prefer resolving via [`crate::plugin::RenderContext::port_screen_frame`] (or -/// [`crate::plugin::PluginContext::port_screen_frame`] during interaction). -/// -/// Typical patterns: -/// - Default disc: [`Self::anchor_div`] then chain `.rounded_full()`, colors, borders. -/// - Custom chrome: build children inside [`Self::anchor_div`], or use [`Self::center`] -/// / [`Self::scaled_size`] for labels, sockets, multi-layer ports. -/// - Larger hit target: [`Self::anchor_div`] then override `.w`/`.h` while keeping [`Self::center`]. -#[derive(Clone, Copy, Debug)] -pub struct PortScreenFrame { - /// Port center in screen pixels (aligned with edge curve endpoints). - pub center: Point, - /// Logical port size from graph data (same units as on the node card). - pub size: Size, - pub zoom: f32, - pub(crate) port_id: PortId, -} - -impl PortScreenFrame { - /// `size` scaled by [`Self::zoom`], i.e. the on-screen port box size. - pub fn scaled_size(&self) -> Size { - let z = self.zoom; - Size { - width: self.size.width * z, - height: self.size.height * z, - } - } - - /// Top-left of the axis-aligned rectangle centered on [`Self::center`]. - pub fn origin(&self) -> Point { - let s = self.scaled_size(); - Point::new( - self.center.x - s.width / 2.0, - self.center.y - s.height / 2.0, - ) - } - - /// `absolute` container covering the default port hit box; chain GPUI styles and children. - pub fn anchor_div(self) -> Stateful
{ - let s = self.scaled_size(); - let o = self.origin(); - div() - .id(ElementId::Uuid(*self.port_id.as_uuid())) - .absolute() - .left(o.x) - .top(o.y) - .w(s.width) - .h(s.height) - } -} diff --git a/crates/ferrum-flow/src/shared_state.rs b/crates/ferrum-flow/src/shared_state.rs deleted file mode 100644 index 1e02c106e1..0000000000 --- a/crates/ferrum-flow/src/shared_state.rs +++ /dev/null @@ -1,55 +0,0 @@ -//! Type-erased map for data shared between plugins on one [`crate::canvas::FlowCanvas`]. -//! -//! Store values under their concrete Rust type (`TypeId`). Each type may appear at most once. -//! Prefer newtype wrappers per feature to avoid collisions (e.g. `struct MyPluginState(u32)`). - -use std::any::{Any, TypeId}; -use std::collections::HashMap; -use std::fmt; - -/// Keyed by [`TypeId`]; values must be `'static` and [`Send`]. -pub struct SharedState { - inner: HashMap>, -} - -impl fmt::Debug for SharedState { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("SharedState") - .field("len", &self.inner.len()) - .finish() - } -} - -impl SharedState { - pub(crate) fn new() -> Self { - Self { - inner: HashMap::new(), - } - } - - /// Inserts a value, returning the previous one of the same type if any. - pub fn insert(&mut self, value: T) -> Option { - let id = TypeId::of::(); - let old = self.inner.remove(&id); - self.inner.insert(id, Box::new(value)); - old.and_then(|b| b.downcast::().ok().map(|b| *b)) - } - - pub fn get(&self) -> Option<&T> { - self.inner.get(&TypeId::of::())?.downcast_ref() - } - - pub fn get_mut(&mut self) -> Option<&mut T> { - self.inner.get_mut(&TypeId::of::())?.downcast_mut() - } - - pub fn remove(&mut self) -> Option { - self.inner - .remove(&TypeId::of::()) - .and_then(|b| b.downcast::().ok().map(|b| *b)) - } - - pub fn contains(&self) -> bool { - self.inner.contains_key(&TypeId::of::()) - } -} diff --git a/crates/ferrum-flow/src/theme.rs b/crates/ferrum-flow/src/theme.rs deleted file mode 100644 index d735ffccc6..0000000000 --- a/crates/ferrum-flow/src/theme.rs +++ /dev/null @@ -1,139 +0,0 @@ -//! Canvas-wide visual tokens. Plugins can replace or tweak values in -//! [`crate::plugin::InitPluginContext::theme`] / [`crate::plugin::PluginContext::theme`]. -//! -//! Colors are `u32` in **GPUI `rgb` / `rgba` layout**: `0x00RRGGBB` for opaque colors -//! (first byte unused by [`gpui::rgb`]), and `0xRRGGBBAA` for [`gpui::rgba`] fills. - -/// Default canvas chrome: node cards, grid, edges, selection marquee. -#[derive(Debug, Clone, PartialEq)] -pub struct FlowTheme { - /// Default node card background ([`gpui::rgb`]). - pub node_card_background: u32, - /// Default node card border when not selected. - pub node_card_border: u32, - /// Default node card border when selected. - pub node_card_border_selected: u32, - - /// Unknown node type card background. - pub undefined_node_background: u32, - /// Unknown node type card border. - pub undefined_node_border: u32, - - /// Primary label on default node cards. - pub node_caption_text: u32, - /// Label on undefined-type node cards. - pub undefined_node_caption_text: u32, - - /// Default circular port fill ([`NodeRenderer::port_render`](crate::NodeRenderer::port_render); - /// layout via [`crate::plugin::RenderContext::port_screen_frame`]). - pub default_port_fill: u32, - - /// Main surface color behind the dot grid. - pub background: u32, - /// Dot color for the background grid. - pub background_grid_dot: u32, - - /// Edge curve when not selected. - pub edge_stroke: u32, - /// Edge curve when selected. - pub edge_stroke_selected: u32, - - /// Marquee / move-preview rectangle outline ([`gpui::rgb`]). - pub selection_rect_border: u32, - /// Marquee / move-preview fill ([`gpui::rgba`], e.g. `0x78A0FF4c`). - pub selection_rect_fill_rgba: u32, - - /// Temporary line while dragging a link from a port. - pub port_preview_line: u32, - /// Endpoint disc while dragging a link from a port (muted so it does not overpower the canvas). - pub port_preview_dot: u32, - - /// Minimap inner panel fill ([`crate::MinimapPlugin`]). - pub minimap_background: u32, - /// Minimap inner panel outline. - pub minimap_border: u32, - /// Minimap graph edges (straight segments between node centers). - pub minimap_edge: u32, - /// Minimap node rectangle fill. - pub minimap_node_fill: u32, - /// Minimap node rectangle outline. - pub minimap_node_stroke: u32, - /// Minimap viewport / visible-area frame. - pub minimap_viewport_stroke: u32, - - /// Zoom bar button fill ([`crate::ZoomControlsPlugin`]). - pub zoom_controls_background: u32, - /// Zoom bar button border. - pub zoom_controls_border: u32, - /// Zoom bar glyph color. - pub zoom_controls_text: u32, - - /// Context menu panel fill ([`crate::ContextMenuPlugin`]). - pub context_menu_background: u32, - /// Context menu panel outline. - pub context_menu_border: u32, - /// Context menu row label. - pub context_menu_text: u32, - /// Context menu shortcut hint (muted). - pub context_menu_shortcut_text: u32, - /// Context menu separator rule between rows. - pub context_menu_separator: u32, - - /// common error color. - pub error: u32, - /// common info color. - pub info: u32, - /// common success color. - pub success: u32, - /// common warning color. - pub warning: u32, -} - -impl Default for FlowTheme { - #[allow(clippy::mixed_case_hex_literals)] - fn default() -> Self { - Self { - node_card_background: 0x00FFFFFF, - node_card_border: 0x001A192B, - node_card_border_selected: 0x00FF7800, - undefined_node_background: 0x00F5F5F5, - undefined_node_border: 0x00FF9800, - node_caption_text: 0x001A192B, - undefined_node_caption_text: 0x005F6368, - default_port_fill: 0x001A192B, - background: 0x00f8f9fb, - background_grid_dot: 0x009F9FA7, - edge_stroke: 0x00b1b1b8, - edge_stroke_selected: 0x00FF7800, - selection_rect_border: 0x0078A0FF, - selection_rect_fill_rgba: 0x78A0FF4c, - port_preview_line: 0x00b1b1b8, - port_preview_dot: 0x007189a3, - minimap_background: 0x00f8f9fb, - minimap_border: 0x00b1b1b8, - minimap_edge: 0x00b1b1b8, - minimap_node_fill: 0x00FFFFFF, - minimap_node_stroke: 0x001a192b, - minimap_viewport_stroke: 0x0078a0ff, - zoom_controls_background: 0x00fcfcfc, - zoom_controls_border: 0x00c8c8d0, - zoom_controls_text: 0x001a192b, - context_menu_background: 0x00fcfcfc, - context_menu_border: 0x00c8c8d0, - context_menu_text: 0x001a192b, - context_menu_shortcut_text: 0x007a7a88, - context_menu_separator: 0x00e0e0e8, - error: 0x00FF1744, - info: 0x001F2937, - success: 0x001E8E3E, - warning: 0x00B35A00, - } - } -} - -impl FlowTheme { - /// Same as [`Default::default`]; kept for explicit call sites. - pub fn light() -> Self { - Self::default() - } -} diff --git a/crates/ferrum-flow/src/viewport.rs b/crates/ferrum-flow/src/viewport.rs deleted file mode 100644 index 11526d7128..0000000000 --- a/crates/ferrum-flow/src/viewport.rs +++ /dev/null @@ -1,192 +0,0 @@ -use gpui::{Bounds, Pixels, Point, Size, Window, px}; - -use crate::{Node, PortPosition}; - -/// Fingerprint of [`Viewport`] fields that affect [`Viewport::is_node_visible`]. -/// Used by [`crate::NodePlugin`] to avoid rescanning the full node list every frame. -#[derive(Debug, Clone, Copy, PartialEq)] -pub(crate) struct ViewportVisibilityCacheKey { - pub zoom: f32, - pub offset_x: f32, - pub offset_y: f32, - pub has_window: bool, - pub window_w: f32, - pub window_h: f32, -} - -#[derive(Debug, Clone)] -pub struct Viewport { - zoom: f32, - offset: Point, - window_bounds: Option>, -} - -impl Viewport { - pub(crate) fn new() -> Self { - Self { - zoom: 1.0, - offset: Point::new(px(0.0), px(0.0)), - window_bounds: None, - } - } - - /// Sets [`Self::window_bounds`] to the window’s drawable area (`Window::viewport_size`), - /// origin `(0, 0)`. Skips assignment when width/height are unchanged. - /// - /// Prefer this over `Window::bounds()` for hit-testing and overlay layout: the latter is in - /// global space and can be larger than the content viewport. - pub fn sync_drawable_bounds(&mut self, window: &Window) { - let vs = window.viewport_size(); - let unchanged = self - .window_bounds - .is_some_and(|b| b.size.width == vs.width && b.size.height == vs.height); - if !unchanged { - self.window_bounds = Some(Bounds::new( - Point::new(px(0.0), px(0.0)), - Size::new(vs.width, vs.height), - )); - } - } - - /// Sets [`Self::window_bounds`] to the canvas element's local drawable area. - pub fn sync_canvas_bounds(&mut self, bounds: Bounds) { - let unchanged = self.window_bounds.is_some_and(|b| { - b.size.width == bounds.size.width && b.size.height == bounds.size.height - }); - if !unchanged { - self.window_bounds = Some(Bounds::new( - Point::new(px(0.0), px(0.0)), - Size::new(bounds.size.width, bounds.size.height), - )); - } - } - - pub fn zoom(&self) -> f32 { - self.zoom - } - - pub fn set_zoom(&mut self, zoom: f32) { - self.zoom = zoom; - } - - /// Compute a new zoom value by multiplying current zoom with `factor`. - pub fn zoom_scaled_by(&self, factor: f32) -> f32 { - self.zoom * factor - } - - pub fn offset(&self) -> Point { - self.offset - } - - pub fn set_offset(&mut self, offset: Point) { - self.offset = offset; - } - - pub fn set_offset_xy(&mut self, x: Pixels, y: Pixels) { - self.offset = Point::new(x, y); - } - - pub fn translate_offset(&mut self, dx: Pixels, dy: Pixels) { - self.offset.x += dx; - self.offset.y += dy; - } - - pub fn window_bounds(&self) -> Option> { - self.window_bounds - } - - pub fn set_window_bounds(&mut self, bounds: Option>) { - self.window_bounds = bounds; - } - - /// Convert a world-space scalar length to screen-space scalar length. - pub fn world_scalar_to_screen(&self, value: f32) -> f32 { - value * self.zoom - } - - /// Convert a screen-space scalar length to world-space scalar length. - pub fn screen_scalar_to_world(&self, value: f32) -> f32 { - value / self.zoom - } - - /// Convert a world-space pixel length to screen-space pixel length. - pub fn world_length_to_screen(&self, value: Pixels) -> Pixels { - value * self.zoom - } - - /// Convert a screen-space pixel length to world-space pixel length. - pub fn screen_length_to_world(&self, value: Pixels) -> Pixels { - value / self.zoom - } - - pub fn world_to_screen(&self, p: Point) -> Point { - Point::new( - self.world_length_to_screen(p.x) + self.offset.x, - self.world_length_to_screen(p.y) + self.offset.y, - ) - } - - pub fn screen_to_world(&self, p: Point) -> Point { - Point::new( - self.screen_length_to_world(p.x - self.offset.x), - self.screen_length_to_world(p.y - self.offset.y), - ) - } - - /// Bezier control point for an edge tangent at a port direction. - pub fn edge_control_point( - &self, - source: Point, - position: PortPosition, - ) -> Point { - match position { - PortPosition::Top => { - source - Point::new(px(0.0), px(self.world_scalar_to_screen(50.0))) - } - PortPosition::Left => { - source - Point::new(px(self.world_scalar_to_screen(50.0)), px(0.0)) - } - PortPosition::Right => { - source + Point::new(px(self.world_scalar_to_screen(50.0)), px(0.0)) - } - PortPosition::Bottom => { - source + Point::new(px(0.0), px(self.world_scalar_to_screen(50.0))) - } - } - } - - pub fn is_node_visible(&self, node: &Node) -> bool { - let Some(window_bounds) = self.window_bounds else { - return false; - }; - - let screen = self.world_to_screen(node.point()); - let size = *node.size_ref(); - - screen.x + self.world_length_to_screen(size.width) > px(0.0) - && screen.x < window_bounds.size.width - && screen.y + self.world_length_to_screen(size.height) > px(0.0) - && screen.y < window_bounds.size.height - } - - pub(crate) fn visibility_cache_key(&self) -> ViewportVisibilityCacheKey { - match self.window_bounds { - Some(b) => ViewportVisibilityCacheKey { - zoom: self.zoom, - offset_x: self.offset.x.into(), - offset_y: self.offset.y.into(), - has_window: true, - window_w: b.size.width.into(), - window_h: b.size.height.into(), - }, - None => ViewportVisibilityCacheKey { - zoom: self.zoom, - offset_x: self.offset.x.into(), - offset_y: self.offset.y.into(), - has_window: false, - window_w: 0.0, - window_h: 0.0, - }, - } - } -} From 0ec8e850ba4fa86ca82167f299c031cd9fe4a01a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=83=A1=E9=A3=9E?= <1835698775@qq.com> Date: Fri, 8 May 2026 20:47:27 +0800 Subject: [PATCH 04/45] =?UTF-8?q?feat(er=5Fdiagram):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E6=BB=9A=E5=8A=A8=E6=9D=A1=E6=94=AF=E6=8C=81=E5=B9=B6=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E9=BC=A0=E6=A0=87=E4=BA=8B=E4=BB=B6=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 scrollbar_plugin 模块,实现水平和垂直滚动条的渲染与交互 - 在 scroll_pan_plugin 中集成滚动条状态,支持拖动滚动条控制视图偏移 - 修改 pan_mode_plugin,使用本地画布坐标判断鼠标点击位置 - 增加滚轮平移支持,优化滚动时的视图偏移与刷新调度 - 完善滚动条拖动交互,支持鼠标移动和释放事件处理 - 新增滚动条轨道和滑块的渲染逻辑,包含颜色样式和位置计算 - 实现滚动条滑块长度的最小值限制,保证滑块可用性 - 在 lsp completions 中修复触发逻辑,避免光标在触发点前时错误弹出补全菜单 - 添加相关单元测试,覆盖滚动条长度计算和补全菜单显示条件 --- crates/db_view/src/er_diagram/mod.rs | 1 + .../db_view/src/er_diagram/pan_mode_plugin.rs | 3 +- .../src/er_diagram/scroll_pan_plugin.rs | 72 +++- .../src/er_diagram/scrollbar_plugin.rs | 311 ++++++++++++++++++ crates/ui/src/input/lsp/completions.rs | 20 +- 5 files changed, 390 insertions(+), 17 deletions(-) create mode 100644 crates/db_view/src/er_diagram/scrollbar_plugin.rs diff --git a/crates/db_view/src/er_diagram/mod.rs b/crates/db_view/src/er_diagram/mod.rs index b7b81f5ff3..8a02654c14 100644 --- a/crates/db_view/src/er_diagram/mod.rs +++ b/crates/db_view/src/er_diagram/mod.rs @@ -1,6 +1,7 @@ mod loader; mod pan_mode_plugin; mod scroll_pan_plugin; +mod scrollbar_plugin; use db::GlobalDbState; use ferrum_flow::{ diff --git a/crates/db_view/src/er_diagram/pan_mode_plugin.rs b/crates/db_view/src/er_diagram/pan_mode_plugin.rs index 649ee7105c..1d8508ddcd 100644 --- a/crates/db_view/src/er_diagram/pan_mode_plugin.rs +++ b/crates/db_view/src/er_diagram/pan_mode_plugin.rs @@ -90,9 +90,10 @@ impl Plugin for ErDiagramPanModePlugin { if let FlowEvent::Input(InputEvent::MouseDown(ev)) = event && ev.button == MouseButton::Left { + let pointer_position = ctx.window_pointer_to_canvas_local(ev.position); if self .last_bounds - .is_some_and(|bounds| bounds.contains(&ev.position)) + .is_some_and(|bounds| bounds.contains(&pointer_position)) { let active = ctx .shared_state diff --git a/crates/db_view/src/er_diagram/scroll_pan_plugin.rs b/crates/db_view/src/er_diagram/scroll_pan_plugin.rs index a49457a8fd..d3fc534889 100644 --- a/crates/db_view/src/er_diagram/scroll_pan_plugin.rs +++ b/crates/db_view/src/er_diagram/scroll_pan_plugin.rs @@ -1,11 +1,23 @@ -use ferrum_flow::{EventResult, FlowEvent, InputEvent, Plugin, PluginContext}; -use gpui::{Pixels, Point, px}; +use std::time::Duration; -pub struct ErDiagramScrollPanPlugin; +use ferrum_flow::{ + EventResult, FlowEvent, InputEvent, Plugin, PluginContext, RenderContext, RenderLayer, +}; +use gpui::{MouseButton, Pixels, Point, px}; + +use crate::er_diagram::scrollbar_plugin::{ScrollbarDragInteraction, ScrollbarState}; + +pub struct ErDiagramScrollPanPlugin { + scrollbars: ScrollbarState, + refresh_scheduled: bool, +} impl ErDiagramScrollPanPlugin { pub fn new() -> Self { - Self + Self { + scrollbars: ScrollbarState::default(), + refresh_scheduled: false, + } } } @@ -15,23 +27,49 @@ impl Plugin for ErDiagramScrollPanPlugin { } fn on_event(&mut self, event: &FlowEvent, ctx: &mut PluginContext) -> EventResult { - if let FlowEvent::Input(InputEvent::Wheel(ev)) = event { - let delta = ev.delta.pixel_delta(px(1.0)); - let pan = wheel_delta_to_pan(delta); - let dx = pan.x; - let dy = pan.y; - if dx != px(0.0) || dy != px(0.0) { - ctx.translate_offset(dx, dy); - ctx.notify(); - return EventResult::Stop; + match event { + FlowEvent::DrawableBoundsReady => { + if !self.refresh_scheduled { + self.refresh_scheduled = true; + ctx.schedule_after(Duration::from_millis(16)); + } + EventResult::Continue } + FlowEvent::Input(InputEvent::Wheel(ev)) => { + let delta = ev.delta.pixel_delta(px(1.0)); + let pan = wheel_delta_to_pan(delta); + let dx = pan.x; + let dy = pan.y; + if dx != px(0.0) || dy != px(0.0) { + ctx.translate_offset(dx, dy); + ctx.notify(); + return EventResult::Stop; + } + EventResult::Continue + } + FlowEvent::Input(InputEvent::MouseDown(ev)) if ev.button == MouseButton::Left => { + let pointer_position = ctx.window_pointer_to_canvas_local(ev.position); + if let Some(axis) = self.scrollbars.axis_at(pointer_position) { + ctx.start_interaction(ScrollbarDragInteraction::new(axis, ev.position, ctx)); + return EventResult::Stop; + } + EventResult::Continue + } + _ => EventResult::Continue, } - EventResult::Continue + } + + fn render(&mut self, ctx: &mut RenderContext) -> Option { + self.scrollbars.render(ctx) } fn priority(&self) -> i32 { 130 } + + fn render_layer(&self) -> RenderLayer { + RenderLayer::Overlay + } } fn wheel_delta_to_pan(delta: Point) -> Point { @@ -41,6 +79,7 @@ fn wheel_delta_to_pan(delta: Point) -> Point { #[cfg(test)] mod tests { use super::wheel_delta_to_pan; + use crate::er_diagram::scrollbar_plugin::thumb_length; use gpui::{Point, px}; #[test] @@ -50,4 +89,9 @@ mod tests { Point::new(px(0.0), px(24.0)) ); } + + #[test] + fn thumb_length_has_minimum_size() { + assert_eq!(thumb_length(px(100.0), px(10.0), px(1000.0)), px(32.0)); + } } diff --git a/crates/db_view/src/er_diagram/scrollbar_plugin.rs b/crates/db_view/src/er_diagram/scrollbar_plugin.rs new file mode 100644 index 0000000000..0f389f59c6 --- /dev/null +++ b/crates/db_view/src/er_diagram/scrollbar_plugin.rs @@ -0,0 +1,311 @@ +use ferrum_flow::{Interaction, InteractionResult, PluginContext, RenderContext}; +use gpui::{ + Bounds, IntoElement, ParentElement as _, Pixels, Point, Size, Styled as _, div, hsla, px, +}; + +const SCROLLBAR_MARGIN: f32 = 8.0; +const SCROLLBAR_THICKNESS: f32 = 8.0; +const SCROLLBAR_MIN_THUMB: f32 = 32.0; +const CONTENT_PADDING: f32 = 80.0; + +#[derive(Clone, Copy)] +pub(super) enum ScrollbarAxis { + Horizontal, + Vertical, +} + +#[derive(Default)] +pub(super) struct ScrollbarState { + horizontal_thumb: Option>, + vertical_thumb: Option>, +} + +impl ScrollbarState { + pub(super) fn axis_at(&self, position: Point) -> Option { + if self + .horizontal_thumb + .is_some_and(|bounds| bounds.contains(&position)) + { + return Some(ScrollbarAxis::Horizontal); + } + if self + .vertical_thumb + .is_some_and(|bounds| bounds.contains(&position)) + { + return Some(ScrollbarAxis::Vertical); + } + None + } + + pub(super) fn render(&mut self, ctx: &mut RenderContext) -> Option { + let Some(metrics) = ScrollbarMetrics::from_render_context(ctx) else { + self.horizontal_thumb = None; + self.vertical_thumb = None; + return None; + }; + self.horizontal_thumb = metrics.horizontal_thumb; + self.vertical_thumb = metrics.vertical_thumb; + + let track_color = hsla(0.0, 0.0, 0.0, 0.18); + let thumb_color = hsla(0.0, 0.0, 0.45, 0.55); + Some( + div() + .absolute() + .size_full() + .child(render_track(metrics.horizontal_track, track_color)) + .child(render_track(metrics.vertical_track, track_color)) + .child(render_thumb(metrics.horizontal_thumb, thumb_color)) + .child(render_thumb(metrics.vertical_thumb, thumb_color)) + .into_any_element(), + ) + } +} + +fn render_track(track: Option>, color: gpui::Hsla) -> impl IntoElement { + div().children(track.map(|track| render_bar(track, color))) +} + +fn render_thumb(thumb: Option>, color: gpui::Hsla) -> impl IntoElement { + div().children(thumb.map(|thumb| render_bar(thumb, color))) +} + +fn render_bar(bounds: Bounds, color: gpui::Hsla) -> impl IntoElement { + div() + .absolute() + .left(bounds.origin.x) + .top(bounds.origin.y) + .w(bounds.size.width) + .h(bounds.size.height) + .rounded(px(SCROLLBAR_THICKNESS / 2.0)) + .bg(color) +} + +pub(super) struct ScrollbarDragInteraction { + axis: ScrollbarAxis, + start_mouse: Point, + start_offset: Point, + world_bounds: Option, + window_bounds: Option>, + zoom: f32, +} + +impl ScrollbarDragInteraction { + pub(super) fn new( + axis: ScrollbarAxis, + start_mouse: Point, + ctx: &PluginContext, + ) -> Self { + Self { + axis, + start_mouse, + start_offset: ctx.offset(), + world_bounds: graph_world_bounds(ctx), + window_bounds: ctx.window_bounds(), + zoom: ctx.zoom(), + } + } +} + +impl Interaction for ScrollbarDragInteraction { + fn on_mouse_move( + &mut self, + ev: &gpui::MouseMoveEvent, + ctx: &mut PluginContext, + ) -> InteractionResult { + let Some(bounds) = self.world_bounds else { + return InteractionResult::End; + }; + let Some(window_bounds) = self.window_bounds else { + return InteractionResult::End; + }; + let next_offset = match self.axis { + ScrollbarAxis::Horizontal => { + let delta = ev.position.x - self.start_mouse.x; + let content_width = bounds.width * self.zoom; + let track_width = scrollbar_horizontal_track(window_bounds).size.width; + let movable = (track_width + - thumb_length(track_width, window_bounds.size.width, content_width)) + .max(px(1.0)); + let scrollable = (content_width - window_bounds.size.width).max(px(1.0)); + Point::new( + self.start_offset.x - delta * pixel_ratio(scrollable, movable), + self.start_offset.y, + ) + } + ScrollbarAxis::Vertical => { + let delta = ev.position.y - self.start_mouse.y; + let content_height = bounds.height * self.zoom; + let track_height = scrollbar_vertical_track(window_bounds).size.height; + let movable = (track_height + - thumb_length(track_height, window_bounds.size.height, content_height)) + .max(px(1.0)); + let scrollable = (content_height - window_bounds.size.height).max(px(1.0)); + Point::new( + self.start_offset.x, + self.start_offset.y - delta * pixel_ratio(scrollable, movable), + ) + } + }; + ctx.set_offset(next_offset); + ctx.notify(); + InteractionResult::Continue + } + + fn on_mouse_up( + &mut self, + _event: &gpui::MouseUpEvent, + ctx: &mut PluginContext, + ) -> InteractionResult { + ctx.cancel_interaction(); + InteractionResult::End + } +} + +#[derive(Clone, Copy)] +struct WorldBounds { + min_x: f32, + min_y: f32, + width: Pixels, + height: Pixels, +} + +struct ScrollbarMetrics { + horizontal_track: Option>, + horizontal_thumb: Option>, + vertical_track: Option>, + vertical_thumb: Option>, +} + +impl ScrollbarMetrics { + fn from_render_context(ctx: &RenderContext) -> Option { + let bounds = graph_world_bounds_from_render(ctx)?; + let window_bounds = ctx.window_bounds()?; + let zoom = ctx.zoom(); + let content_width = bounds.width * zoom; + let content_height = bounds.height * zoom; + let horizontal_track = (content_width > window_bounds.size.width) + .then(|| scrollbar_horizontal_track(window_bounds)); + let vertical_track = (content_height > window_bounds.size.height) + .then(|| scrollbar_vertical_track(window_bounds)); + let horizontal_thumb = horizontal_track.map(|track| { + horizontal_thumb_bounds( + track, + window_bounds.size.width, + content_width, + bounds, + zoom, + ctx.offset().x, + ) + }); + let vertical_thumb = vertical_track.map(|track| { + vertical_thumb_bounds( + track, + window_bounds.size.height, + content_height, + bounds, + zoom, + ctx.offset().y, + ) + }); + Some(Self { + horizontal_track, + horizontal_thumb, + vertical_track, + vertical_thumb, + }) + } +} + +fn graph_world_bounds(ctx: &PluginContext) -> Option { + ctx.graph.nodes_world_aabb().map(world_bounds_from_aabb) +} + +fn graph_world_bounds_from_render(ctx: &RenderContext) -> Option { + ctx.graph.nodes_world_aabb().map(world_bounds_from_aabb) +} + +fn world_bounds_from_aabb((min_x, min_y, width, height): (f32, f32, f32, f32)) -> WorldBounds { + WorldBounds { + min_x: min_x - CONTENT_PADDING, + min_y: min_y - CONTENT_PADDING, + width: px(width + 2.0 * CONTENT_PADDING), + height: px(height + 2.0 * CONTENT_PADDING), + } +} + +fn scrollbar_horizontal_track(window_bounds: Bounds) -> Bounds { + Bounds::new( + Point::new( + px(SCROLLBAR_MARGIN), + window_bounds.size.height - px(SCROLLBAR_MARGIN + SCROLLBAR_THICKNESS), + ), + Size::new( + window_bounds.size.width - px(2.0 * SCROLLBAR_MARGIN + SCROLLBAR_THICKNESS), + px(SCROLLBAR_THICKNESS), + ), + ) +} + +fn scrollbar_vertical_track(window_bounds: Bounds) -> Bounds { + Bounds::new( + Point::new( + window_bounds.size.width - px(SCROLLBAR_MARGIN + SCROLLBAR_THICKNESS), + px(SCROLLBAR_MARGIN), + ), + Size::new( + px(SCROLLBAR_THICKNESS), + window_bounds.size.height - px(2.0 * SCROLLBAR_MARGIN + SCROLLBAR_THICKNESS), + ), + ) +} + +fn horizontal_thumb_bounds( + track: Bounds, + viewport_width: Pixels, + content_width: Pixels, + bounds: WorldBounds, + zoom: f32, + offset_x: Pixels, +) -> Bounds { + let length = thumb_length(track.size.width, viewport_width, content_width); + let movable = (track.size.width - length).max(px(0.0)); + let scrollable = (content_width - viewport_width).max(px(1.0)); + let content_start = px(bounds.min_x) * zoom + offset_x; + let ratio = (-content_start / scrollable).clamp(0.0, 1.0); + Bounds::new( + Point::new(track.origin.x + movable * ratio, track.origin.y), + Size::new(length, track.size.height), + ) +} + +fn vertical_thumb_bounds( + track: Bounds, + viewport_height: Pixels, + content_height: Pixels, + bounds: WorldBounds, + zoom: f32, + offset_y: Pixels, +) -> Bounds { + let length = thumb_length(track.size.height, viewport_height, content_height); + let movable = (track.size.height - length).max(px(0.0)); + let scrollable = (content_height - viewport_height).max(px(1.0)); + let content_start = px(bounds.min_y) * zoom + offset_y; + let ratio = (-content_start / scrollable).clamp(0.0, 1.0); + Bounds::new( + Point::new(track.origin.x, track.origin.y + movable * ratio), + Size::new(track.size.width, length), + ) +} + +pub(super) fn thumb_length( + track_length: Pixels, + viewport_length: Pixels, + content_length: Pixels, +) -> Pixels { + (track_length * pixel_ratio(viewport_length, content_length)) + .clamp(px(SCROLLBAR_MIN_THUMB), track_length) +} + +fn pixel_ratio(numerator: Pixels, denominator: Pixels) -> f32 { + f32::from(numerator) / f32::from(denominator) +} diff --git a/crates/ui/src/input/lsp/completions.rs b/crates/ui/src/input/lsp/completions.rs index b594281dac..59d56a4fc6 100644 --- a/crates/ui/src/input/lsp/completions.rs +++ b/crates/ui/src/input/lsp/completions.rs @@ -30,11 +30,19 @@ fn completion_menu_action( new_offset: usize, start_offset: usize, ) -> CompletionMenuAction { + if new_offset < start_offset { + return if has_existing_menu { + CompletionMenuAction::Hide + } else { + CompletionMenuAction::Ignore + }; + } + if !has_existing_menu && !is_trigger { return CompletionMenuAction::Ignore; } - if has_existing_menu && (full_text.trim().is_empty() || new_offset < start_offset) { + if has_existing_menu && full_text.trim().is_empty() { return CompletionMenuAction::Hide; } @@ -152,7 +160,7 @@ impl InputState { // It will check if menu is open before showing the suggestion. self.schedule_inline_completion(window, cx); - let start = range.end; + let start = range.start; let new_offset = self.cursor(); let existing_menu = match self.context_menu.as_ref() { Some(ContextMenu::Completion(menu)) => Some(menu), @@ -390,6 +398,14 @@ mod tests { ); } + #[test] + fn ignores_trigger_without_existing_menu_when_cursor_is_before_trigger_start() { + assert_eq!( + completion_menu_action(false, true, "n", 1, 3), + CompletionMenuAction::Ignore + ); + } + #[test] fn refreshes_existing_menu_on_delete_when_text_still_has_context() { assert_eq!( From 90d28e4cd1677273d5cfd92e7ea0c00727471f0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=83=A1=E9=A3=9E?= <1835698775@qq.com> Date: Fri, 8 May 2026 20:52:49 +0800 Subject: [PATCH 05/45] =?UTF-8?q?fix(er=5Fdiagram):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E6=BB=9A=E5=8A=A8=E9=9D=A2=E6=9D=BF=E6=8F=92=E4=BB=B6=E5=88=B7?= =?UTF-8?q?=E6=96=B0=E8=B0=83=E5=BA=A6=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在刷新调度开始时缓存所有节点端口偏移 - 调用通知方法确保界面正确更新 - 保持原有的定时调度逻辑不变 --- crates/db_view/src/er_diagram/scroll_pan_plugin.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/db_view/src/er_diagram/scroll_pan_plugin.rs b/crates/db_view/src/er_diagram/scroll_pan_plugin.rs index d3fc534889..f1a61244e3 100644 --- a/crates/db_view/src/er_diagram/scroll_pan_plugin.rs +++ b/crates/db_view/src/er_diagram/scroll_pan_plugin.rs @@ -31,6 +31,8 @@ impl Plugin for ErDiagramScrollPanPlugin { FlowEvent::DrawableBoundsReady => { if !self.refresh_scheduled { self.refresh_scheduled = true; + ctx.cache_all_node_port_offset(); + ctx.notify(); ctx.schedule_after(Duration::from_millis(16)); } EventResult::Continue From d4774a19fcd49bfdf005e55a4aa084b9613d62a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=83=A1=E9=A3=9E?= <1835698775@qq.com> Date: Fri, 8 May 2026 20:59:29 +0800 Subject: [PATCH 06/45] =?UTF-8?q?docs(readme):=20=E6=B7=BB=E5=8A=A0ER?= =?UTF-8?q?=E5=9B=BE=E5=8F=8A=E8=87=B4=E8=B0=A2=E9=83=A8=E5=88=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在英文README中添加ER图及相关致谢链接 - 在中文README中添加ER图及对应致谢说明 - 保持两者内容一致,增强文档信息完整性 - 提升项目文档的可读性和参考价值 --- README.md | 5 +++++ README_CN.md | 5 +++++ er.png | Bin 0 -> 290459 bytes 3 files changed, 10 insertions(+) create mode 100644 er.png diff --git a/README.md b/README.md index dbe18679d2..992499482e 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,11 @@ **Edit files directly from the app, with syntax highlighting and autocomplete.** ![remote_file_editor](remote_file_editor.png) +**ER Diagram** +![ER Diagram](er.png) + +Thanks to [ferrum-flow](https://github.com/tu6ge/ferrum-flow.git). + ## Platform Support | Platform | Architecture | Rendering | diff --git a/README_CN.md b/README_CN.md index 4ccbd854f7..396b268381 100644 --- a/README_CN.md +++ b/README_CN.md @@ -75,6 +75,11 @@ **直接在应用程序中编辑文件,具备语法高亮显示和自动完成功能。** ![remote_file_editor](remote_file_editor.png) +**ER 图** +![ER 图](er.png) + +感谢 [ferrum-flow](https://github.com/tu6ge/ferrum-flow.git)。 + ## 平台支持 | 平台 | 架构 | 渲染后端 | diff --git a/er.png b/er.png new file mode 100644 index 0000000000000000000000000000000000000000..5cb6c3ba0682cd565686c957cb883d64cb6e73c1 GIT binary patch literal 290459 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 z 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¥oCnbGnT^pUll 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%0DCl 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%0Dc^C!l>rPUxWZD$Lv=7%z;-$r^TT4`O{G&BMN z!M<)9G*%`p#-j4gnq^6YyvKhWjqFT|ms5dl6g{Ou-*g?0$}k{4cb7gMMRIGP0Nq`0 zaBrSuy@WheQ{gERv?mMT8iBya!AVl+ zfJY>!bW*+C!4yy&AkUrwMyH{wn(j0VbX$-KMXe3Cw`cDZ!g$RR{u{P2P?LErL>qtd zcZ{@lIX&D4RyAERa?-^Z0fZ3*>+#6}t-NH9mZAN<~cXO#_msP;8*93MH~AG5!DjmJ9ePEY8wy5r*m;S_KZ03;9ki9-9GDuFTjVYa;VazbRR zAM|`md|46S;=p9ZmFPeFySJEO<0quQcx>EZG<9KArxqt5nP5(OPb?T_%c7#90gbbO z2}z~Agje>K{Cb()d@B$uA*BGgNR@wDnE37~zDJYNb}REhOeuDaKd6)!ux4d_}SyriS0MYX`-7wGMv5BZ^K z1ru>Vp`y?P;UiZ1tzs$3zpyE=%2uA#%bmr)x$gE}sC`70`8E|kx#L3bXY*2Uf_w3O zC7{-`+OltGlh4LoaqfkiTWo#u$HGNc73JkEttPcThBM@j9JGB0W5^KVCoMmJ78hmH zoGWwS`2>@2X%5AldGP`a$d8zv`%{Z`0G!CARo+A(UF zm!EmAe`INN^q9(Pl4o!E;n@}Uy~lCw7Im8pT{t*UzM9tGA2^3;;)g)FkK!p62WQ+~ z8;|6Wk;xwmuK1pZwh_R?z3l&Wpwp`~Jt`VwytVfY+O##=e^p3LjT2ltp(qICo_}$^ zUTcM5@OgZ;oQ0v$PJL7gb{YOQWToLzQB4832xuGp^I#06@71lpI*i)=OM?ZV1C-X0L(PUXh7udmas0~=+%1s*Ia zhbg2XaBgUPv4j1qqPLMU5W5T7Rx4<~HV4xjQ~+S))AEPa)(YJJPAw14zV` z%MN6s(g=M~?5=jgex64n@j<~)#<=^cD`LZ?>1#Aplh0gEJp_2kx$Qs6evRfO-w#Az zTpMGsftl?&SU2hw zj;!;^#XlLCoUDdU5(XEnk$6yvSC^I^0M%~N<3Ov}G%@;FX}jMT2!xtz4v&sxqS$A^ z!`tn55KmEDY`Fk;ECnScB`z*am^dx21UP%(kdTr>0BZ-h^+QUNsn!T!erBs=aN1E&SYA0fDXw|< zImN|0u@ohUrMpeTN0x==m1mql$>Y9scfc!aVQv95PuLjC)+WwVQ}=vC=-es0nw$H2 z>LUE4Wj@@ven1r1Xcw(X9|_iF!Q_I@=W!X3lnkJXc7heFS7UCO3zDUyiAdp+i$T_TrjqN`QLD#gkt?-O$*WsIRxMQMFM}C={o)G9}=+;`jE- znT|StsmGEOV&91y*tPJ@f#D6)l14pVK3-;br=~C6Pm2@m%4Vl~!eRW-o}R=it95$v z=CvH2$1?~Nl0CbD)xTA~B(A0w9hUz9?(H83w)J0idN=@->^qU5o@T~Ov(IC4D+Lv; zi9JQuy4+DzGi9 z8w!c^UVIu>l(a3(FzP3 z!;V`a>GsfjR|%Sxe9N9)ST}h79EHESmg1tl@c9p(;b3HJ9m&?Rxz0GfX);yqQ3dM5 zr#d|Bk86jTpWpov8~TZmw6_Ks$m_9>>)R2{ZJ0fp*^bs-ap!;RbG$n*zd7Bxb`)g` z_1^U6@}6PWfzvHZZBI{aYoJAtI@mVa{d<{~eh)Jf;-t`}9XL_{{k<<_?@&UyzgM^8 zyx~0R^U-#%Vu^+LA#ioA32HTC9spm568Aq<%hGKkqzVmg@xHn`{5gSNjh%1(tmY<{ zaxOi$3Pp@9^}2(OJe-O7Xt#=l?UkX783d$Lt~!-Y=G{GfVh zM+J%8RXbTLPpDxT@)C8onxjc1BHj&cQ5oU|(KH>}D4f8DPY;UyO!;tPSF zUl#*@wtqiD-74(2@+pBs|{ zKP{yT3tH`Otf{HL8A(fcSULn8x3-TXrSG}BJp%AKmr~@=Zm7m!WuxU6Qy4|{=<}?| zOK7!Un=tN1FyU8e{bS~`^siFoX`&6Ck~CuHo{IoEcGm1lDq0f21I9N*QLX(Xj& zH`j&#e)j(iHjm%I!qVK{o7=Y{k-j7igXS#*_URqJ^vdkYQgjpv1tOfO{}HJy;Tdq~ zHT~IVO+_Q&&_nZP@Qbga!0}hl|Md6EYW1)Dr(klRh@ksxuH?Li>cRiHynWxG9viqQ z+5YPZA$z6v7aQ?zzV@HT(fK}%TU8_eB81ZqlZVEx!5`x{I+w<8G?cKDGAItRqbXq5 zb$eo0@3GtLOT6m@nd5L(-MiZ&q!SwQ?++sS`$0u}#>pmAbh?gJn#K9$+S*!dAyScR z6}0YF_}KCf;UPhJMfWl&{ znn{yU{@iU5bvxImpv&I&7c?E1n2_p4eo7!?4W&s+?Tc ze*Bn9R%>Uc6*L9yP7V$ZC>{+K02$C0QV2!e&^NmYLTC(({$Xmk1Oz6Unv9@JBjqzN zGz4A%U}@8!f1cgpGUjeq0xBZPGr~mxti;9i_3uTt#d8`5kIqm~Bp-i4EAR1-txu1o z5i%AaiY>YG=_fssFE}r}+OtA&w?QUJQS^$yo~C8L&&`VU*{+5!2zu+UaVScn{HEXO01F1jSS!_%V}Tu`q?G38;tl4 zrT<&*%0b!xiMwJhXWZ4O87M`}<9fJj{HTWV5*jgVigGsMnlF_H#^~U_?K<+a4$5D1 zyxnfxNB8%UpbM>~y07t4K_NP; zX17-g)Zxa3n^dC9d+t{Ei2|!f(y?nHvA_%QnIN!sRFwAgJO|xlwBdB33 z-9esDCKw|XY*SQov~)u1&vsSX*_DF=U_8*ZH6X0_tjwV!FDQqGgU870s6Yby^Z8u) z{|`hL^`9qrXtjS^tbe_@)0-In1%FQ`?;D4l^50No7Yc?ycGC`)lgnFMHUm1hufBmH zF=YtovcHX5897ab9D;g?oPr{H9o$S{9f1Z7Qbw=}vY03@)~WG~*Lg%oHy^|^X<}wZ zLjx}eh+P2SR#sAKY-$3|0O%1}!I=mYL{NHXioq}dMS^J;%KSfZV8Co2SVeo4XPm)8V^`_0n3X?`1zM?UTcvcl=Xa5vw5rZUP=f!!47Sbv8WjgW@pl;sbrM1fFO{ z$y8;$-B!!3q!&9n(zku|fUz<38`r;OvXef&CNJM#UfD_8%+<-s>O3_^D9?2hQ=rQ2 zb&V>}dDe$wMqLR5x%g@NJ33N|iq?APf25{HYp^52@MsQTRbZpL0M8Y$>|LHy4HGXi>L8`3 z_5@E3z-qJOKJ3B`9tNTlxT{D?Nddd;6#mTp5ZwP;ehm|=dXaeXs4N6Dnt;SRlVvDR z=K{P1pu(&M?UwEq* z?aJ0qfIbLg^YlHH6x1CQZA+XHqG(b$FHobM+BRM-)0q86{;S74XGhHG2eZ}lK=+P> zrgC~-mBc}2cBj(D8$rH~d!sT*;ue;MGb_FOT2#@}C5yG&P{2kHgkc`J8?onSu8m)& zr^edaah*?DjN*58nCo7(z2$~YnU|XpSnckywlvdvoUVN^loG-l?AlIOYMLFBZlVuVr<32-I9s!1J<;{|1_K<-bnnZi0fD$v4&qP&PU& zbRpy&&)yQ@WhMy$xt4q(ets7U32EK91W17*YZlo(>d!Y}7-A zC&Azc;V|DyFz_oK6-gB)?zaT{mx_#^zhB#Yu6l8Rzbp`P#(s^KYu&-Y*#>JIJb5^{ zz})D0!aA_m9x%S_Qh9>>Gbcg+8EIn^+BOfoviO6%tSoT-ifhsWgv`>yqJMb>O`xmA z`)m~dp0zO+PRbneuvv{ZDP*7fv(hhK3s@^$Em!ab?FYwDl6kUk~M;%t$DLT zW&mRLw4xEP#JSrWw?$UM{}**{9aLr5wU2HL6i^UQN&!KnJEau?De0E(R=UFgX+)$; zQaYqt0qO1r=?3ZMtgY|+{l0T%&YZu`9>;N1*bn=;pZi|xT30yX%83}3?1--3kHPue z_gS7*kvBEf5L<5K2TB__3FA?ckq`-NPu^?iXe#FBzCu3x=hnraJ~vQk1C0OC=Oa#U zv@J_k$|ol6AS&|v=Ix;kn=cKqapt#vZ%PRR$*^_dkR&4@>?&LDUi?5u7b!Mk&-%Z$ zKh#dBchRro%xZmLIzrw;N=C_K5vI-?4wlT;j%XMd5T7gs7TQWm;j3+pW>MZBCnqP7 zK3*p41upxmAWbSOE(SXra5TV`L$Y{uwt>a-?PcuH#T^BOet;oavo|QIsIt>quuOZt zKI;&bm;c-}8ddTK3=l}3y!o1zW(fQ=fP__E@m4&#^7bJ=f4nc{%b3*s{5pWSKy3@E z*#P2wM>{)u8X9i5qk<;2Oo$-spfA(___3r4u}o-zcl*y^H{%z>duGAXhBoSs88l_8 zO?thr*n;Y-*>A-UL%7j?=A)eo`vEZkah1+(E-x?Qg zcr<%#C+7NA(VGh?0hx0S#t5-et5c3gbdkrm+VvoI@9;Bz;^1)A;ZKSGLjdZopvOBt zb*U+KVw73$qRV%QheIlXZ= z;4w#u{g5(Vn*%A+VOv(rm*NgLB@`DCT6A2c4YyL2{(DK`ccev%a*|1N-up6~%?x57RTY?TVTX$4 zMU~-;)52b=LC{ zH(otixTlo0(bW|!{3TL*;N7*G2zFhgXpirae>*6zk4bCGy54y6#LF3%Db;n&Xrf4v zz%IR1ZmA-jbmxZ^Sy*e7UnppU0PN#kO@Gj^IyaYc-U@og$~Rx+f#u*_;X`E`?5D9(leIioiO4GhaNt-(C?BMS+IR z&*j7AEIV9RnPl|;YX;Ja`_}5}syg}i((m)TTl`9pHg09qcbu}j_lAZhy9Il~vMxKd zcs)O?%)4@=P~A{gD*1I4AmywLp`?QJbkSV4 zkmjP|VhZv_btq$j1)fC=W}30FgD)nd!I6%LD615zV5BMhM!+$MT$@8k^Ob#eYC$~& z#BNg0v!5RtVLSyk{Hyar#lZeqHBNMgKG6YamoT>DU|zOx*@o$~vZ5lh?MpTWu*DpM zZwH7sNSHH+t<+VT!0<{zUS2w$XCLBZC66Bv-M?UOHP)I zz~8^@#%l64BO0r{qi;G9Z+rJPI>rhAYMd;EbbAXLQP06UiS7=_gQLeYmsMr%XEp32 z$W%0XZQO9JxZ~lR?sHVp_#nJ_{I&&j&e$}UF%>PhuKos*lE<6MIM~Nd6`ctz(JK}@ zKY|{RXsN2=IMUHQgbktY(vsJX!Qol9- z`D0@YR!S%OqPOerxWA;pSwi1|rbzqsp?7ze$>p`EXC2^^IJ$Aw+0|L2mPlJ=db_#R zR^eNqLMc#qMYL;wTF?#a=jCMRr59KU7INC|f9~_&*`X~F9YjY#@W>+xAAbRjFC+w= zw7d@+H(35w!94o9^?7+)ZBaTHVF|DLb%6j5tR*?~f&Tti;8gVPItCB-r)Z8CUl7eG zj>BkSA9~14d$6Z4W|dr|E_v>|40I>Grm-xS{XvyTdbQ+EO#nH2}qqmb_lYWKBs|l z>p4!V2~`yp{CoG(;?z!H>laGu>FcwB0T)!*110MRSm@^L$ zU3V%gm|yp%yQegG^@`i=+qbVfAFJdwVTkCZhwLc-(nG^uSz9ybyH}J|s7fwpIlt=! zM8+gpc8=_6h~2x|{uiqCis`b|Hri@1XlMzqLPg?VPmTGAxivBT_&GY;$Mi=m4!{L~ zGiU+*qVo9@(1PNA?%TmY-@*%LXJ<$A zhQqqx+^o@3DcYQbuA{Nlr}ASXa1@6l`0LkqGL3L#8Lh<>H0k(SltS*Cb z#%6@e3DI9zJRa?^7vMjWUbzM!T|MXonFWjciJg(_<;6KXCN&z2P^q}<0K2M>bHhwd zuJ^~=Lz_s@>eI=H3B$8lWxo~y7R9S_@|nf|Z``XL>c=2Qn=>tWvjVP0NIz5~$p6v@ z3Sx&w=VbUDHrO`_Nl8IuRtyAq&_0>0L!b|Lrea%;Z;r-YwH0vtfsJb8qcWROcLFq1 zn{jb*;KmEiub_TAJ9FcV<%iR|3#0$y8H!#0ovqSg{8$l^gY~B>?w-c(?=iJZU-RjA z==Nja9tp;qG2};h+EyD5R$~r78x{w07D@M0n5P>rUOPB^+1+7z&)*4kVg|*dO+Y2@ z7b&*`9z*^1aUv7e@)qY#;KLJuT_|;YAl_8l1;leJA#% zFuJo!ZJI&LQqA#d04xu+(RUA?p+NlEk`9;NkaBq!*=sI;h%jNJR3+1e^N z|0&79gcHOYkhd$!k8hmSw<34-srLJS;H_7$v(QAZmZq)7hepl!$er^m+uZY82bwsm zJW(TUf&2ogMc1vW@DsbaIIScN@tCxbo)9`*ZUkTFQh}duy7%tpU9s1x;#TRB!CJUo zL}Rz7U&F=xztFt2%>Tb>USk&oVAPS7t2ALq)vWbLJUfC}1S0Nm{%s2HZjj$j;+1D^%)if=O^KAM07BU!PK>id4 z2jY#%*xiTxk3Np7R_hEYJn|VCRcAT2*3~VHSSmx-fX>2Sw7-%}D=Q68NlU#H@9Zlv zO3#{kS~tp;09XtwC#!*$#;>#mp7@W1>*EtSwqgJkloQx)G<7x}RDq3+urTxyScj#; z6Fr~01CxU*Y@X-Z?k%i!%=ek~zl0x!m9!?B;6CE%BjjMN{$H8su11AseaZ}uzR&Ex)W%Y)DDrsuyGD;KSf8W(RB3+-SrQ?v`#NkGBGsWT(EwPhvFyPppF1160X^>Vm`qD5uhsy+pTslf8U`stWu>v z-fW%}ymVQvPgB)2$1~4bZ7VqnR+iYjiHoD6sJy2B)KYV@ch1;hOE4nzv8-h`_T7o< zcp>5EsMpulrekGkpY$a9{v~>C)igp*P2R6b%4CG#>%p$9$23bhs0fn~!l!}%Hm8%E zE?m7qyl4%wFuHkk`8$%wr04sj0l$V81vy`Y9EYTQyZmv9>f^INpnp`=V2AW;!a7*( z_Yt<^k>2}}5LSOXs;kDg(+KIagft%oQXQ;ZB+E5%ym$z?Xc%zOxlQ+=GU5Lc>C!h`kwzmQ=}`Bv0Oc_A|@V z?#yl6sA$@8<|v~j>EM79h#kh=ny};aUf_UW@MLQ-QQbRvt89ye#qQI`kLIRoB4@b< zl<354X61Ptr>9O$lM>PrtxxP1^}OmrWT_>@J9U(=4Efc-kP`f@hq&0IqgR;=ZJktG zGK+dkO7;aWs$Z}6>z~{>TD5)>J^F51CgBk|ql#zW2f3{C<`7~+{6vaS3CFEdOTc3i zHW@`88{WV?CyS(0r620Y9h~NL4DgR`{Q-{eb~QAJWrYHmazp)>n08*>6Gd4Gs!kYu z_OxZo+;`!A!=si6N7nfSwboWJnX=1j=C^n6-vfCVlbDD7^%Nf!)i-IL?pUtwiHS4F zoj13#x_}!$Z~$FY-Tu(4j?}iSq1;!8zzW*{A0p7JpPzLDvlVFzGKO9o2)Cb&=6eiK zxNjIG0__2G4kLy7a0oU5NC!y^6V9ITKrzfwEt~~oAr(u2`YK!x^K2U5xI(`O#}cTq zg+TBQZA6=qc|yA3$=(VSE$@ZN!CMe~I$}8Y`8uG0WazJVraA5_w-Yx@*c%RDUZXUP-BLZ39qOGrAcdS}`|*5G ziZ;t6D3lgKpn=A`7lKq1gM`h+$*2_AU^Jis8W=70JDi-1GhjW24sD}*ZfmXecR_T@ zmD4p@)Q=4w*1zM3t1y9OM$GN82<)*o&65+C_6T}*uBxq5M&*dbZl;(T{iUBVV<(`| zQ<~&3UhL273N~9Z-mD)Ue{T5!OfE$KgNS_@SaiNHSF|p>eFa7H_j`V@0x50!NDzp{ z0&}Okk&jy$LI*k9{95WiY^=0E<>-0$PeE)fFkgYy^y&pM>TM#5a0##_8s9>0nw+nv zI_PQnz&8pB?g6g)2pl}}hn(v_pn>=SAnJ~mmX_95pmhNs32gqKJ$nWfCHV83d3wNO z2ah0F?1Lj9JtS-v6v!sYf++!{;vp^Mp%#Jg4E++~$$-_>U$CRHMt49omwOq$mJ=BV zhZ1=xcpo{CQEblpvFN+8Ah zGSEESJ$AZ5*k7U1rB@htGAg*cH-Zq{k1Wv0YiVhF zoQzUAKZNMvswl5o#MnX7Twnvx?(dzM!Nk#9eo60_+Ccj33gU-;YVzt-WkK)(k;k>O zYbKXlsD>CtNoQr=vrI7YtN7XTr1YMmcv|1sm^#c4AvN|~?k^>)V2O0=f0$Y&^mt$M zqiahee1knlPK&Nxy3-!>2KWW>+OfBdjBFQSO|O_lyt`p$c@NAq_4RXo?0|mWl#ci4 z5m*&1GT|B|DHSbT=;yld>IA? z=yJ`{*uC?FGC(V`+%^TC!%VA}46diKI-^L=iKKUWXk(Sb=Dh5BcH&$S@@#mPwjoYW;cT}uB-uYB@xUldeA552)4+MyloBwI+;;9;KD&l?(=|-X3 zG5u}owh8(@HVv=7ulr@w3p5O%lE!k|&w`}%(w7H+W@&J3+LnnXyWE*~wC9r`;N?@f z*MNocZx8qy(qnXDQu5)b7yo+vv%h4pkU}s_*mpofAVS)H%pkN$vEI&D3CXLQh+R%G zEbfg;KHxMS`%>uRvx+f(6=Cw>Ur{hq8=baADl_3K^6Y;YSe^>`Os=(Mk|w)5ySpw` z*APw}*RCBeVeBj%{rMY~b>`jb6W&^=!MLPTxiZzTx$%!s0xM|-txJZ8i%w93uM+NTLN4~e|L}62JRm18ERJl~L7$lks7D~&MV-~0ku))Q)nvMVm^ilU z2k_!FY;Ay0QMd9hQ^rf+=da)}74oWhy5Q}F13khr+h0;FHL8Fu@7Sc8Y za_=1y)?5bv{iM7|n2iaxB(k353mo_gZtFPTLD;lb0hwq| zw{(kCZM?}DxcMKp)Rmd9y!_}H;9rX~EdCK_==`T4`1iuQqPdTC8SS=bo@MUUmOMUk ztA)6w>};ep20IB3F|nqG#zZgLsKo)C_t`uFDXR3fkkAef3@8MEZx|9H^YrOcVc`!c zDf%y{GIMkJ!DWvolvtPprbf^}p#nO^*FR4w`VQwT|Q8 zC47sxVLp*osaHrODVd$smL=Vl7)O<9MNV#0sOx=gOeWqW`CEG#CYXxaEuYW-htb%h zQq9$T)ZXyY`SJJeLoQs7Ly6m)!EuL+8JC~%5r_l}n4-FKQ+VhI9<-&FYS3F>c(rk-h+CBVeSkI^RU7tE(#|mh;CZ5V#2tP=XKI(zb}49D4FG zurvb8NkHv^%cXRxUFBd2;{ahr6_qmR0uJykA(9EinApH42gcqJZ`J?se$0d>MnlJReD_yHARhx zs5p-Wv$M5!?U~(MUnUcx`8dgF_VqgUb+L6J{$tWEAAu#AXDYB9ETjh-7W4aS!8Wrw zdZzZce<}v1z2`Rd*48bu6ohU`{Kd(^TMPXL#(}3c3%{F^(_nQqv}TG3HsS9OwF) zYs=|s-nok+5=x8i`vuX5BOxUA2qdtSVJo|fhs{9^%xo)R*XoZw4vG?g*Sc<1?MfL@ z+D>tUL6HNb2>{rPV78eT`#c$&nCQ9E_t;PPJ5c$dX9fn6tm8U#f}TgeZ?Lw`w}wHw z*<&1W7nc+0(%073z|rN@^YYLWD*j>K6i8U+WKts!1yT$bCnwn5BXha{Fy2TLUL%Am z(itX^@PhQ4{{H^q9JO8O=RdbXRS3d_;&Ve*H8V3>1OSTgpqq_DRvwQ)FBqUAi8wn* zrUrO0!+fc_`V6E*u#h173#^#d!KVOx+;2B;juuhz@VG4ZBxyQ}-b}jjvPjJB;OrhE zirf8jX<>;{rTu9Fi2d;bxLpj&t76Ud6>Np{%rxES@~OI52m<})L``|iihWVMx|f{G z32B;UbY6cu?cQlK!y#MTEmxonF8p2!63PHLLmk@IsfVs()o@2}S&;HTV~UAW&I zoTzqKzavnExLLCD)vWlp+=g;vJf@wktxC(idHq1f&<~VK=^rcEG>LOT3gKFI_JRo2 z4_oLa*@rtW#q@;j4Pp#JHjj^iW>I9A+v?m|cb(8Mor{c6z<5)F%tbOhg4*djr% z1t=isB;bcHa(OORZ{FOm1;N(BzR)8z06)oe9EPm`O_hLi31GR)sD5E#VZc|qfdJVB zASVI>hkff-)3xVayjugq!^pf}n4tA#^VV35vTmHT9=3q{HvlQ-&d$fsP9w3cm3vVF z?v)e`Q+X)FG2zz{-U+n@$c&^dhRn-m1*cCy!@-33#7r|cN700Xd?BUY~t#}6LWT> z2@+Cm&`ROdxXosX#wbP~*f{>UY0b1U&FOpR?+rr!_9>H@8XdtZ&vwIw?COk>kUSjs zjxRVkIj5z>SO_T+*wSGS~o(34-8B0lTrG$!!CE# z3pQ4mELom{sl7<;@j2*DbMKR(dSO2j{j~n7LIY{_8rQ4>V$r}@^hOv#3HT1l#~Q)`P>HRFn6aq1k9pPf6E!S5D==r%O1X=zIoX+{G>90WceDCE_Y>@}b3vqdL; z&Dc{Gve9%;u}Z|VHdmH@=B8(+YaP9F(b;~xq*Ov-%gESZL42Ia5D*A|i8kEL)CK%6 zE%WFNm|!I~M%71;LzQL#grXG(vGx`euNGh3${^s6fOudn^Oe`w#00&|-WvopLq70C z4igju2oUrPJk!D7GS$9BBwR(vJrc4n+ll9JXQg3xXV|R8Cp;wUO6}R3+*_BXq5`-M zuG9V{@iEo_y_vW~r&xDK?(3fEN+m`WMaT~u;#e**XP4DfP*KwSCcm%WOS|`}XEIrp zE4p04%4+ih1;ydOL4zbRnc6?u>=kz|(c1Q>r-644NsMqFefaMkPpaKFv675S2<@MH zTO&ou$qvq1kSx9po0c+dI54|3Yh3IdjUT#hu39!rO2u*lry+2iT}mAgMsUh*iD-hS ziuD1`g9m(uZC_wqoxtm81>0?W0zWJ34q(7wv<=Z9mAtr)ld!YG14DANwWe*U~O5DggR%yoIbU&YWwgzlVkOL%fg&9NJ)-4tgyz9-hiWNudl+ zaNY4NUAT{MvK~}OYPe#L1!I-XG1ohYi{le@_zV%BVzu{nljbVliVB2Ot+#sr=6Q{- zqW}8b`rvawSOX-(ADo{BXygH*9>1HA%Ynj=0yvbbMnc~?DLZwTg?vt!JXftJgPhR3 zbswsA|jL#1;xx;doe1?gP(5{8BdgzR7Vqf_8rL?cWDng^z0n&XXTp1QcK`8)cCf0 z^{KV^v1i>B24c&~$#D8YTcVA&D%9LOdtjSG7w8tZTcT+EJ1Z+GG=bGQ9Dwm*EYXrGHd^YJF-H6<>|7{By`Q@(8 zAb_Y~u(mc85yV;_fzeKGPL9~`Ws+M|Z;=xpsD0On^_U3&-hfw5T|F~!IH|CBeBHr{ z`xvA{po-mU8%>j^aqgLG>lk~B;fb}LtGkAVh8U|gaGrkma!olk zbaviGfN$7&I_0?r{|6G)=Nj>G@#`oLX&!5C-7&ScDSm`K8<8! z5Va6L7Hljmb4r4e@mLV5sxgwO-$(A3dGb1M&Zdog{w~o_uKv=J7;*E6&*rv$MLEya zr$u6^u{F05R#uD%Sye^KmBm-vj?<*HG}-L=zi@svySNzPK{ApFMKsd-PI3805)?Z2_9eQWVCUR9 z84Ya(_`5;s3Y>>_P>sL@782H+VLC)0TAM!DB`TC!ld7D3c{GQGcw=XG7o9i1-AoZG z8b9NYK0XH?;s{wKB@vRN)3tB6R^g~5!LJYdWB%p$}Ljf~DBhjaE0_w_FBU2=Q=84Eh1Kt$c)x7p0ebQ6$aRGFxZ zciS88ALQ6CwEk0MBsCPDnpJ7Fk~|9fjOvQ7%?O|Z0RYg|>y(ItcwI27bKA9B} z#b&z`Xo^eiFHijipYd0MUxmDunx=;O=Eh1oude&^{^g8qxWy~?yz}y3UqkS|x-U?- z%c}x$nfXmkSDz{xP>W2OQ!S)}7ea92M6$xp$-5q@0;7FarVz5R1P%^+gpR(x;ggEo zl6$Rkd6~JxxxyR27bVEwfGf(C_yg&|fuVel1M$zR454ifo}SEHbw@^SJJo9a9i1$W zxb|Jlo7v-ix%_@|+6@m`{^tb0v-@gM(oU--HReeabHwhtFpe6&3w1CdTy> zHh3~xT%M7!4CzCFr=oa)muO{G5d;W&L#u`G^72BY+$(bRq6fp9%JOo%9)^bxXY!xC zg!$9^sa}XBf-cGv;B;WW0e4~^>f4v!IX|3?`L^_gePe2w_lL*1M;9fPg^gF6GECtv z2l@BhW>&RueZp^wC+z zKlP)jY?1BJcZeZt8apuxCow7upMnJU9t&AzZZU6cr~1y(`uxDHAXG4QYax2DZmI2J zeLdh8EFSX7Cl3z3i1&JGRlsS#gW##8ryq(cFu>_S$Dg zKo^4S3m%vw0;VZ8067{Ql*m_8bMw+#e%;U}UXFYw;_1UXdpe=O6b9X7l@<&mk!pBk zMhWY4DXESET}wiLy~6T>f^BGeX~X>+epdr8pI`N}98_p@-J#t}VK?Su2wZ-9<0Iuc z2?-aFNfHRYrgNXzgs^T(V(cOWw>s9(=D$>pJ+!uI)5K9KIaw2ty6-!PQS&pG3W~ylGjk6&~SgVuK zvEEy0&mdIwMm@wEdk2mE4*3@!ONYBB&XVJ&HzM2S1xcP9biG;=Pmxc$1|_|v6S^h* zvBz?0*Xgc^DLaw1`JvhR>2zKD_w1raB5y^nzU(~VA|Tt-8FSQ{nlfsxeNM2O@1a%46|6^e+w-rtyi4OEB5Hc)_)JF=+>{VTOey$mkotJYpE~Q4Ra{S0Jwv)N zp@b62he!n^5P?iOU(hRt?~3>ejq2PI3?3v&KAG$JwiN^DKb;p zS2od~h%hudJ~1NnIjwZ|pk+yVim2$aLv~gkWX!fOQCs1ah^5(NN!;xZ0RUx6JpdA9HKcl1y(`mokC*680>jh5NpSrh1_ z>RaDozHcPDy)L--N?95OLGNm#E9=#L{(>W{DJ0pnv++-gr9JECb2q+7#u)dR?N&%) z#IvRwy*WBHmHsuOIH|W~abZmK#_4GSuLw$FcbHfzEYSBNM1560^mt*NMs#;2etiFt z5j)m?FZ~kz_kn4y>5Kb5@sF<~%+wX+UrHJv5YC2s5v-$o*sINtPB%NSaR2Ym*4+n0 z$Or4DavTZKdSYD6Xwh5uf(HmMsFe`R^e!EGg-+F7WrG6?Gc(Lk`O+w)R|A>^D8Z#8 zxSm3349p=2VMFnPwAGb}#6(ias~9ynbUxo720PiOB&0lc%g{U}nN|W0_3GIyM4y|H zg2Rpo;Elq<#rWwPgm45~Sy>qvRB3aDeKCLg^y^C+&iTzvm|o{2(+t4*7d*OOK^!C6 zu%9d`+I_s1IcvzWG;+{t>GA4VcgvLOnzO0*xo&paI92Yxenc3)s`XnXr7EiIMeHyg z`i9W@B#zg$q{rNz8 zd@D0U#O)|j0m0ljXlkb@{IxE>B>eivhORc6BPds@Y|u9_tHPB^TwL!Rj*u>0baXPM zQ=83}XMBAs2q#vWjE-&MlkOxj3e-#9eV!c8@tp5Jpn>1ufgktr+?` z0=xBClxRzi@;5$g5U(Si4)i6^I*F)? zyn*;~7tb+i>np2kQ>q6+-@VSx&iv{f^kZoU7+FsegZ(qV6{s=inkH-A$G0~pLB_@15H3TH!(LTL5fw%}Rd*?s$`gBB zlKx_%iiUzMd*e`V;kyWKbC8W)!Q$G85b?QP+g*%v@(N;nI>pr%y)`>$&|bDhQp`)IwY1mmFhM zyx(TFE{_hgFWt0b(Q}m4wM6>z-K+V;Fj^^wOlH(Is;y*VhCud;K)i50XV>!+mZ*P2 zkzgsLfq4S~WsERwzh!=IHrIw<&&*Q+*F9Usa{dNB)%=bvt(7l7Z@>TFP1oyC+Wx)w zf6jG0iTGLdV5Q_8g7_qhsm~W2!L#vD{2T;WkR=8{*1S!{R4+hh z8G~ksp3NgkxdGd5{u>AwZN9GAj}WrfCRQm?uK}|he}DAPbQ6CvfvvK&uplzqNilOW zqgvy_fq)@{=h+;ok2#V93qoMs+(rGPaF75gdBiwCq-C$VW@5u4*O2wtZ@ODN zjrG-iI_=k38*<=$@<8;RZDOgp^=^H)l<2lqy! z+EZIfixkE;5z@g9@C$Lr_D_tJ$$?RQJj|gGH?anpTJkkxwS26%Dk_UDvEvh>v&TJ- zS8w+x+)S!v=JaKl*MGUsscJ>^zd9psR1yAn<2YA;d~PCWOs;eQe(~}UBhL)BG^isOKB65C%miSt?+H3f5%OMmhNhk7g0!^B!lEIa73gCfozE@={`h6mn5r3fX`*B9M7GYGuBd4``wh*_&)-CJCPx^Bj`YoH@h?Fy zeSX99(V_6@=%|KYO&V8EbNl-*y+*9jkH~#AgCu=xLa9UBD1+}?zJ9HsuI$WkFf`er zf$$|E&a6qeE7Iv^(T8>F87gL2RVP?%&}(tuJbN!N4!17x1_HzO_mRo@{m&9@ZQ@FA zUfox)YQ{vAawmabCa&)t9%0*AvY2P;7!Fi#=b@7cpU z1ekCbBSjuTU8Ud{j~8$q0l|NtCDP!4&d$hSp7`SkkiE9sAMYu`O8`P^fL{d2KFGyE z3y?SU30Phbn#c>7KB#^mA45`H9DHJyab@^e3|c^72MUuV1nl-)2u;u zFA3#GyREPK%g8UvcJ2vZ5u}u1&qTd%)szJWMCz5q?vwC(4855-<5nnfWnhXEd=48x zSe*2Q<1`Oda5V2}Eze*mc7wlU8>41TG$3;;OT5J!4=`OjR}Ee6L*$7w4~oZ>Md?bz z({B1#)#h8kIdqxX7Pp+s2-Pjasf?iv?m_JQeE3KEZ>6x@3+}7K-df0ew)&h8^WtT2 zPfAN~YhlJ$ldmc)I-((g1pKVCfL*(YN&{eJYm0$URgZBkuef#lfJ@2AsnHjkHe902 zyL*ZE$rA_}0YHBeGDP2q>F7MnylZ(^MDCX8cmQYXa*(g@6JYsEOG|@mkZ4L_K>^af z0R!VziCMVr_nf~mcCVm80=h9Y$>JXHo2`z{4B(tFtd)tST%(F5Tyx$i83uDQR0PO2 zAYTOy0Yx~l#6B-&9NIx}N|nRU&EGZ^2At;Rb2N^kGA~d)ptb|W9>3es8wm-h@yUkU zV2TE3BIpzfi;DVV+aMX1m9?)R{tkEvLI4_!u6R{%31MOKzFGrD&hWs{_~_`!=;~!7 zqb7Uv;2_Ooj-u*h$b&BD>5SqG?KDzS96}(%Mh|xM;7yn2OpT4tZcI%N?*^$TYgf9Y zWR&M9k%PSN0c#@-xy4A&L%~Ta4}k5s{Yb3+ht7H3=Az4emGBMbO7z&pP*mnlolK;Kcq@bhi0xe7e} zQS!2iw2^dxEj9)E6OfSf_4G)k_Yr3wPN&se$}j60MBSb+_1J!2z+LDSu#i43E0LCk zF$tX;|FYRCQAvnqT;OHqalzV*{tf4^y(V^~ChSBp=_Ob2^cE&&hyI%(Y@*2XzIzi2;npV<|%BjH#T zHWfmYk+B%ww|xFgjdEWfuR9O@`BlK_@U~>Y&x5S-o*pmHmwXR-FmcQU73{ZaFPmfwJi+k9snFDwq)gNhLy^VRM%GG<=%+RsL^@&OZQFQT@hsJ^>Kh^U#h=q~h{(A2XjthKLYp6=jIHq5xYE*P|Mqw zSu+M}!*&5#p`z_s<%!T|i(du@`oC4@uO|J(3j6!knAo~MA*B5J^~d5ggbfWJG52i7 z;({tV449X?qqC~Gn1ya4)Kg=yuq1D|+{3!_x2YwvsO6`EcXKco3;1wxl1fo0f4;MB zr6~Cv-d5JtAub~BO~HyQ>ETLU+?|xfu+pZ2{WUkq@XP@xuaTl z5dBu`UF}k$v)K zl^wLVt&Q#h`_X5%0}p+Mv(`>F)Rk0}^pPLu9ijJV9*gTdG3js)s^_Mgs*iBpH~xl~ zsbqE?(ZhasFoR8UWvr=myC8u0>er9j53yMPb7c@6XKtP?l!QQFe$+QIYWlrb-7?Q* zY{(yc2e;5vf=uS!F|rPq4T1df73&XCS81)`| zSgd`r^8Ci%avv3^!W`Uf=w7rg9lGUz*=_aTRF39cwRktt9PM|Ci`-oJ-!O_#P15#@ zYKqD8#$>1Pz1&9Ay~FldI;Q!grecwr#(BxIL)bMOzSv!q)DSH}(IXmZ$KvIQ3rB7RMFT`V@p^gi)?~2Nij= zqWZ;M{CU?Ze>J4pR!B3AuQYS<`Az<(1b4m1|DljcBs%h6hrD7?jd-UgFHTMAaYmJn z_PO8@eA}qQ3{h(#%?p3ZA7FlKzHk4aiVR=!rNgvZzy|@Mj|*F{xh8FL6H%Fm_N2$; zDxrEBp}8ER>AqnbT*`QXqLrPumT11PxqyOzxHWecZX~+h_Z9jr|i4Dx;$bGi&R0m{oH~YFVOFdy1z%bW5{rd_jKoL*t#YxpMlh zEYr9tHz9%vseSHMIWJgL+v>>GbaVffM{wVuHL)o;Al|tNR=F=7PX+X~@w23_wO*&? z;x*bD^8L3HdY!E0v5gy`;>X7oP8o~KNUM@VGkP^Jc*Z;0>5|>Z*V<5UZ)F{^qiV<^0~@~bMd88 zl;ymv&9@hT7qV7YlO`IQSH4NNC@m_P4BYzqCwpS^e+D2S{%xWsMcR$t_pZwNi0As$quc9xvvX?xIlxIO z;;?diU+F(&Vt|u*koe2{*VrZ1GfKqF#XS)RJDbKktU_2AjEPj?Vv^_qe;o1tt%`pL z2^xZoY;lrERb_g>IKU<9--iCYJFwd%H0nZ`g-rmJ8E*V-zdNoK%!x5S?r%a+-kkPT z^m`MnDi11Y>2hQ}4+4(rnAG>CE9B~yQ_A#7Ud(N-XelUUTgr(UQrJjch?9t2`jg|| zyK8!x_)Lz%DKJ$31Bq0ybntxHy??iS;PPMno3vzdo8hxS)Q}JBc`B^@Hy#y?Fb(a@ zraxihlX|1d%;Uv2@o{(PFu5LmpJ~`o)zRgXb#c4m1yx zua#Lb>}~c!EybeG-!*TD1hZHK_WbCxk_^1}4OZXZHTrks-0Ru3Hd+X$lM=|yXRDPY z|9~1kVs{_WXoW4P*x9aui;4J>)iG8>gVCu@p3C|MKa(1i(ePmmpPY{bypsc6O;STlxY=wUx zu)jOf$ElI0rJrQ{($b}#G2=5Qzejx%{O*oWdYbap)&*i!0~E7BY%HGMXAf-O4it~$ zU>py?|3{-@k8#L}eFG;T0h?%speda+)}GM6rSCF-YflAPLwur<3Ng4OGH)}OT?tJ; zlRzR1E_dPYNUMzC?=GBqha{hD>UB8n?1=+h^MT}g>MT2aynnxqtl;03oAguX4}q0b za-U*r<>Ync=~DtV^g3Eso+(F8w%;wrkneH z4NBIP$yru#7{hnuUv+xW^ppuD=^5MvxH#0SD3LBkw2sX*(c(s~Mnp0{euc!s-^(7} zEDtnJ{{svRmR5^ysRi`%FYwKS4)mHR6a!K8KS*7tlm5pN8R-7I>P$X4(Z_|UWCn{X z9@r#AKdN9OM}7Dj4X%>tnvw9EUAjSIT$fjNE-nQoW@C+p?S@draJTk!fne+@c zaV*JSrubA6Tkm$+-W@1W~|gd{oYyayA;`t#3jIZ~HFN7^9JuhckDn%rc0$O@nKfY~&CHK9Xqk z$nG$SROB1}+bL0R{HuZ#r!;=x@67YJQzdYJ9=HB*iEwJmJWhQ?|MLZz3h?X+)OellWS!BWtru;@tTiv@vS=p}@slR||%0hX` zQ>3;;jCx+&B|?Ue|8tE)Q^TQ=KYWTV4gYOR|E+Y?r<(3zy7~$Vl3P^I;{S618*SXm zD|=LO`B|QRW3|Z$?mt_<6R}G4l(VP}XKe~o)mrSz1{H;K_-dq){pWy*x4;1IWuG)H zgW)_hdMH1@R1HdwO)}K?zgJecEk>AOmd}+1)YtXVB1D1C&nqBIsYxLUqGV*;glQ5m zasa^vw>%KSz^maS*0&6YvJYQc4IoZIT0#P3@+#SbAe{lOa0w)2!LqRRc%oiD8jrWZy(?r@Gjq;;WvmM;JN16F&3)+LsI zoQ;xbCOV=zJ}N1J_&k_TL*QKR2BP}Bsgf*D`rJVthbBCMknRJLe`3O9^a$uLd2Znuev|cev-!1$a7_{%DRqp1ZCVkh*)wA=L6sS<2&S zwv;0VY9RJ%-pA}&_P@KU(EF=@36i&k`s+vZuSC^N%4>)v^J3nM#US`^vy$@ODqhsL zM68UY*$?=~0eN;9w{B59n*~*=d@iTFR%~(xjMo7a3bPV6HC=%bBH|$rk2;f>hOF#1 zc&6dw;hDX%v9STc^$Q+gxqbwjFdyRzr$4j*0QO=ApqbCF17rnrU*h-H(@K`FON2o_ z!ifx8{C#xflrk9?ACF{SZf|cNEUqY)Mda3)_&j;g6-dZ7<-9~3@k~BmjA9Ii0Q+mh z_VJRUtYOf+7qwH#GcaM+e&*mitW02EsAX!#c@_;2H_|1%4nL1Ca zoWcAZWQy~J!sIlJj8+yFAXfkq-#%DcfY}JIfGJpe(dAbINQv^Jx!D+Q0;q+6q6flN z7&61}U{7=jy!h&x8v7SW%_=;LL_w$s(B>0^1M1Q)9W5$^gCKZA!-eUKgEC;mx`8z+u^>~5OZ7T9v0EC{ zWM*c9>W(%XY2XKZ6Y$yKj&$7;bOd!S22-`Hyu8qbBuwij2M0lJ2SZih>`Kz()g4Z` zLOKu9ng?s>^_PGE3^ay*c1?IUWo63$i?z3asUKCfKj1z>{UMQA7>*JJtG6JpROfVdO5qeWXYgkCfVBz z)e#?ZqZ=FUj(R>@w3LVV)^Vcw`G#$uKP2h|9L~v$CTs8-vanyKT#0n)1nSn zg*%w4R2)55`*r9%H=dWTaB%q?kdEfuc-SMTy}e2JCi?qp3JX`aOY9013U{i`2&dk+ zPE>;w=YOSD)AQfF6uz~N{k=%~KD~6H)YrBw8CecJ#Z*?r?~0%AZcq99-oa?nbD?zm zesA~Tom(hP78$-ndW<#q9w5vPw!eAzBkMJ2WvKohc=N(XOiAylqtf)E()7og(q6Yp zRHCbf?Y$G}{@4JVou0EL5I>Q%pId66^nO?C9?b70R^$!QFju z4k8PrZswQmF5rM|z)7eB=o?HRkSPq4m9jjU;By1q0s=o!loi8D;&M~id~G0v6*E*@acg3~YZU3f_(9G79Y1Na0o+~AW!MhzPJ zP~M$!7WfhSRg_@UfoP`qn3!q+ASIK9fx{FzPrHm+0B-ei^EISna$GeB)w|D<$R0`@ zt=(Rco*SRUm8VGLIGgCBNP{!BSaf;8)kSZ2Xpis)bH$IYp|Sd!U#g^Vw(QJc-SEoL zvemj2FA?Nry|9QEeBInEVbdooj&kc~D^}BluLQT*yb$omLJc|6#r=o&1F8bwNGo9= zd~iD=Se;oBh)?zo<(s3n3Ik;P7fU68!5Hvh1YEDNI}&!#F_E>-2~Kt@ixaoH^WvZ> z+r#5pliD>Wqmf6Gu3M=Q?GdWJZ_M=GNIu1si}{Ode4YL|d4??dUcer*7F|^tnIz}L z;>2G;*xH=d_zwXaQSNkN(;SOI#Q8>15e)&mwmn42Sk+~uKQaJDZ%R3moN2)?a<9Y>4F-!Fo zTJfZ4&m*2_H82|cl*>LNYtT$0n{sf9?PWApRlS7xZ8w1@$Cnz-#LG>iAv$1&;Iy5| zG{N02Su4BrZK_M>bi^^0_J^Pw0IG=l8#-iS(^Ux@f-x@>SE^PL>hp z#i+Y#6YBmNo8HfVa{*{0M`Xyz$-85DsZm1GC084K;M~Ny> zBv-j&c46Tv0+c9P^*`<5D2NxBHuHo;CU|@x8V0WcTr{twjkgNq<&?e^^& z@KM&#m_^csXR()izbCwtLGYBx#7Z0RT4koq1Sij)TChF;ZWs{#{MbgdsZJAE`0>U^ z*AS}8dos)UPbLLnE3Vonx#H?lTRY_B$%2?)#5;SxZ095xT#6Cjtg=7U&%y=vIOEeVm~-FI z*sdQ_KcQ|zi`2)PhOqv*iWQ~b&-$-6 z*(dHGwZx634{2nfuUb)RwB%GmLqj1#ib62Fo{3}_j&LrE`4*%S+Htw5v9Xu+HUc(d zBhy7_H{!Uhp%su$^Scu)7T)TH9v3d@RA6KgXf1!2knkn~z%=lZ`Q=A;vqz5=DvC)5 zw8>|3sb@@2zH*csp&>$=G#Ut8FXFJNmS`@$>ohw5q+a892j$xxc`98hM})|$m%}_l ziZMjLL_TqnKQq5ZX0755Q@y*i7mP#s64juBO^DX z-`LjD@{%hsi(j6+9cZC|W1H`zh*5-049Ri{au5!qoI`a=fG{2m1U7}43`cD&JPCf2 zH3A>SRW!AXuw<+v@LNs*F z&c4N{FqNo?2m%2X^?lF!t$;?Dp58+;sOl07jBI>-d<5A2_P$7e5)hKjjDxVNV!VxU zX7&^wu^R{piJ4B4-WnwiZbk#4!!7Bh%{1LC83W@^tD2YY1#K)W|-n(n0=5cL+1j@(jHE$W*dejUx`cs^SKxnTM+TSZ>5jk z$0_HQTk{+(L(^}#syR2S+ZmC~^HSD&vhR5jSb@Bh43Ww>Uip~^IEO8ZC1JFX)+Io? z+@EVB7N|KUWumuO--#Xly`+N((@4Gb1 zK)W_%6dKn5A`5&3RusT4d|+QW<})!cAQTkJgMJs#ZU{qT=inez)k3Z-(5c~!7U8=! zd_BlbeLUM2g1EDudr?*&n6-UPa{M+E`!ngGM+BJo4%@p|Bb5UKL-Dw?e_f^dhn~&s zTig-J^*mLIhifbtLHClGgokF-M8utBEg{#L|S&l|nQjt7OK*#P&7i+A?uC4lBU`}_B){Zu^pd<9V# zHHJRFdUc6N?6aj%LW>Hrx&#ph5u%?(I zHi3fqUv5yMv)fY*xT{~uhZcR07vtuyC`BJk$D#jsmNfT05wa-6JBSDvcZMi+NCj2M zvy_p+zOfM`=^%*s3HC1nj{DiFbFUd{1{(RyslEFQE5M};`0%$Kj}H%xM)DIMTduAy z6oOn-lQSKBa)9cAhdr?WFimQ-)UBj<($o6Jm5+;yI>r~O+-5&rKYvEG_m`EG1(r4q zE$tOokZt%9u(eUZqwF7CHoslx%t+X1W#Z0V^X4kTM$Y#rc^AvQ1oo&AYsJ~MAGxQjr$0KH2qwQb4MSs-UqWM+1z4PMi*ws)uu_WOYI>S2Im9{&f|ZgRpcgVz6l zq-?{pVSJKsO;cTIIaNrS-bKHCligXuOJM>Hp>w&mH<|c4`mVLDB{g-9=MhV3TCTH# znd_7Lj~>x_*ElOt%75V>O9vUsCD%?J!dUNhgY5E)Q19+JN$k{Ky@s6Q?yGxL#>JfE zY(?iU0URA!%f{EI5hSw08UTH~!C z=nG!FcriH>>z|pM3m{^dZgX<+`Bs1nt+F1)ZeOTkKLTmS4UuPzk(0I6HOi&EHxAZE z1q-uo02guFb$xe&1k#bw#Mb$9@YMY58@&d+1c<%ZxMNW`(( z=Cf|MGjo09)4gI^KZTo$c5Yd0z2>v1quf71hQ-DV2a^CD`Dd9JxvX&mu@`>T3<9;8 zmF!$>Y~IYoaAzwbQitSA)xr@aKELckDN0!uk!FUToZ3 zx1!RZIQ8~^{kn}th^znz+ihANRhVTtZEbeBj5nxmR+3UIT5!22ophAO3P5i7!*m_W zwe_{5k&tB|>Vvn}uVR~hc0>;u?poO1kb#~7WkqkLANEzy(~t#%ICmrW_TE)vCW9J1 zCPWPI8+(0?r9*OGXGe$Ciwqecx6w@Ybs`xr zsHE?okUYlr2Cp+}Y_wmG=+Hmc&spow^|c05AtwX#=5lgFK;J`v2YM5#)7E-)wWZnV(tFHdwu9ry8=DuFQ*yzfOXzms|xktXuHHdGJFwx&1%)7W{ z2M%s9v&6V{Yu)^mkZ<b!AJ{7atv8%r1?i;*RO}4DVH*{Oa5`+X5}zZOFgSxpLxu7=#3~x1bRap}V458h z9S!EEW9_pKhl8z#V%5M|GCduVdPbJN0xy3%zTjSNzWIs5x2~(35anpmp2WKwZG5g7g?ayp6@s3#%OqEVcEZVE1Oj&Zys=Fz5yB% z4eumMe;+ioT}xX_%-{C6u4%57@?CwNsuJHkF>wo_`0)7cq)Nlm4(!(aIs;~tP0&e7 zsln*LSi%-$D6})6-LJV{U^A^zGp`>t)G}`Y_cChH1fgZ&(5q1)BMd+koiGM*HmBd8 zn&XXHeN`Qxbcy1;d>P}t{v@}UaOlHflByU)^d9U*#80{-Sx^zPb8}#O=4(@KzeDL2 z4Vi|4xL^$*lw9p{aXvI8mz)fD_8E!Zi@hlr(s5jKOO(q(PIO3aam*Bg0427}z%Z35algM^BxKZ zP~2{ybNnh)b69!E9-Y&xS86huInHl9#u`+33Nw8oZYvQ}Y_1X+nyap0Rrd6tM>Bv= z_}K}m^Wg(0mk*;J9v-&|*_UkumoI5GX;+tw_%>dN^60E9W-r-K5icY$Ww7N@GK>^; zo;|RX%IFwsY8yUL?W^wHUZ0g~%^9awiHT48HraU<(G^oC1?qES`9~okOj5-%>Q ziH0ZH9{c9d>^J?|w|QlPapNv95^ZfS`H6|u#c`Hh^lW9vT%O#o{x}P4Zv?a#_oAKh z5@Zxa?jTZh!50KipAZ@XlZNR_u0Nj$OmoBY}5A2|ZU-$Ul>=llsI zkTOsau5$zN)2O<-KaM52JY@#~YUKaMo<`>e6S-`qG7~rgpnQUH^eK!y!O{nue$mj- zz$;ojhVwyq`^)QZ@0TA`dw_#1G`c|dovL+m1fDfyzz*~z3fl9kLcZ+#_ou)FzVxlH zPl|AC0IX^0k`swhQ9r{OWP3UQXqV;ju0BM^z~96>AA7ADS-#<`d0R??hZmmuIvk83 ze!_$j+$SSB&B^qa%s63LY`~c{V6X=O4#aKq{P8HHiWChC3Mzyr6-pJvJwAuxjofZT zKKS7xiRbC*@x1n6xUJKd@)EE-EojQqL7akk2R|%y;gb$+Fbo#J!ySz&&v2i5pS_nH zy6C%c=D(W!$ddrm24prPgkFd{Y-FMb;rxsVIkczW>!~Qk?#<0>sVT}~TFxjMs|XHD z6zF~LxG))qaYWr{jmty?=F}8Ps4QCRFrSKcghKq&W~~6H=}<`G_e->tEIegD=SlGp zuJ!8O)pcFJHKm&Yl9SAoIl(yJALu*cSKZLYk^LNSF5XYzout#*^yrDGAH=Szh8vADj zLvW!0kQ2-%;2Ct9^`i2CUh3%*Rh-?Awzk!|009$Y>Sk;d+}8bK=j+%Dy?W7rc=O?cTPTWp8vmuMfDs*4)zE?O`IIqo;WUe74z}hvoWA1PXr% z1jWL^fl*!@^nWKM11};#w@9$e2LH+YLC#C`WJHD19V2)(hlwPn9v&^&L!^;{AFbyhJ5 zN;NgzgqqYC?hRlb9_cVagz+j^`n-bNL7AY+Aqe4t#BTsCU?)!W@p0DskUf-23_v@y z4iEXjJ@PHBoSvQ@SddLoeE+uIga zRs$p!(8u!F(g!qWWo8-}8$T5I{GQ(hwEGu_%>u|ADBSjz05tH7WzzJ3Y8c*9hz!%G z5vF5+{=w=;Zi2uH*gLiba*8jv)$?mG{9!(4wkwm-)_r;VK)q^=+0SNKV@X6!PW`s= z!xREV)|n}zOT!}U=FN|LAKv*L9sQh_p6c7Q<3Nj9O|qcdX-;;BJ{zd??3Hu#%9_2? zNe;|xb22iDN(w+<+N8yZNGPZPO?qWH=cik@S$W-a{AA4%;-4kHc@#nb!m06}_oqQu z|2zOcF}ge^TusYR699XsR~Ody?+y>E^~!5RG=YW2y_{pk2*L8)P*$)n(y# zn3sONI5;!}cS4O+;-KzF?veXLcDb;6k82|egc+vY8U@>E-fL@womZ+8pEkKQlvv+3 zC>b;Mv^4waH2xK-H}t)Q<)gG<;Vwi&expJC!P6``dbV4~mt6sJagCP%$Hbzq&v%fP zRm#D`=cvC;uRTzAYYAGoeCg@)=M!(%jBq1-O`8GD(P=NCXF9XL=#}TR@3Qa0qWSbbZii11 z(u2`{AE=aSokuK-xRJ9F{Hk?p4sBaRoozxrj=tAs0hWw|%#@qMeD5mn$p`2e^4(z6 z2gCXY^&YH`Wnti#se^-=5hZJ1wmH>aBQK@Q4UyFH5_gWjR#n)R2=#Hdeo+^!G$SZI zGelY-8eMh~+ttIZt-FinZcHF|VafG+fTP8;<@<-}G}W40KF_lkb!6owz^~9KA#*X{ zwj2m_tE}jWpM#mJ_=Sr5MveP<^esQ(WXEs$W`TNYX;K*w_XYNYAwU{HHB`%+a zA3&A`E(tjCcP|mv?yP;%&yt}D6wxv@ogVrX2@WCQ?SpySw|m5QhP=F%TdGvl9+sbU zr0~4vi7&W#mlPaaUpM9C*Quk#;i;VaCW}oxY?3ccE^CcY%isSpk2FW&9m$Q@)4bX< z5IW?jop@*Y6j@7acnXp2yD-K7TqgOc8JpX!7@$&XI=-*lhWfPho?amRaalpsDxsV3Isz1BS zl}#YUV0^6o`}f4xvmKDjrD!(~8S$4dU%o}aqdulgKMd+eZ+`&gafz-XYK9IYmenNU z@q8X~RUDB0@KEfjzw#mBcG+`FP9hF@%ihg}Y12EsDO;Ljk#_`b)6_hoUi$2D&+*LG^b%Iig0fBA@#iQAg_bV=ljVs>9_NXJ*Ry#u^&VD7P8$oM zvf+6;+?>}*qV}E)cU|HB+$!c{tzS?Uz+u6&toH%M4Q=P9&rNTK(F>!M@H}npX=)nk zaFJE7mcc=;N8cF8cqweMXV@SA@oOYT`;#}j`wqnE8>`fii4+17GC{#>3rl!vRe{8O z%t?k+!689y>Xsz-dQsnVJ9^eu(i=s^X~SD@?%af5SJ5xi(t7x(F=eWcSf;Agf?pjC zjE4vtHM|sPE8-%9BVxF9b%>GxFd|8;PV)Awt~oBLl$3W8ocqOdG?OSuM4V%fry}{5 zE;|#SgyhFUsrrk2lg7i6!qY(%SAUOx1wto8JE#u=V*~9V=dfKC6IeunmKq0q%TfHQ8_Zfht4rw5WpgoVI^2@N7ES;W=V zXWVb%dFny$2L;HrNu_PD7EmmcM}MNW^F3Y3Yq?WCZin=z<|1QG82|M4<|;uiLT!^R zFUMr-%CA?f2^BaBCe9J%5evkxRh!`H$ou5#{1#`b^SZ z1kt#HFP7PG(v`$Ktt0Bkg0YZ3Yp|o%*K2|Y8-#i`p~i4qBc1Hw5Ul)Co|2*k0XRBM z$^{x%vF-vdX=A)Bz!OSd%>eu<-hrN;-(YbLmHE-h2^g;>3xRi<036XJ+)K!CB(Vof zmVR`6RaPrfloUfM!?2N9KiC4_5*B7r7X}bhf%wrWEzf-kByxqk6t%XSaYw^%U*O$k z(FOb4GI%W%>@YKZB~(#S0Ul*6EG?j&f(Rwg<YJ4Sf82>bK~{QQ`8 z_zL7+8&5t-OO#9-H1HBpoPO6h+D`My-uCAD`Z{Hke`yjAV)yF5trD-Ge;zQ9%Jdvg zb-D6L_J9fn!58I>Z$L>mG+lg8pJ_zMPDVl9SzSU+G{*TT%KZk@Tt~$%7nf<9p;%lm zAwqVee8!*wFHg@N_l#rMJ|a*VYIdc7CTDKG1!W{e_suUYrMrv(6#|A~5G8TEw|4dN zr9UaSF5e(3>*L4EWZwS%ixw44b7e2zy-NrP@Or2Rg+$K&-tXV{*p2S(D5Oe-f|MS> zc^MgAAY;S>U*gjZELz^-FAoH1Bz9Nvu$f zaI?})UqjTC!SnEaQ}LLvFdML9)d5couS zHLDL}=!`t*ACC4K5bp%l6W!{cp(;%}t)`f>$D%(mwPY$gY6wlMyyllROj>tn@Gwfz zi;_Iu-ZpdAi<3V|gJnd8@6VBdSiTiZW+zlkC6;Jpu3LkiF4G&w>Tbe}=!tIV_KapT z9f~>%nOrMh5!U z)@%XIju&SK&_){B<#wfHeS1IGbUU~)wF-ljG#>TvOt%|L>iS{~D%*V;;SRO6Q zatEz;vY=fw(GCToCZwseZEP%d*OycuWXnB@qF1YFEt#WtG9tmO;N(F)Jb9J6cUK<; z^XlQr-9Y&ZXLfv|1fKGUaU=C-9U^Ud1hef=C-pV|eITK!+3qjaRZu9uiD{R@y*aol zo_(ol=1l^JuGAF_L(fac1ZQGCe*GFkjXjy)K0I|la#!KI`Isq{pQk9f3qY7IWYWm` zDEQz8kP7hg@>UPh!|B{~^vI`idwZK%@7C?xDqybyT%Kr%=L!sbD%9KC+a4CFlrVJ0 z|LCzZ?-d(+Szt} z5cCp##;e;jpuZJt$IFvic(i`xTi-0M=)9V_IuEy1c{vcXmz-QYU4_?|*|Y{d1ZyC$IDsjAVVUWITQ4l4Xd{W2 zFPXZ~<+Byc4}Ytx3?0G&G+O%AItwZGhLkLP*8R0%Q#gxq_GKSo1H#gwiS!~-1vXn9 zGmEr(1=kfHA#e1MFnFT%+;(9pcS zy!5Fl!bB)A_;$aTp8!7(0-L7)T+w4khg0p9XZ+J~o?v^s<)a5hy3a_3VaE=#N5R|QAPhOCfmH3PFZ~A?oBH&XC$w(>cGgIJtJ>0wPC&b#NH+OWSK$8~3OVHz1--U&1 z9*56!MY_S4DaLjF;>1J=XC^qMi9N-&zC4}Ry>5eXH}aD~VceBgmtX!ew@0t!k>G8* zt^FjU<=@3jAx(wGfdE+Z*<H_!%>^Blotke_@I{hZHV6Xr?QT7@k zDd`2UNSOn`rS5FPvKvzpEc;tOMciE_UN`UXQSg$PkLPJ8W1988FJyU|$xyjCu`I z5>6X=i420wR@6!^|B0wAia8WS_@Al(G#rK+yD-ET+C;Xkt?hjQAQmttHO$h$iMry zwuVo%Oh2pJu+pc&?$emk2;0A?;>2K)8+|?P{S7ai^ra4?9Rs_^l@WOO_^ksUkzfcM z2xDAh@YF%_{1YK0p&w$h;^U_;&NnaaN~)?(K}TiGx*)y>JZ~7o0U;V5Wve~qF-kHr zGDy0~8bn%PzZE8emYRTIY;foD@?&rYxvfa^Nw$*)2lMJJ#u`IcH@9y^@jRFh(_U^5s1@0IVYkjM27^a?_|@fW8{u&ewMcujQ^9bKoC2to!<&JL!l9Jr0^vk8&G zQU0U?{@M+Q*!5?}gVl~MGWUUDRdqo8kc>T=?OS(Ms;V?q0C`d&c+nA8ndnSH24kVc zcI|itPhCPoX}=~rfjmt#)TpJzZ*8sLa@imM%FxF%b|gatU*^pbWk)@UoRUvssE=#- z`}q((PE6x>S-vw$yG40Mi22kwRXYx$m-LYQXPz^`;nbUK>ABod;VTyq!%gkMHL?hb z9awf@^eRxUC~_|Xnh$D`reClb1$cViC(DsLftA>*#r)yPZ{(x4g;M0W03-}jms9gd z(S7(|fJ==}P1Ta;Ce0yheK0mcb4TV|UDREi2)LbbGN@E)6$X_8k61dh@@6NjUVz%9 zGD1ATLx}{K8*){UPZ4I5?jI0vu)V$Ns(*P`oWM_~IlwVp^d65^%QwZq!|l+Hq*-P% z2dysF$*KCLS;}TP*}<3^vxgjc_c`*O4D%ZZ*=@wWcPpv*)!SVkYoEXQ4IG=A50CAR zj>Z;A?F2A5Uc7j!nan%$Ch656(LKf|G{&5c{Bt^TxnfPIjHY(^lbz2D4WgSr6(3?0 zb^j}o0!mr*}#yj^aDkp~RP*c6RnP$<#ib#~^26)X)C zt5%eWNtLAz-!)IR25y+*L;1c9+?1X^Wb72{Xst#g8YRV4kuFBj+Akn~&2Mhzx%k4v z0)O6x-5nPX&jl#app0-pRep-Py}Juxi7T*$fp5JNjOMv4&#?#N|8i}cYJl%q$x8v6 zIHW%}jD0+p17DELY}w`5fY$)D@m{USd^60uC<-PU>!|)z@#3Ofg4DWJevqvkk1Npn zzWI4T7q||!RXA~Ms;Wx|_f2+cw>3mSXMRzzAx$XGpw;pO!&W{}A;>n55ndrQ(z6uz zRwwKGMWGdP)j~8_Zp{m8zquy=a;qbc9Pvv%L0gG7RJp8!Y{fYt4g&J~yMcV`l%?XX z|C+)f5DvE}G5+a?MDY#MXxRE@r@+TWzj!RJJR%~4hnUAk=6J#I?Wip{tUnBLAOk!D zPD-Ea7zMJtygD8CLSGF^b(MjI6)YiuYM;7YTOoj07r^U`bwpK2V&dXRh>3q~Zq|YG zQm~t@u5Nf(*i@A*BqBjuO-R%Op?C03|MWX72i6?QIOtQOqNCx}xi`HcElaGtVTM~e$NdPbcIxY)bI4Cc@y#0ms0jLZkR={}rc*3l zhTX0M6G(YAnq(QD4a{Hk#13skr9s*Cqy1cQM23dwC>5%wT10 ztzK(oc84*2(c*U2B04cqwhVh!Ip>-pv{7{eb+a<7M}-!N%89?X*d-r7Q;WWaLCUT* z*rqM-To``e6Az(i)ZMI=z<$1UyOid#qfI*N!fgZ+CalNsCW*7jun)9U>z z-xWGLhiKcbAzU}oGKI4p*O&U+#%oDlWet`N{NU{|C^^O`R?HDj?&W(2jiy!O1abzB zK^EwF25cOkMvWB`MjZYNg=6RW<%uT6*19X(YUajS}We?$GVN#S@+_By$Pe8K)UD%p zvc>js5B+EN)GU$ls4P!tQT^8ZV^)=tmnOSB;p=C)7>}@Qm8zj1<#{QK87q`$>TA8{|x1(xdX=2GPBSi7T#O%7^o-^@?ho zY_r9h%Z>1men=}7vXk~?bT50=dM%D9*%gA+zN@lhFE_1#)RPNEPP7`G;B{daaZJB=nJIqlOyDP zR4z6xcFG1fZ}OAW=qbPj!z`Ao6jEua@t>8 zoP4`RJ{Nb`(6ePSl5Zpc3rj*dANk$>e7R2D(EjSkL`wVPLRmIS_eOo=pFkfPa8MsCag3+U{W|U_8ZNouYz! z!dRETV@t_zd;Ej^Ch10T|0Tqeyymi{BfEnx3s*!B`A-HI1OEA1UVuFsN*RD~lz!fS zH>+MD+Lh8Ms&26Sc7zre69*65dhum6w#7~VKT%)H`1wIl)E$RSRjRYVEx{nW>jhvz zkL^l$qK*x%P7)ME?#u&x*QL!BHH&YneqNo2Zw_Bpk3RjghL#d;SqI>f;K(Vz8HmJ5 z?~dc2zM=c)Rm-f8IFNZ=$S)`Cwl@dLa4d7z&4RQGzFFRf|E$B|M0gJ@5}YQPn2ngm z1~h#3KmCNO>eau)4tV`Hr$>U5Zh%!;L{`HhwWJXT{0$PrDF4;u!=KM7cxilFi6fKx zMQy3zR{m>oWo?tf0v zcbI4HjZNBL195HnCpBi*J7WB&A~{*fQUse(k*{4A{m&0RCM-Z_67)F2y|J=*WW2d5 z8Rd14IxZF-+^!zOE+df++dEbbKau|mq4J+A;uG-i7| z@AwJ#WB%)PH~#ZFc0l3?^v$W36B-+I#bY0m)glQ0ObDNt{^x=wBrm5oidkHh_D~GR zbv&`tBvgt1*7bMA7=Qfdx+m2C8x)Id%-g@rkBeKT_JWpDu4Ms!O0W?@E~138zp;48 z57%QK!OO|R@od_vM(fr$Hi%hpVeTFLSXGBty845$zUG92iuCaL(*)W7ERRZZ%3FV{ z2Wx$%eL52ySkSO1#6Bnx&U%=X-=a?7*JM-l_oLwcPaj2XMp9jL#H)DZS!H8Gy0lb| zv$loF6eStEkMSB12jH0^J$)}KW1Nv;VR&(0=iLI+pT+ZJJOu#BP^a{-DavFSRAln< zO9_ZN&);803X&@w43HnHCmKJcvC_#W5SLIB7pq;{BFjhorn{_A!`!&-w{_$z5S1i< zx~td!TW}MRuW0`q2h-khZ_bQ!F(BJiuiA-9fY-`1Ir*D67Kl=AW*K;V{%lr}6LTF= zNu{FLv99D>JfZ-j&7xH}xW2|d{|f~>Jp8jnabNy#AJNspN{0rzL0H`su$a>`EnZjf=>oY!RxnI6~3B)#P2tF_|LB{|V-FrKj zT*Sx6N2=OxOmIP}-piL>Zys5hnf>0|BLSa~te+JZJYO)F7#LC{WAa2`npHu{Hm5Gx`3&P_9EIQFqoDKbKHZov#;l!=%8R$EVTT*Z1^rE`X2k zVg%$%jXuCDWMdio`Q0tjt}rEKzG}0UJP9TlghX?*vxV=Ew(GzMP&#gY&A14bG84iMq_sf@ey1EA- zoMuqS|5ok;XcLH@?Pq>mJ!=A{V?3m&fan@2vlhhQ)9P6OnCbx;gdB%Gn+3;^)U-75 zINrB-cf&(MjK+$+JYfI}Of#LB2CQDFaK40xm%Tr-prqI+m%o8w3JuC?pTw z?aiM_?C2InUPD*aYsj7X$>Tl-wIap`Ix$?~Gn<>^U6uWSr&Y@41HI|%R}orVT^$|Z zAwx7@4$8G#JpvJ(Isy1BP4k?9kX@q#L`1YBqgRgx_;;^gr%J6rLf0e&H^bc^AR@YV z?MeZchWQm(Gu;#oLLm_m<;hq6)j(~sw%#Q8ASG8=);k7^lj+an3?~bUsITL0@ zWn~w1wh#x&9Jvm|93Zh9=;_&kY8LJ%05>b_oejQl^VXqt9xc?)AsKy|cNekCOoYC) zvfz_9-9kW2JUH|z!t>+hQ(Qjos>(`slQz4@UCvYt7e52F_@_x8Fg+>Lrliv;QrN#- zTNYqTn(>Y3Poa_0Yao%H(j*o?Hu$$7Sr-0NLdk_QA#^aEk5Uz8$T*INP+HvVU>$&eqm;XU%KRc(m~0qemhK+Av9m zi4=(If!??eyhLE?KfCz(2eEuL5eC;t^rFC7g!eB2aitd9O9(Q`?jm{`G(6fQ3fG;HDF{*#u$Uv^Sm!9dOk%eGX40+9ttDuKs zsQ&#?Dg&9yCMqflbO~!LmVFQaG&eQDRy1;|PP|2P=}8_oE;lD_aVROZj%X}vS6Y7o zA25!fC?0g|;Q1tXpugef&~WnAB&oYr4$d3QR&5JPMjhPYC}q`&6Vmzem2$lEzT3&t#lhsMum9e?nA+Cbz5E&Nt&?Dq7D$K=pG zH~Qe_+UgP6p+Fx@MZzCwMX_S_$^g2(y!ScC;^Q?v{iD^@3fKvrm=2vxy|5+7;YH9zJyUELYwDlF8!FpYJBt5A|NLV1)IP)C;J znV;Cr*YCh}R;n@eV3|IJuvTyxz4GGZ>jp|*sK3eN-(t80J9$V=wFB=ul=?mSCnabO z&4xGvTLq-d-q2AS^xxl2=hlrNPdeJwFb;lbZ{BDmBOyTj*o?K zC~!gzh^;!TlFLHk5=-MOH(lz+3tj^uCr!TS)!t;+BtTZtQ6WU=%`UDO;q9%Rj}L8{ z>DyhfUCJya%=O)ig6F>7cUc?=&zs`4+I41JpIs+{oW9lBhWGeCHwod7gLUzv4=yD{ zqXnQL7q;OPt=mE)T7`1P8!eUgw%RzS9YrXw`*l8|US;t~kG$HCTiC2yg*@VV9MvDf z5eH!n(VcT!5zV|Ld4whd-?0bRJN)S+vj&?3NU6xc{ymMm*#A{3kSm*1fIbxA(7p(} z3}E{(ac(nPv4j}j*1*ZhN$Y7&VOZdDa=4gGYz5%Th4fv4{R6HFDv8~<*(@WJ$p#>* zYpCP-2}?uZmJrONbkO_xlq4n97xUR69G7t;=`A`aj{aP@Auy~&1h252|ys>ueS6sUtctGvPk8vv#v?|1MfbT z-0s-3grXwN-4>WhVy5@glkf(0EREM~ZPO5{6x`_Gxq|C6XgvME3U%_rN#@|ga2xO0 zLSty?vsC1ywL~#TD)EqRg%vY$qNZH;MgSTXuqUqecZ93(kt0{1F>>{F7^V&yMAF41 zW+8DV*W88$3u0p8$koS2>{Y3I2~J;RXg~t5O~S#(hJ%ZH4saJ#nh%nYME;-eyo9!U z1TpY2n9Y8EJ3Bdu76??#IiK}mpn3dQ%%cJ7w#iECEb}~MI2kbej_E{QC!rW#Ue-(d zvw6^?wMq62@>|KipaDIPLL$}2H{D;j-zFrqWSao`-!b1*an`U zW?~!CFVYlw%Qngy1PUd~U}CoWXIb0Iq1qXE92IGLSiGOCatIT$>mH>9((Cu{A6mVt z9O#`0&PlJr9ZPZfta32Fx(cc2*=r+lJI;F$m+G_^`V?YkoHimf!4;h?5xUK zWlttp>1J7x4>UU-QURa>D!s;tby1)rftGrQuJN zepm9PeroF%1&T0m0=wH=Uf@x7BI-%3X)i*=to7wQ9OZq@TiQN@3~@5!6YJue(VdHC zoOEaOD8dIWq=QaBOA2E)N*vzlJg&k!X!vHgNX`=MPXROTernxW-H7p+^8BFdC0v1elLx^5#2cj zI2V*qThhS7gjqS;Xk+;nfQU_gfHz-2+3Y*rQoBE92%*Lt{8K+$rXe?i`kV+HzJ$6u zxOLLPtRbr$^e9PdnTUO~je1Kf>BGfR#qq%c@Z^Z5Y9?p?_MbYB$IYdDK|ZQ6w}PJ*h9 zGGH{WFGT8&_KYh&S10@`(p;DCF5T^V4jYEvU@2B17QPrR$>|Ak=ca9xj;~x^RO{wX z`%Akte})SkUe_^zYpki#ydQG_*j*flDanoW3y1;#bRWleXg#1& z>CohlCk5+7Vq!jUSPb@J4rngYng`hi69&=%FSy%ANF@8U0&utp@pOC9?IA?6vGF-g zD6@N&Qj+5kX9HVNsX~T~BJ&5JeO+Xv#|4Q{Ac62gZJ&qz*Ia9xMq7o{b#-4fA0G?r zROB;}bo<-)q{wjh$5(>yM#)&8Qc-~s9-^avI;{NjwI&#F{Ai=85N$FNH~NAlKcvr2 zEIIMmaK}3gKDhltCI>EO;xUXHpYX_fvyqY-l`9$aI^`ZqIm=IGh14(yY_4^!L}Xmr zW&hXpAm*WA;ws7U@tT}e(ey)eE(L*!nT3T038-#QVcCF@BNVog%-Y_uu_}(jQ-#_$ zrA@}<<}Hq(LT%z@S8!-wUvY$H>Gbpz5SkK3*U)RX3Fg<=mDSYZp{MF&y6g4nEh{gC z8aO&SB1x*^0ED-_bmrnc6%-kjBndAGe3MkKL)F@(n^TUnwmNjv*``Zdqkb}d;K6C?c!l7 z&dB@?1oyM<+rD$UqI|Iy9<5$*;Fy?%T1V|UVm}KEWy9DE%bY%fT98?H9yzI#%fdFC zI;yBG9BNZ*K4@cIJ9sqZ^vpE|2Ksj6XmHqjbFt7VrjB*d>x_(#=P8zWdqPVJ+#1vR zK;u~;#)2k;S}S&27YfvOHW!0*Y^4wA!+G@kD9GS-{iy@j_<;rp7Kb(P_!;b-ok)<{ z{k%k$nU{9~zGvEn?I5hJyEu0w+uqx|ee-0lzvB|hwL3@}k(dr0SoA@Fy={cO1pm=I zgfx0B!!z2|-d=R*eJLUD5j{1BUdA0cwk#PTY{qZ9s@KrbFH;hVi*qH*oJ;OJ`8SN- z&?JdSU~m>zKJ_hsyQA}WXnu$DI&xILa(NDpiy(iBSn-#grI{iDosHcQG-hV^lfe3U zGK8Opwv79Mj?9n&KmW+P|H0myM`OMJeWTj#qR~)MgpHyS%9yz*GD~F)Wyn0wvqEW* zB7{(xGS5SViU=9XJd4az=9%-l^y~NB_kHg3JZC-Yob%VY)?Rz<+PZRmeTMh@HNB(x zfjiD!!U9XjZ<1UJtJ_~~#>eE@z4v#^4;@69z{&EN{&@L^MnjxBy8c2lBw3%Qum?DT zq|F3jqTlulJltcd6SI!*5j4pt08gA`*)D_-8YJrB3MGVt_O@wztn*-!jxQzqjW2pl z7w3Rlanc;77j<{VrroKDo--+}xJs`Z9s1*qs}Q{ySg+vk=;>9z$oMUc$7GI_%fIF} z-cCMG3E7GteCCo+x!7taHjQ?zKA|8Fuc7W8>QYKe2&^E#qUK|{`b3j2Yw~i;KD1QO zU4~p6?KI5M*5bYndD9!Zvh|9P5*k(a56y?Q$DQwKHYuuGCZ$DE;p_!JEJgm z)Qm+h_r*Whfl*l#-d`V{YABx!GAfo+Zq<<>Dmq#v2h=j#{J#?Pxe#?D_3#(+7o}sr z6C$>PRJ6W%C^H96bQQMfRp1H1Qk+!hf&un{g};E@ogJ~be!U5qtiFb@oH9$kKvV@f z9{>@+Fm!ZvQz9QoVe=3|9qT+c8d_VIzH-%_)-D!eW=1;S!9#~41nf=`+MlCGkIp$< zMBD*7O2TyvY7{ziR7N}wlQ-z;Ve2Pg0Vv56pfdHvJKMR4aUm_x78MgzzKDHOMxXTg zGotj)7NJ}!zu1U18sl?4$IaEX+jX`Ecw_s*j%}XZ7oxw7FIi1J-=&M-8dX*b?cxBn zYd0h=`hN|L>u_mR?fluAm>8BtmHKn=5dIl%m-Hj4D+=TY>=SsBe%1@HHtzVJ?W`aBu@8P1&r1MjDtdZazHXFF zPNv%2E^l+)mr)Ckt>ojwgolArQWTLq8yg;`DzGhyF8@3bSOgBf;{e4U)_#H@CBOXg z#f!J!3{_7zL!l&#F#`wC;v;tgk`eRSr!Rc99DnS9^a4Bi;l(%bbAIY=tFNyILQ>z* zaJ9s$@$D6yy-t_|H8wT|BR|yIEBMjmqI>m{0KkXX#Ujk(B%B%W3OG9#eB9=-+R^hO zv6nIo#Xlh1&|@@U**e=uk1B{nys*BfR69&vfeZpR_kiUmkLV)$`}>1zR?Q)~j$@83 z46lCv;H=fv@=e{Yc;1&kV5zn`{B=xs`hA7pX|V5NGgUmYAF>QS7Lais*)eIi5sp$0fU zYqaapXXlaF5O^uFvCtns-Ww__fp0Grs$Uw&ezZ4csObC+I$4>SZ^P9E6&WNh2u71# zHoSBG3WQE1B z*=5(jK<%vCa^m7%Vy{U^NX|HUrf31OK6!8de&#pBSDyX2#URCU^^r_fP)!Y{ROv=X zGjBTeZ}$+j@Oe{NQCQ;61otNA+1piRhjY}Ul@i7$oW2(PjAK4dZpDAJ=)jJX2c=b1 zs0L+-iMQDpvxkU#@Y-6YE47XH^(CA9m>%EQSbKOiVW_&w$J*ypMMogmJ$X^C-?U1N zUOCtxtq%q|x{R4-)<-G1mLJK?^B#Tb%V{0P`k9)3QFLM^7+#fWws@V4i>HHc)P0qb zm7l2Bz|hVQ_o`SEeZO=h(cjly+GsofA1qpIE8OMhFX`u`C*F2+^qr=ekNmWh!?`?) zVg!Y)bX!ivKu2PgfS*4NujURz@}zg~KG%6{BYH)gkVrm8x4Jx6>u-{yZYIO_b$bfN zO`lI{GUlf4+73oUQspb{X@UwifZ|bZ-Q41$|KrEphQQ^Fa|+~o^Z_kVJQ#;_S5vct z`+CB)ssU@eLfY*_wkiJ$F~RwGiU$|RH*vY5KUwsACnveNt>N?10&O7Q9MO4>n=g-q z$3z{~prFZ{YbpC7BPSZu>fZ6Gr1qc?^=d`L-Cg%71Px}xfS6A7lz3pmRDqcy040P4 zz|sA9DzY=rBI={xYYe8u$SHV(<>l|OzJ>?ZY9F23mU8+TYD;wb%x{qDP0;E7e1;9c zz;MU+O?34AeKfa!#KL-ZqtHGj)NJn zmX|2LAXgWl3a`=1)O#>ca*u%hYif3l-U7_Mmz0!`-=pghJp~0P^fg>wkYqst#^u2- z7s~nhm5a=asR*Bt?L-&n`jV!|zlEm>4T*lJ_RXJEpXe7ullp2`^K*^<4Jb)U(bz1XPO zbH@JbO@v$QRp@Wfxd-lJACXJ>D(m1Eq5LqE0W%j27aHy%;4>HK9Rvj)!_u<{hT z1vF`#3>njFb{xgfj6vPqHcqMX85G!AN!%P-R1_LZMOnYBi9T3U`xRtdDNkl;Hcm)+ zttTG+SgIvvexLHoqGYib4tXDHYt40%-%;3hU94CaxD9sl%x^1&EfYd|3e=lHP)vhO z7(UCaKpisea$7ECsjM>l087KUqfaJnJXEOSS%SZgjqdu3NVQJSRQBS|Jx}Iku5A%tQ3*>uZx!RhSYC=U$nP70y#gg#pV5-26b~^$c6+C@rmD-51l>eIp}BXlPt8 z1`qE5W(#B{;|xOhOHMQ&;|=dp;t(uU=8gPmc@EdJE*;L0*NUo0A=TF2M1CGhDj>Yg zy^e_3LtuJv+^yx8U_?_lA6{2c;X3I~P*;EN{7%Qcf`EJU8Rx9}%7XXb3^vT;7N6}A zBueMse}5JLZ;OQI5U>gU%bVib)TSEs6%o%gURX^pyQQNVMCv8^F*s;W9#QHyDLu|(Y>e&iow5#vw;VfmEc+k@sKD-bHWjix$|Vj&H*Nm2D^7kfcD2Yz zL+7MepVp*~`RoH$IlFz-Druq%*{MN>a2)_u=}LGAA(ZdKnmVSy``^4E058KOCcax7gdp-;6jPrS>(cpj76-{oeUYr{cna`-s#XR0`R61h!CRHb#2o8? zx`eJ!IyPX}gOtOaxqofn&IeWj4K8B=ED8=UlnCxG0W>Ewm>3A*`%}U#`wfk zi0e)D&dwW43qp)a35`5CI+TyzuefBY9(&e2L>2sNd>n$q#n~lYlk4x^NHHeB=pCAM zZMTW<<$u!oJci!-cpbO`Zs-QffMx5t@Md;Uva-zeCLVP*rv)3JHD=C7f@qb@3Zn1% znCtZLFTYE^%|+ZO&8&V>~->%56 z{;v#jq8l0heot<^|8;AS`P=`tKdya2JhEcq>G_cRe*^Ds0^RT(le{w!m*`%)7C z^y2rcE2>cDzU#TcA63k6-UL{W6=-yQ+ba^KaPjM@Zsp1W>vb)&&oL=tuYZA|UO`^y zFld=By(1a>Z8DlQjaW3Y4av!AMO;;5Iln4Bp%VrZx;j5bMNi*zDQo+%4Lol4lYLX_ zDa|eMqEQxgr`OiaE7*hU3tw7q2W??kIGgl) z-{HatmxnT}FC&f;?_B1JFLs?Dd>y^$b&JYih{!`*g^Fdcn#iLE|CzS2Zp!IMe9PUU z{RZ)R@O^fNvw3Zwyk9(f{>^sA`Kn~?+xSmzU}k^6>LqtLXZSF7Sg0Nz{>;D_*BE~& zdD3ua);&k$l&8=oC?^3wmIpx8jcJ}c>(Htm8A>=!iM1uOIDo0*=qqsMqq#!4Wj6s0 zMH1$HW@Ux*+uSbX_`)k6A<)#?dif$OL0Cx4fxCRf`q}YLPoCV<*A=Yjd}mX#JV^8G z@*GB6K=dx&lGItOOCqDghaD{;r?c+ z(arVJ?hx%8ZAxj|?^D1T_`tCCaY92M%e14twEk?{?2-Ha5!q{v*PC)!qtrP$Pd)b4 zv;Mp&=rGj9DqWW^;;`UVTl)kywl|UzFXamze_iH`ve+!IPHMN+93-}APz>HP*LSVi zzAR1GyR4|;0jrt3t3&)LOuIb$XQu+ylDzb{HAhdb+ns9P50mqu9XOZ?&iKosnfe$A z-QKP~Xm$1KRRj#6FSQ)1rDb$RTGHeD>P5~ul~-{YJ34muybSoPBRp3x-UHeR8@b!D ze;@neMiO@G#fyO~3iei38Ixl0sS{#p`WIkg!3h~Qm86tltCHAcj9rew68Sbm@{BXT zex0dJK|HyE_2*ew0|9O5cCB?(B7O(tIw3CY^5uHc2XGr)Q&NJo2~bs{ZctRb6BMePLz+1Y|8ZS{u9Hl8U%EI%vE?tT~u+gOOU;cxgOzv1NVM(H;-` zVVRxL5Qm22s9Hn(83uN8{{PIlG;AEr6DPKiy7&(tQ~bU?;%xNIgSuiNg;2$|)DLRx z{-1|Og0LNziL$MOot=4Io5MtVtYT-$+xGT$z$dsh z&J;GWt7Vq;_NEmhd@rq-(-5<~G2o&Tdy`Ex;bV6xBKeo$!8k@CMySJteR!GSA^o>6uTR*Ih5F3gIms)RxBUJ}~cJ*Zlm zTUzp(CQW%=kb;Z|3FfGE1ma<;{9bDK1}}V}hBQp4%oohVF_gs&R@T;;%1L%MHsKIM zb)=1mz{dibk2=r)DF63@Z7i}cfHm$6WDN)6dIty>C<1h zBDgNafTMn~*6OOghfshpXlv7ZQL?ZQ!!WwCQl6PP^Tn5}pB>=xS9Iy6#l<4)?{0wD z&HdTftrfYVZT*MH$ZqG=x@bV!O+oKqFZ&__0!)9}@_8%qcOzPFW6GXh z|JynT0g6fYxkxkdK1tmvi&biJI%{FBiihNkpX4s?G2^`T>F>d9_e*!}+lTt|$-{@7 z-TuM8TJFmOhypw7k6c<{L_LPwVW^^m#dJiXEUb^}!u?qf_PzEcf3)I>C0BQAYfkc- zm;6^%+}d~V-@o5qc;SVe8)`Qw-phnEC42<_XaST@97L3{BRXk2yPWCb>b>kCCO?Ag zGhx(4BnPdi`x=~GKWCSLyg+;e_X}iJgM#WCIO=5135LEc8Xq54)$aLnnu@o)zpJD= z=D!&2D2av>NB{CKpE0AMsH3UK`e9h_*2t%RgyPYdS;>^0!u^gDe>T#qsfQ%JwbUkA zbgvi#VS=l;oH~fXED#pt)#}X^xXz+nCf|Yd3;urL&iURsZ6Kdh zJtd;#&^N`tdsh_Is1PL*QPE#HFq55~4Ksch;u#}y;yN&dcPzEBpw{EGXx=xw)~#5}HN{opR;bM2YRTnN^9WyeR!7G&IqmSWIm&7Y^^f^!)kt z!$Q}U)L&10i7nub|8{uLtZLA{%jyvMmk3(jtWP`PRb~7u{W8VB3jeK-_)0;8Cs5SU zbDzj@n!Y+U;f@O>`n6y4bNB09qSDC7%hS#=K17b#j>(>ql89)2K|zHGfgs<2t(UK1 z{i_RXY>Jbvd#KpwW@nk+01vDj$n7D?>W8mglFrGy?cvhbP%cn73kwT`(_5u(&#StV z|Ai|^-3G3p&VBOY^Ox2V32XgYkm1hb(p@K)@d z9?G^MW26@HEHY-^%`&GB8(y)2jf2E7W0EwIKR>!TyR=#=N0;m|FU~0UaX!k!lY%842KT*9tz1i|Ly{h;N^NF~-4`1D79K0)&8g%>J^IYRcaq}4h zJ%J?xPMNhM&x7or#IePtn5)w6Q{=AJJVv0m&{*+U1B-F8)Pf2Z~t$kK@Ae1U?RNI(4OisgrgyE;P z8kOi&x%wsl!;IcvYB<(R*UKA>jwEJ2%%1yN$7fn#%U@^~4`rSYnX#Sbgkd`Gj7CBa*Jn6M23g^za@G+De4+X6S~>6651PlsJHD?&r>>gvtJ~iXz$s&{C=di zwj!xBv7{X-ty%~NIWDClyne{Tt^Qj@${h=~c!luO-mMZ^M zV7`q7Pz0F+ENYood|F#o6%-U2?xD5YvuoGcQwR)wi{uxK#Q{nWQV*Al`OnU9!b~<$ zqBVr9Lu8Ud+ao1oHU`H(z*YPV@LT|Bw>y{(d3t&R)By)DI5f0}0T9P6sifQM<3!w2 zNlmGn&edNEiE%m=#as)0{?eI;RfXiN{*3B+cWIo2pD!exB96zFT?YqdLqp~Vwy!P< zM_E;#;LGC?>gTEjhV+K^J*Hnk}B}`uc~6N$Eu+ z?2G3kR`V-+!uqqD&~1VTz=?-YM}UxgLWQ>wGN(}!A=tuQ|5ot?v;0d8Lz7@AgB-Z%eLJ(rH)TAn zM*#P6Lc!jXhNu8&{_YuorRnzAT;E9}vefIPtUR%nGm2&r7xgJ`l3lw12KpbD3!Bf{ zULUi{;`cyiiN}4>J;SA>*s}hEyRIZDUytQVcBP1j*2oaMp4NLMjU;;;J39W;){S3Y zr*W*l+80mNm{nL9E|Z`X|n?a$XlA?ax`?9R;OA)t%?2nenLk#VHtW)WH zHBOe_Ve%!J4hlj_ZOQE@+hHrlT(j`xwn7IBI5)Bk6lB^ZBqRur6VkxSy1KN)L<}nl zEWTg95`t+UjHzs3U&zVH83HS*_i%;RY8Na^cs)TJHQGvahF~atZ#h9OfOzcE(`klLkVM}oi{Xh-oNj+G)uXC!@Zk_Cr6~)L;dY} z_7MN0l>FAJF%r=!_*gkP&t?KlOq#?9B9xyxdbVMjFE$m*ku~B@jhqF37gT{Vc8VXkFvW z>TkEH#-0y|t?aHTm(DNzQs{hAtU!I=zFZ?qHzUN)=Pwa+|JZcN^eE+ed|?DBpN^*;`t$dRl|YRDYTddMAn zDy2Y+gdsV_J3Nr&*Yo*>5Xl9lrGk@7+~3&EJM(fdVQBDvxAWT-#ddm9vV6x-4GP^i zm2Wtlc6=5iQSgtW7b*0r)YJPN_CrL({9aRU@zROOeIc`7M*PyjG=OuJF_sVG9--Wu z**!T+T(6awl(@KNtx9s`iq=`jdLjkV#rJnVWsMvup}TxgY-!n%oPoEmpE-(BpO0KaBw)XCU5S`gLeArczqu6dm~$T7cgr~Pacum0Uf zTt;Pb{S9R)(X=7qytZ?$ni=d%I*}4;sfWb!`72$PPBMBW*LdGoGY{KJJN*4Uo72(I zjNPYuF;~Am(Ac=CB`xG#{<*e}XCoxpo%J`z($7>ZM1D4V@aH2!)Fh{GVA9|3X24EN z(wlo8KB)SP%O(%->FxClm@K9cB#2l2>*>m)zyAipzXixRVT7W66h&ISg#TF!@kECU z^cQ&9VubUS>u2j%=ZVGs?X1FY2$0<+$Ts=q__=b*>$c<%T9AKw+;=byi~yjH)z z_SJTVKZhCNH)u&NK$raIHGH1r|CPTo!*~jDlUuL6JIKUdh=ihoHX>qQoB#Qa-p%8G z{(k5n&wu&zNx}w)`rEp%g20YuWn(kL^K&2}Jgh%2e3HOFnH6~YHNh&1rs|F+PiV4?F1|Q{y@N zyQj`)BHY>^7(3h__jnBFhwhe+9WAWsu(6&&;nNv*2QldC+S(SXj3L$Aw{HhzH3SXk zHGW}X;p4|Y%UUdU+V~^Uj_gf#Ha9afl;sPUVAiszYhi%{s6ASe6eD?la_@C=aq*`W z%>8&3DES)NO+@cRf!isa!LUnQ_b);d%UW6--~yLZZU~%2Rz)dFf!F{TYH+C^(JD;9 z9~(VVJjLcK{TM&Ku4DdlUtt)2BD#glM$$X%9|&Nw4>x(y4kq{M=?C1U^Hz396FpFnS& zia3^Qr+||U)r9BWt_fq;EI6zjTs?sD#yFiGbzd}vsA$QD59b-Ym6K}f>gp;h;gVa0 z>uU_C7&3jLtFVT6_RfvY!xH!L2P|%6m=bTQlF$yKWBiCWLm`7BCs#&v8nbTNLhVDq^m-lm}3x5j=YW(X& z?kwI~blx};*bKi{{WFaZiFqy;)@4$Z3(;Lb#)JU??$e*CBof}h_cb{CU|))QPL(Bp zAIKGgqC?1OCaX?_hEwk{)EWC&2_d2P8VmzOuAQW%+4+EwldiB3q-+}8_X>0>HPomB z;@s7Qi3o=dHDLwMHmM8cft5|`y6mk6iH)i~rXOFAMU51L2f<%=f0#&8u=E%CD7R+C#pJy7 z6zPpy`z*iaONR@N^v>@TflAM%uiI$g6f8Stqp^c8#{AvJJI?>lKfMr; z2il)B*RKP@0$hbc>^+1%V!rq`plQqNlGPAPQ7%C)7TBu^LBE}ttcwte%Ouaj!!;BY z|2ZUm@)NpQHda1)C0B6V!pU*it#%{C;F@qk*=jX%je(L-5M#t#rbu}mH z(Kht80pG{&39Ga;QXZsCx;F6r1pWa2Nz9zdEpgoX?3P~7aASD#8yRq^ccuqbmc?QB zBQ+0J9>nABb6_?XVJcVbz1T-IW%x6C|uY6W!?K^e`4arQ4xdY?NcdBF}cd?t>eT@OrK(A23SQA zb?!FzgZSQFx_`X$!-7`7RgC$NS)6wIkz|plKk##G_2^MLf&SO4;}fPnWEBBQ#hX*? z>&G_Vx#o~D#wesMEASPii~e3bv4ktvlV2rk*4$d03&RFtw+jju=1!=MD)x9(UpA2= zdXVK@vL;@9Cdf>Uz6}!iUL1@QJo~tivKO;`owTu{f`yS$EQ;Io@~P>$xyJwx zPbkJF^-+FK;rYRU;51*V~oSmns zu44U~otTui=~^1s$Tftf27?iB`2+>^p@UawvB+Lim>#0xH5m9Q$S+_!HOR3q!nSYD zU(kK?(a-Up2jR2n8$VIB_t)aSUyJjj>Txdmv7Jr>5*7|K4}DNm?tNz=ASHvl6|Lqk&<0F`iDZ0s(E z7@mv@Kbkz9&kvF7g6vUbwU)y;ql;)qewc}`T=~|6xeGPnd^9QFJDstS;GIhr#^ZgYH3p`8N z8#g)$iuuv8?e!wo)_cUn@6tt{{oGc0CHh<3OX*eTe$tlf1KO_d24bnGHK%^8()V*{ zSCWg#($Nds&soyYnB){p_WtPK^y|`HuD{eGJt%zE^IXSfCgxqH~ zq!tm{=1oeni=J#|>`t_(sX}^I*7}%CUCy^dTUD`VxF6xB%6o&49uPn=lAoKq0Hzv% zroM#4J+eOJ2AxezR!W#GTGVQgM?FDz;DE0CKYkLkApJGk543P!eviuwH1~_#6=;%q zV7w@$IO*Ai(&X1KOQT(5kazVcDY>_$##hO_;Em; zVW~WrahTz;>3-7jbPyzWXPKD?feZ}QMUn2`Z{9Js!_vlf!PdidL0pM3O^kv5Af5V+ z8x$ewQ{RKe#}YYJsD^fG#76RSc>5l1wfOG+O^MJ~w|*;?4r(DiDxYaNjA294giDLX zr7&SpVW~H|&Q?J4!j+7`t2Ckpzq^ZeazYF66(dZQPF-g+X$`YNqXwOZakLmKYtr)U z(Af6-asickaXw%m#+y^ZpTETPBTIZHpw4?3?(L@)(EIWT~H>>^L9cymw!|d;w0Yhszs()o&y6xZL5!xmtQmiGlh)j>lzgI76?JqBHb2VKv-g#83uVWeCB#mR)?M20*=>dXNByQSRoKar*jQy%{Gy0hYen;;@~Nsf1KA z3{i0%if~w2oSGULX;T*VfVWzytm~;PGbRO_#e9y!sIjRwgD8Y$N(|utX=4}; z00HQfbaV(knA*uy#mmbZJa^jc5v#Xqfvu63K?mPuQ(fIz+>4^qWd}|L8N*7Rq?}@$ z6p1+C)9+`2wh|_>v(rWiI+$;)EsOc$Uft=Hk6IJ|K`_c+zU+tz-dKd!)YK9bPt2J} z%UIjkfYpuhg2MK&of6~`&Acc}gtMVW2c8)NEKWW@p4qUyqI&pRBuHWyE{z5QKA+DA7}1+OzK1u|sN= zm>wYrXnFWN{=&JxLO%>)tpti2eLL+U=cepT6i?hn0q#LYBPB4@^0W`BN;3t!Yf!(R zUD-`!&avatC5hweDU<3}31pu-3T?dP=Bicnh6ZUOs%k4+o)Pce`WPFJw@UgKLbn>_ z40Z0Mq)gb<3A1>AsjP(T*QhkIqYQZp>YAEtK0*$gZFo=!W7B(K-1&sgZSEUFzZlA9 z1_IDInk*P&H!_+=&7II>%)-JF z?GvpKDHIwQ$h6~uT)2#nagK9cBn`#UzbwURrG%b2P5bNbgzGRaMbMkf`d#v)COv4-p@!Qxd7n=pgi?UvydAKUA$5H59ox7-oIBD znnZjF^^M+kh&mo_P#!vjn2^V+eXFeXURa13HOg2*?VWxfP|9V4Qw8JiPknu4wbl5J9905WEaHv=FumTo#DOd^~n7oNDVk zHFoUWnah0nJt3dmWic%HzK@U8g+8F7Dax#YmFN_EZjwU6jLHnzuDHJ@=#Ei}$%b)D zslR+D=iHHgOWbP!&8r#bGSoGim^$Pd1@`w{Yy`%`RC^K>9c>#-F3Eiz~tHMi72Ql z-#Z@Ps=4coN;kIk$_Kr8uCrn!_8+h+^WpeiKvOTpA2<>k@^iOS8r_`<;SlA@Y7Po9 zM;E6P2OpM86J=8oHlgY%Y{Iu6w_D6*ZT==SEY4&WLT3rH*DeN%0|y}RX7~ODtYD$d z>@h4lw!J@p9&5_RCFM%(7ctS46k@BWq(nhSXU=>O^OGQTvBCLVRV7_&joCy-B4~+& zzs8^aPFt@X_amsit&OJ@gW!&Xwcz35*|u%lK4n8|0^Fo~_|sHZVV*?vNLyCCQ4ajC zjBIQvsi~6)l8(Ee1HCFN;;>X8dE_3(6hsnqh>D6&$@+kYS(xre9kMzQayP9f{*M+w z+g6|pYUbE~GPncFN5fMo_xBD478C8JKirfBvX)eKuhLS9wJrC;R9Kig z{Z#EeAMD0h3eGV)NHA+i@9jprdZG?FOARFAmsXh@RG3!~0?^eAgC6UUvAAuDTt znvwH7$+e_0phI)Vd3l>3QPp-8PFb33-LjMO^LN<3=M)jqW7E~M-D?FG|BV1@ykq|c zbVOH22UUYdi>8Craa2Mm)^>y7R1cL%G@XmoW)C6Wxf4;_b+#eFzwx?V3~}-iqrgdQ zl@U#gDJ_7iu)(pEv7jN3#?}DiAF^=LFr8H#S2V=+*af+{yjr|Wzj4KnA8&zQ67xki z<<2NQo;QjoP&;2&RQ!ePZW0E3Gd-;~FEM1x=E{|4H`qOve%ucqrzA3e^7BA~w#C{7 zlIgiX(nZpVuI>h-cOGu(5+u{3)wAn`OWewTy(;=LI!e91K}BRqbMln(sQ`<#C8IZ$BpI)jC$+gy&F+lalbw-q1YQ{=Xc!$nWuBZV%e)6U@Wrn0el(u~1Vb2V zL)M4sXPB6Du|gqekJH3ZmlB+lMJ}5s-@l$p{OBbA;>-`kyYSuMFaQy4(wsc~ z>(}MWm&wGAA`%MX64Oz65{8Q~f!f*4<5WU-0V^3Hdt>DvENlbRR#GX-f;c5S*81s# zZTArIeeBjI-Q=_PC({`eUwo49Vrdf|A8Vk+%04naLjFCz#V_<#td`?|T=dg)BPp9{ zqrPS<nJ@OK!Z`XAzp#RSd9|d zZ$K#7Xdx~z(ARHjY#i?{Dg+K!N-D}Nee!<}BEFy>Mmvvelhnk-fC6fM>zMd>0|fOD za+oy>o(Bfb!Znbln!$*$(rhz8WAjp#=!;7b>4awonKxu|GuYf6?d`nh-k|HaV!@&i ziC`ElEv-9uJWf}ga$7~Sy$RhJL4x<{RWGP&RcO6$iNlx%%^6Y}kjNjR?cTN)^w)

1i?{Da5c{25grwGCWtQ)&XRg-0E>f0 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 literal 0 HcmV?d00001 From c9b0a327384218aaf60b7fb8fabe7d8aeafa706a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=83=A1=E9=A3=9E?= <1835698775@qq.com> Date: Fri, 8 May 2026 21:00:27 +0800 Subject: [PATCH 07/45] chore(main): bump version to 0.4.0 --- main/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main/Cargo.toml b/main/Cargo.toml index c6b88a2b89..32e09b4b02 100644 --- a/main/Cargo.toml +++ b/main/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "main" -version = "0.3.3" +version = "0.4.0" publish.workspace = true edition.workspace = true From c18ababb2a21a20f132b5211623da366f62452e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=83=A1=E9=A3=9E?= <1835698775@qq.com> Date: Fri, 8 May 2026 21:01:01 +0800 Subject: [PATCH 08/45] =?UTF-8?q?feat:=20=E4=BF=AE=E6=94=B9=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 3620836ea6..74ae0b1053 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6037,7 +6037,7 @@ checksum = "670fdfda89751bc4a84ac13eaa63e205cf0fd22b4c9a5fbfa085b63c1f1d3a30" [[package]] name = "main" -version = "0.3.3" +version = "0.4.0" dependencies = [ "anyhow", "base64 0.22.1", From 2a96d0569dc26ccab734e4939be2e7453dbe2e47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=83=A1=E9=A3=9E?= <1835698775@qq.com> Date: Sat, 9 May 2026 14:25:11 +0800 Subject: [PATCH 09/45] =?UTF-8?q?feat(logging):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E6=97=A5=E5=BF=97=E6=96=87=E4=BB=B6=E8=B7=AF=E5=BE=84=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E5=8F=8A=E6=96=87=E4=BB=B6=E5=86=99=E5=85=A5=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增日志相关国际化文案,包括日志组标题与日志文件路径说明 - 增加配置项支持自定义日志文件保存路径,默认为配置目录下logs文件夹 - 实现日志文件初始化逻辑,支持自动创建父目录及文件权限设置(Unix系统权限为600) - 日志初始化失败时输出错误日志,并降级为控制台打印 - 使用tracing-appender实现异步非阻塞日志写入文件功能 - 添加单元测试覆盖日志路径解析及文件写入行为,保证功能稳定 - 在设置界面新增日志文件路径配置项,支持用户输入自定义路径并保存 --- Cargo.lock | 20 ++++++ main/Cargo.toml | 1 + main/locales/main.yml | 14 +++++ main/src/onetcli_app.rs | 136 ++++++++++++++++++++++++++++++++++++++-- main/src/setting_tab.rs | 24 +++++++ 5 files changed, 189 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 74ae0b1053..709f2f5655 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6067,6 +6067,7 @@ dependencies = [ "terminal_view", "tokio", "tracing", + "tracing-appender", "tracing-subscriber", "winresource", "zip 2.4.2", @@ -10474,6 +10475,12 @@ dependencies = [ "zeno", ] +[[package]] +name = "symlink" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" + [[package]] name = "syn" version = "1.0.109" @@ -11283,6 +11290,19 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-appender" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" +dependencies = [ + "crossbeam-channel", + "symlink", + "thiserror 2.0.18", + "time", + "tracing-subscriber", +] + [[package]] name = "tracing-attributes" version = "0.1.31" diff --git a/main/Cargo.toml b/main/Cargo.toml index 32e09b4b02..fd5b434252 100644 --- a/main/Cargo.toml +++ b/main/Cargo.toml @@ -19,6 +19,7 @@ semver = { workspace = true } tokio = { workspace = true } tracing = { workspace = true } tracing-subscriber = { version = "0.3", features = ["env-filter"] } +tracing-appender = "0.2" rust-i18n = { workspace = true } base64 = { workspace = true } dirs = {workspace = true} diff --git a/main/locales/main.yml b/main/locales/main.yml index dd4e073103..cbf51c4473 100644 --- a/main/locales/main.yml +++ b/main/locales/main.yml @@ -666,6 +666,20 @@ Settings: zh-CN: 设置自动保存的时间间隔(1-60秒) zh-HK: 設置自動保存的時間間隔(1-60秒) + Log: + group_title: + en: Log + zh-CN: 日志 + zh-HK: 日誌 + file_path: + en: Log File Path + zh-CN: 日志保存路径 + zh-HK: 日誌保存路徑 + file_path_desc: + en: Leave empty to write logs to the default config directory logs folder. Restart the app after changing this path. + zh-CN: 留空时写入默认配置目录下的 logs 文件夹,修改后重启应用生效。 + zh-HK: 留空時寫入默認配置目錄下的 logs 文件夾,修改後重啟應用生效。 + Update: group_title: en: Update diff --git a/main/src/onetcli_app.rs b/main/src/onetcli_app.rs index 4ffb47cf54..35c8fcb06a 100644 --- a/main/src/onetcli_app.rs +++ b/main/src/onetcli_app.rs @@ -45,7 +45,12 @@ use gpui::px; use gpui_component::dock::{ClosePanel, ToggleZoom}; use gpui_component::{ActiveTheme, Root}; use one_core::llm::manager::GlobalProviderState; +use one_core::storage::manager::get_config_dir; use one_core::tab_container::{TabContainer, TabContentRegistry, TabItem}; +#[cfg(unix)] +use std::os::unix::fs::OpenOptionsExt; +use std::path::{Path, PathBuf}; + use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::util::SubscriberInitExt; @@ -113,16 +118,70 @@ fn quit_app(cx: &mut App) { cx.quit(); } -pub fn init(cx: &mut App) { - // 从 RUST_LOG 环境变量读取日志级别,默认 info +fn init_tracing(settings: &AppSettings) { let env_filter = tracing_subscriber::EnvFilter::try_from_default_env() .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")); - tracing_subscriber::registry() - .with(tracing_subscriber::fmt::layer()) - .with(env_filter) - .init(); + match configured_log_file_path(&settings.log_file_path) { + Ok(log_file_path) => match log_file_appender(&log_file_path) { + Ok(file_appender) => { + let (non_blocking, guard) = tracing_appender::non_blocking(file_appender); + Box::leak(Box::new(guard)); + tracing_subscriber::registry() + .with(tracing_subscriber::fmt::layer()) + .with(tracing_subscriber::fmt::layer().with_writer(non_blocking)) + .with(env_filter) + .init(); + } + Err(err) => { + tracing_subscriber::registry() + .with(tracing_subscriber::fmt::layer()) + .with(env_filter) + .init(); + tracing::error!(path = %log_file_path.display(), error = %err, "日志文件初始化失败"); + } + }, + Err(err) => { + tracing_subscriber::registry() + .with(tracing_subscriber::fmt::layer()) + .with(env_filter) + .init(); + tracing::error!(error = %err, "默认日志目录初始化失败"); + } + } +} + +fn configured_log_file_path(value: &str) -> anyhow::Result { + let trimmed = value.trim(); + if trimmed.is_empty() { + Ok(default_log_file_path()?) + } else { + Ok(PathBuf::from(trimmed)) + } +} + +fn default_log_file_path() -> anyhow::Result { + Ok(get_config_dir()?.join("logs").join("onetcli.log")) +} + +fn log_file_appender(path: &Path) -> std::io::Result { + if let Some(parent) = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + std::fs::create_dir_all(parent)?; + } + + let mut options = std::fs::OpenOptions::new(); + options.create(true).append(true); + #[cfg(unix)] + options.mode(0o600); + options.open(path) +} + +pub fn init(cx: &mut App) { let settings = AppSettings::load(); + init_tracing(&settings); let http_client = build_app_http_client(&settings.global_proxy).expect("HTTP 客户端初始化失败"); cx.set_http_client(http_client); gpui_component::init(cx); @@ -312,6 +371,71 @@ impl OnetCliApp { } } +#[cfg(test)] +mod tests { + use super::{configured_log_file_path, default_log_file_path, log_file_appender}; + use std::io::Write; + + #[test] + fn configured_log_file_path_uses_default_for_empty_value() { + let default_path = default_log_file_path().expect("应返回默认日志路径"); + + assert_eq!(configured_log_file_path("").unwrap(), default_path); + assert_eq!(configured_log_file_path(" ").unwrap(), default_path); + } + + #[test] + fn configured_log_file_path_trims_value() { + let path = configured_log_file_path(" /tmp/onetcli.log ").expect("应返回日志路径"); + assert_eq!(path, std::path::PathBuf::from("/tmp/onetcli.log")); + } + + #[test] + fn log_file_appender_creates_parent_directories_and_appends() { + let path = std::env::temp_dir() + .join(format!("onetcli-log-test-{}", std::process::id())) + .join("nested") + .join("app.log"); + + { + let mut file = log_file_appender(&path).expect("应创建日志文件"); + writeln!(file, "first").expect("应写入第一行"); + } + { + let mut file = log_file_appender(&path).expect("应重新打开日志文件"); + writeln!(file, "second").expect("应追加第二行"); + } + + let content = std::fs::read_to_string(&path).expect("应读取日志文件"); + assert_eq!(content, "first\nsecond\n"); + + let _ = std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()); + } + + #[cfg(unix)] + #[test] + fn log_file_appender_creates_private_file() { + use std::os::unix::fs::PermissionsExt; + + let path = std::env::temp_dir() + .join(format!( + "onetcli-log-permission-test-{}", + std::process::id() + )) + .join("app.log"); + let _file = log_file_appender(&path).expect("应创建日志文件"); + + let mode = std::fs::metadata(&path) + .expect("应读取日志文件元数据") + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o600); + + let _ = std::fs::remove_dir_all(path.parent().unwrap()); + } +} + impl Render for OnetCliApp { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let sheet_layer = Root::render_sheet_layer(window, cx); diff --git a/main/src/setting_tab.rs b/main/src/setting_tab.rs index e1ca4a51fb..8189e64656 100644 --- a/main/src/setting_tab.rs +++ b/main/src/setting_tab.rs @@ -277,6 +277,8 @@ pub struct AppSettings { pub terminal_confirm_multiline_paste: bool, #[serde(default = "default_true")] pub terminal_confirm_high_risk_command: bool, + #[serde(default)] + pub log_file_path: String, #[serde(default = "default_true")] pub auto_update: bool, #[serde(default)] @@ -349,6 +351,7 @@ impl Default for AppSettings { terminal_cursor_blink: false, terminal_confirm_multiline_paste: default_true(), terminal_confirm_high_risk_command: default_true(), + log_file_path: String::new(), auto_update: true, global_proxy: GlobalProxySettings::default(), database_open_mode: DatabaseOpenMode::default(), @@ -796,6 +799,27 @@ impl SettingsPanel { t!("Settings.General.Database.auto_save_interval_desc").to_string(), ), ]), + SettingGroup::new() + .title(t!("Settings.General.Log.group_title")) + .item( + SettingItem::new( + t!("Settings.General.Log.file_path"), + SettingField::input( + |cx: &App| { + SharedString::from( + AppSettings::global(cx).log_file_path.clone(), + ) + }, + |val: SharedString, cx: &mut App| { + let settings = AppSettings::global_mut(cx); + settings.log_file_path = val.trim().to_string(); + settings.save(); + }, + ) + .default_value(SharedString::from("")), + ) + .description(t!("Settings.General.Log.file_path_desc").to_string()), + ), SettingGroup::new() .title(t!("Settings.General.Update.group_title")) .items(vec![ From 690937ef0162b900b9076e9bfc8b0e575a0d9097 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=83=A1=E9=A3=9E?= <1835698775@qq.com> Date: Sat, 9 May 2026 16:47:27 +0800 Subject: [PATCH 10/45] =?UTF-8?q?feat(terminal):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E7=BB=88=E7=AB=AF=E9=BC=A0=E6=A0=87=E6=BB=9A=E8=BD=AE=20SGR=20?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F=E6=94=AF=E6=8C=81=E5=8F=8A=20Vim=20=E9=BC=A0?= =?UTF-8?q?=E6=A0=87=E5=A2=9E=E5=BC=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 sgr_mouse_wheel_report 函数,为鼠标滚轮提供 SGR 报告支持 - 终端视图处理鼠标滚轮时,根据模式发送正确的 SGR 控制序列 - 在 shell_integration.sh 中增加 Vim 和 Neovim 鼠标支持的包装函数 - 通过 shell_integration.rs 添加多项单元测试,确保 Vim 包装函数行为正确 - 支持 ONETCLI_VIM_MOUSE 环境变量禁用 Vim 鼠标功能 - 单元测试覆盖 vim/neovim 包装函数参数传递和别名、函数保护机制 --- crates/terminal/src/shell_integration.rs | 225 +++++++++++++++++++++++ crates/terminal/src/shell_integration.sh | 39 ++++ crates/terminal_view/src/view.rs | 49 ++--- 3 files changed, 290 insertions(+), 23 deletions(-) diff --git a/crates/terminal/src/shell_integration.rs b/crates/terminal/src/shell_integration.rs index d16f44a965..0f11ba7fde 100644 --- a/crates/terminal/src/shell_integration.rs +++ b/crates/terminal/src/shell_integration.rs @@ -9,6 +9,8 @@ pub(crate) fn embedded_shell_integration_script() -> String { #[cfg(test)] mod tests { use super::{embedded_shell_integration_script, normalized_shell_integration_script}; + #[cfg(unix)] + use std::{fs, os::unix::fs::PermissionsExt, process::Command}; #[test] fn normalized_shell_integration_script_converts_crlf_to_lf() { @@ -26,4 +28,227 @@ mod tests { "嵌入式 shell integration 脚本不应保留 CR,避免远端 shell 解析失败" ); } + + #[test] + fn embedded_shell_integration_script_enables_vim_mouse() { + let script = embedded_shell_integration_script(); + assert!(script.contains("--cmd 'set mouse=a'")); + assert!(script.contains("--cmd 'nnoremap gkzz'")); + assert!(script.contains("--cmd 'nnoremap gjzz'")); + assert!(script.contains("function vim {")); + assert!(script.contains("__onetcli_can_wrap_command vim")); + } + + #[cfg(unix)] + #[test] + fn bash_vim_wrapper_preserves_args() { + assert_vim_wrapper_preserves_args("bash"); + } + + #[cfg(unix)] + #[test] + fn zsh_vim_wrapper_preserves_args() { + if !shell_available("zsh") { + return; + } + assert_vim_wrapper_preserves_args("zsh"); + } + + #[cfg(unix)] + #[test] + fn bash_vim_wrapper_does_not_override_alias() { + assert_vim_wrapper_does_not_override_alias("bash"); + } + + #[cfg(unix)] + #[test] + fn zsh_vim_wrapper_does_not_override_alias() { + if !shell_available("zsh") { + return; + } + assert_vim_wrapper_does_not_override_alias("zsh"); + } + + #[cfg(unix)] + #[test] + fn bash_vim_wrapper_does_not_override_function() { + assert_vim_wrapper_does_not_override_function("bash"); + } + + #[cfg(unix)] + #[test] + fn zsh_vim_wrapper_does_not_override_function() { + if !shell_available("zsh") { + return; + } + assert_vim_wrapper_does_not_override_function("zsh"); + } + + #[cfg(unix)] + #[test] + fn bash_vim_mouse_can_be_disabled() { + assert_vim_mouse_can_be_disabled("bash"); + } + + #[cfg(unix)] + #[test] + fn zsh_vim_mouse_can_be_disabled() { + if !shell_available("zsh") { + return; + } + assert_vim_mouse_can_be_disabled("zsh"); + } + + #[cfg(unix)] + #[test] + fn bash_nvim_wrapper_preserves_args() { + assert_nvim_wrapper_preserves_args("bash"); + } + + #[cfg(unix)] + #[test] + fn zsh_nvim_wrapper_preserves_args() { + if !shell_available("zsh") { + return; + } + assert_nvim_wrapper_preserves_args("zsh"); + } + + #[cfg(unix)] + fn assert_vim_wrapper_preserves_args(shell: &str) { + let output = run_interactive_shell( + shell, + "source \"$ONETCLI_TEST_SCRIPT\"\nvim 'a b.txt' -- '--weird;$HOME'", + ); + + assert!(output.status.success()); + assert_eq!( + strip_shell_integration_osc(&String::from_utf8_lossy(&output.stdout)), + "--cmd\nset mouse=a\n--cmd\nnnoremap gkzz\n--cmd\nnnoremap gjzz\n--cmd\ninoremap gkzz\n--cmd\ninoremap gjzz\na b.txt\n--\n--weird;$HOME\n" + ); + } + + #[cfg(unix)] + fn assert_vim_wrapper_does_not_override_alias(shell: &str) { + let output = run_interactive_shell( + shell, + "shopt -s expand_aliases 2>/dev/null || true\nalias vim='echo alias-safe'\nsource \"$ONETCLI_TEST_SCRIPT\"\nvim", + ); + + assert!(output.status.success()); + assert_eq!( + strip_shell_integration_osc(&String::from_utf8_lossy(&output.stdout)), + "alias-safe\n" + ); + } + + #[cfg(unix)] + fn assert_vim_wrapper_does_not_override_function(shell: &str) { + let output = run_interactive_shell( + shell, + "vim() { echo function-safe; }\nsource \"$ONETCLI_TEST_SCRIPT\"\nvim", + ); + + assert!(output.status.success()); + assert_eq!( + strip_shell_integration_osc(&String::from_utf8_lossy(&output.stdout)), + "function-safe\n" + ); + } + + #[cfg(unix)] + fn assert_vim_mouse_can_be_disabled(shell: &str) { + let output = run_interactive_shell( + shell, + "source \"$ONETCLI_TEST_SCRIPT\"\nONETCLI_VIM_MOUSE=0 vim file.txt", + ); + + assert!(output.status.success()); + assert_eq!( + strip_shell_integration_osc(&String::from_utf8_lossy(&output.stdout)), + "file.txt\n" + ); + } + + #[cfg(unix)] + fn assert_nvim_wrapper_preserves_args(shell: &str) { + let output = run_interactive_shell(shell, "source \"$ONETCLI_TEST_SCRIPT\"\nnvim file.txt"); + + assert!(output.status.success()); + assert_eq!( + strip_shell_integration_osc(&String::from_utf8_lossy(&output.stdout)), + "--cmd\nset mouse=a\n--cmd\nnnoremap gkzz\n--cmd\nnnoremap gjzz\n--cmd\ninoremap gkzz\n--cmd\ninoremap gjzz\nfile.txt\n" + ); + } + + #[cfg(unix)] + fn shell_available(shell: &str) -> bool { + let available = Command::new(shell).arg("--version").output().is_ok(); + if !available { + eprintln!("跳过 {shell} 行为测试:当前环境未安装该 shell"); + } + available + } + + #[cfg(unix)] + fn run_interactive_shell(shell: &str, command: &str) -> std::process::Output { + let temp_dir = std::env::temp_dir().join(format!( + "onetcli-shell-integration-test-{}-{:?}", + std::process::id(), + std::thread::current().id() + )); + let bin_dir = temp_dir.join("bin"); + let home_dir = temp_dir.join("home"); + let zdot_dir = temp_dir.join("zsh"); + fs::create_dir_all(&bin_dir).expect("应创建测试 bin 目录"); + fs::create_dir_all(&home_dir).expect("应创建测试 HOME 目录"); + fs::create_dir_all(&zdot_dir).expect("应创建测试 ZDOTDIR 目录"); + + let script_path = temp_dir.join("shell_integration.sh"); + fs::write(&script_path, embedded_shell_integration_script()).expect("应写入集成脚本"); + + write_fake_editor(&bin_dir.join("vim")); + write_fake_editor(&bin_dir.join("nvim")); + + let command_path = temp_dir.join("command.sh"); + fs::write(&command_path, command).expect("应写入测试命令脚本"); + + let path = format!( + "{}:{}", + bin_dir.display(), + std::env::var("PATH").unwrap_or_default() + ); + let output = Command::new(shell) + .arg("-i") + .arg(&command_path) + .env("PATH", path) + .env("HOME", &home_dir) + .env("ZDOTDIR", &zdot_dir) + .env("ONETCLI_TEST_SCRIPT", &script_path) + .output() + .expect("应执行 shell 行为测试"); + + let _ = fs::remove_dir_all(&temp_dir); + output + } + + #[cfg(unix)] + fn write_fake_editor(path: &std::path::Path) { + fs::write( + path, + "#!/bin/sh\nfor arg in \"$@\"; do printf '%s\\n' \"$arg\"; done\n", + ) + .expect("应写入 fake editor"); + fs::set_permissions(path, fs::Permissions::from_mode(0o755)) + .expect("应设置 fake editor 可执行权限"); + } + + #[cfg(unix)] + fn strip_shell_integration_osc(output: &str) -> String { + output + .replace("\u{1b}]133;C\u{7}", "") + .replace("\u{1b}]133;D;0\u{7}", "") + .replace("\u{1b}]133;A\u{7}", "") + .replace("\u{1b}]133;B\u{7}", "") + } } diff --git a/crates/terminal/src/shell_integration.sh b/crates/terminal/src/shell_integration.sh index 277113abab..9f3e815509 100644 --- a/crates/terminal/src/shell_integration.sh +++ b/crates/terminal/src/shell_integration.sh @@ -40,6 +40,45 @@ __onetcli_last_history_command() { fi } +__onetcli_enable_vim_mouse() { + local editor="$1" + shift + if [[ "${ONETCLI_VIM_MOUSE:-1}" == "0" ]]; then + command "$editor" "$@" + return + fi + command "$editor" \ + --cmd 'set mouse=a' \ + --cmd 'nnoremap gkzz' \ + --cmd 'nnoremap gjzz' \ + --cmd 'inoremap gkzz' \ + --cmd 'inoremap gjzz' \ + "$@" +} + +__onetcli_can_wrap_command() { + local name="$1" + if [[ -n "${ZSH_VERSION:-}" ]]; then + local command_type + command_type="$(whence -w "$name" 2>/dev/null)" + [[ "$command_type" == "$name: command" || "$command_type" == "$name: hashed" ]] + else + [[ "$(type -t "$name" 2>/dev/null)" == "file" ]] + fi +} + +if __onetcli_can_wrap_command vim; then + function vim { + __onetcli_enable_vim_mouse vim "$@" + } +fi + +if __onetcli_can_wrap_command nvim; then + function nvim { + __onetcli_enable_vim_mouse nvim "$@" + } +fi + __onetcli_emit_recorded_command() { local command_text encoded command_text="$(__onetcli_last_history_command)" diff --git a/crates/terminal_view/src/view.rs b/crates/terminal_view/src/view.rs index 3be149adc1..5b3c0ebb73 100644 --- a/crates/terminal_view/src/view.rs +++ b/crates/terminal_view/src/view.rs @@ -106,17 +106,13 @@ fn take_whole_scroll_lines(scroll_lines_accumulated: &mut f32) -> i32 { lines } -fn alt_screen_scroll_arrow(lines: i32, app_cursor: bool) -> Option<&'static str> { +fn sgr_mouse_wheel_report(lines: i32, col: usize, row: usize) -> Option { if lines == 0 { return None; } - Some(match (lines > 0, app_cursor) { - (true, true) => "\x1bOA", // Up, application mode - (true, false) => "\x1b[A", // Up, normal mode - (false, true) => "\x1bOB", // Down, application mode - (false, false) => "\x1b[B", // Down, normal mode - }) + let button = if lines > 0 { 64 } else { 65 }; + Some(format!("\x1b[<{};{};{}M", button, col + 1, row + 1)) } fn should_scroll_to_bottom_on_user_input( @@ -2998,11 +2994,14 @@ impl TerminalView { } if mode.contains(TermMode::ALT_SCREEN) { - // ALT_SCREEN(vim、less 等):累计到整行后再转为上下箭头,避免放大小幅滚轮输入 - if let Some(arrow) = alt_screen_scroll_arrow(lines, mode.contains(TermMode::APP_CURSOR)) - { - for _ in 0..lines.abs() { - self.write_to_pty(arrow.as_bytes().to_vec(), cx); + if mode.contains(TermMode::SGR_MOUSE) && mode.intersects(TermMode::MOUSE_MODE) { + let point = self.pixel_to_point(event.position, self.terminal_bounds, cx); + if let Some(report) = + sgr_mouse_wheel_report(lines, point.column.0, point.line.0 as usize) + { + for _ in 0..lines.unsigned_abs() { + self.write_to_pty(report.as_bytes().to_vec(), cx); + } } } return; @@ -3798,10 +3797,10 @@ impl Element for ResizeEventHandler { #[cfg(test)] mod tests { use super::{ - UnbracketedPasteHazard, alt_screen_scroll_arrow, detect_unbracketed_paste_hazard, - has_trailing_line_continuation, has_unterminated_shell_quote, history_prompt_available, - history_prompt_dropdown_origin, history_prompt_overlay_bounds, - multiline_non_empty_line_count, should_defer_inline_history_prompt_input_to_text_system, + UnbracketedPasteHazard, detect_unbracketed_paste_hazard, has_trailing_line_continuation, + has_unterminated_shell_quote, history_prompt_available, history_prompt_dropdown_origin, + history_prompt_overlay_bounds, multiline_non_empty_line_count, sgr_mouse_wheel_report, + should_defer_inline_history_prompt_input_to_text_system, should_dismiss_history_prompt_for_keystroke, should_dismiss_history_prompt_for_mouse, should_dismiss_history_prompt_for_scroll, should_reset_history_prompt_for_terminal_event, should_scroll_to_bottom_on_user_input, take_whole_scroll_lines, @@ -3858,16 +3857,20 @@ mod tests { } #[test] - fn alt_screen_scroll_arrow_maps_positive_lines_to_up() { - assert_eq!(alt_screen_scroll_arrow(1, false), Some("\x1b[A")); - assert_eq!(alt_screen_scroll_arrow(1, true), Some("\x1bOA")); + fn sgr_mouse_wheel_report_maps_positive_lines_to_wheel_up() { + assert_eq!( + sgr_mouse_wheel_report(1, 4, 2).as_deref(), + Some("\x1b[<64;5;3M") + ); } #[test] - fn alt_screen_scroll_arrow_maps_negative_lines_to_down() { - assert_eq!(alt_screen_scroll_arrow(-1, false), Some("\x1b[B")); - assert_eq!(alt_screen_scroll_arrow(-1, true), Some("\x1bOB")); - assert_eq!(alt_screen_scroll_arrow(0, false), None); + fn sgr_mouse_wheel_report_maps_negative_lines_to_wheel_down() { + assert_eq!( + sgr_mouse_wheel_report(-1, 4, 2).as_deref(), + Some("\x1b[<65;5;3M") + ); + assert_eq!(sgr_mouse_wheel_report(0, 4, 2), None); } #[test] From 47486bc93b0bc149b4e7c90fc54bcfb9b23fe566 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=83=A1=E9=A3=9E?= <1835698775@qq.com> Date: Sat, 9 May 2026 19:29:00 +0800 Subject: [PATCH 11/45] =?UTF-8?q?feat(ui):=20=E6=B7=BB=E5=8A=A0=20TitleBar?= =?UTF-8?q?=20=E7=BB=84=E4=BB=B6=E6=8F=90=E5=8D=87=E7=95=8C=E9=9D=A2?= =?UTF-8?q?=E7=BB=93=E6=9E=84=E4=B8=80=E8=87=B4=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 sql_dump_view、sql_run_view、table_export_view 和 table_import_view 中引入 TitleBar 组件 - 将视图内容包装在带有 TitleBar 的垂直布局容器中 - 调整布局高度以适应 TitleBar 的加入 - 优化界面整体视觉层次感和用户体验 --- crates/db_view/src/import_export/sql_dump_view.rs | 13 +++++++++---- crates/db_view/src/import_export/sql_run_view.rs | 13 +++++++++---- .../db_view/src/import_export/table_export_view.rs | 13 +++++++++---- .../db_view/src/import_export/table_import_view.rs | 13 +++++++++---- 4 files changed, 36 insertions(+), 16 deletions(-) diff --git a/crates/db_view/src/import_export/sql_dump_view.rs b/crates/db_view/src/import_export/sql_dump_view.rs index 3e78302e58..1bba7e851c 100644 --- a/crates/db_view/src/import_export/sql_dump_view.rs +++ b/crates/db_view/src/import_export/sql_dump_view.rs @@ -3,7 +3,7 @@ use gpui::{ IntoElement, ParentElement, Render, Styled, Window, div, prelude::FluentBuilder, px, }; use gpui_component::{ - ActiveTheme, VirtualListScrollHandle, + ActiveTheme, TitleBar, VirtualListScrollHandle, button::{Button, ButtonVariants as _}, h_flex, v_flex, v_virtual_list, }; @@ -506,12 +506,11 @@ impl Render for SqlDumpView { let elapsed = self.elapsed_time.read(cx).clone(); let logs = self.logs.read(cx).clone(); - v_flex() + let content = v_flex() .w_full() .h(px(450.0)) .gap_3() .p_4() - .pt_8() .child( v_flex() .gap_1() @@ -708,6 +707,12 @@ impl Render for SqlDumpView { }), ) }), - ) + ); + + v_flex() + .w_full() + .h(px(510.0)) + .child(TitleBar::new()) + .child(content) } } diff --git a/crates/db_view/src/import_export/sql_run_view.rs b/crates/db_view/src/import_export/sql_run_view.rs index f956d944b0..e478239b99 100644 --- a/crates/db_view/src/import_export/sql_run_view.rs +++ b/crates/db_view/src/import_export/sql_run_view.rs @@ -9,7 +9,7 @@ use gpui::{ prelude::FluentBuilder, px, }; use gpui_component::{ - ActiveTheme, Disableable, Sizable, VirtualListScrollHandle, + ActiveTheme, Disableable, Sizable, TitleBar, VirtualListScrollHandle, button::{Button, ButtonVariants as _}, h_flex, input::{Input, InputState}, @@ -453,12 +453,11 @@ impl Render for SqlRunView { let elapsed = self.elapsed_time.read(cx).clone(); let logs = self.logs.read(cx).clone(); - v_flex() + let content = v_flex() .w_full() .h(px(500.0)) .gap_3() .p_4() - .pt_8() .child( h_flex() .gap_2() @@ -687,6 +686,12 @@ impl Render for SqlRunView { }), ) }), - ) + ); + + v_flex() + .w_full() + .h(px(520.0)) + .child(TitleBar::new()) + .child(content) } } diff --git a/crates/db_view/src/import_export/table_export_view.rs b/crates/db_view/src/import_export/table_export_view.rs index 64bc01c8e5..1042ec262b 100644 --- a/crates/db_view/src/import_export/table_export_view.rs +++ b/crates/db_view/src/import_export/table_export_view.rs @@ -9,7 +9,7 @@ use gpui::{ px, }; use gpui_component::{ - ActiveTheme, Disableable, IconName, IndexPath, Sizable, VirtualListScrollHandle, + ActiveTheme, Disableable, IconName, IndexPath, Sizable, TitleBar, VirtualListScrollHandle, button::{Button, ButtonVariants as _}, checkbox::Checkbox, h_flex, @@ -876,12 +876,11 @@ impl Render for DataExportView { let logs = self.logs.read(cx).clone(); let current_step = self.current_step; - v_flex() + let content = v_flex() .w_full() .h(px(540.0)) .gap_2() .p_4() - .pt_8() .child( div() .text_sm() @@ -1351,6 +1350,12 @@ impl Render for DataExportView { }) ) }), - ) + ); + + v_flex() + .w_full() + .h(px(600.0)) + .child(TitleBar::new()) + .child(content) } } diff --git a/crates/db_view/src/import_export/table_import_view.rs b/crates/db_view/src/import_export/table_import_view.rs index 288aac4c69..912c54edad 100644 --- a/crates/db_view/src/import_export/table_import_view.rs +++ b/crates/db_view/src/import_export/table_import_view.rs @@ -7,7 +7,7 @@ use gpui::{ Window, div, prelude::FluentBuilder, px, }; use gpui_component::{ - ActiveTheme, Disableable, IconName, IndexPath, VirtualListScrollHandle, + ActiveTheme, Disableable, IconName, IndexPath, TitleBar, VirtualListScrollHandle, button::{Button, ButtonVariants as _}, h_flex, input::{Input, InputState}, @@ -792,12 +792,11 @@ impl Render for TableImportView { let current_step = self.current_step; let validation_error = self.validation_error.read(cx).clone(); - v_flex() + let content = v_flex() .w_full() .h(px(540.0)) .gap_3() .p_4() - .pt_8() .child( div() .text_sm() @@ -1371,6 +1370,12 @@ impl Render for TableImportView { }, )) }), - ) + ); + + v_flex() + .w_full() + .h(px(600.0)) + .child(TitleBar::new()) + .child(content) } } From 2d404e3ebe74ad9f11cd5f83b05076acc20c5273 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=83=A1=E9=A3=9E?= <1835698775@qq.com> Date: Sun, 10 May 2026 10:32:10 +0800 Subject: [PATCH 12/45] =?UTF-8?q?feat(remote=5Ffile=5Feditor):=20=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0=E6=94=AF=E6=8C=81=E5=A4=9A=E6=A0=87=E7=AD=BE=E7=9A=84?= =?UTF-8?q?=E8=BF=9C=E7=A8=8B=E6=96=87=E4=BB=B6=E7=BC=96=E8=BE=91=E5=99=A8?= =?UTF-8?q?=E7=AA=97=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增多标签页管理,支持打开、切换、关闭标签 - 按标签ID和远程路径唯一定位标签页 - 根据关闭的标签调整激活标签索引,实现正确聚焦 - 标签支持保存、加载、显示加载和保存状态 - 增加关闭未保存修改时的确认提示,支持保存或放弃关闭 - 完善标签软换行、语言模式、文件大小显示功能 - 实现关闭窗口时所有标签的未保存内容处理逻辑 - 主窗口只允许单实例,多次打开文件时复用现有窗口 - 优化窗口标题显示为当前激活标签名 - 本地加入关闭标签按钮,禁用正在保存的标签关闭操作 - 添加相关单元测试覆盖新功能和边界情况 - sftp模块排序相关代码使用sort_by_key简化提高可读性 - 语言配置文件新增“关闭页签”文本支持中文和英文 --- .../locales/remote_file_editor.yml | 4 + crates/remote_file_editor/src/close_guard.rs | 87 ++- .../remote_file_editor/src/editor_window.rs | 737 ++++++++++++++---- crates/remote_file_editor/src/lib.rs | 5 +- crates/sftp/src/russh_impl.rs | 6 +- 5 files changed, 679 insertions(+), 160 deletions(-) diff --git a/crates/remote_file_editor/locales/remote_file_editor.yml b/crates/remote_file_editor/locales/remote_file_editor.yml index 933185e79f..a3f1c3829b 100644 --- a/crates/remote_file_editor/locales/remote_file_editor.yml +++ b/crates/remote_file_editor/locales/remote_file_editor.yml @@ -26,6 +26,10 @@ RemoteFileEditor: en: Soft Wrap zh-CN: 自动换行 zh-HK: 自動換行 + close_tab: + en: Close Tab + zh-CN: 关闭页签 + zh-HK: 關閉頁籤 discard: en: Discard zh-CN: 放弃更改 diff --git a/crates/remote_file_editor/src/close_guard.rs b/crates/remote_file_editor/src/close_guard.rs index 0422298160..1afe57b8d0 100644 --- a/crates/remote_file_editor/src/close_guard.rs +++ b/crates/remote_file_editor/src/close_guard.rs @@ -15,9 +15,42 @@ pub fn decide_close_intercept(is_dirty: bool, prompt_open: bool) -> CloseInterce } } +pub fn find_tab_index(paths: &[String], remote_path: &str) -> Option { + paths.iter().position(|path| path == remote_path) +} + +pub fn active_index_after_open(paths: &[String], remote_path: &str) -> usize { + find_tab_index(paths, remote_path).unwrap_or(paths.len()) +} + +pub fn active_index_after_close( + active_index: usize, + closed_index: usize, + tab_count: usize, +) -> Option { + if tab_count <= 1 || closed_index >= tab_count { + return None; + } + + if closed_index < active_index { + Some(active_index - 1) + } else if closed_index == active_index && active_index >= tab_count - 1 { + Some(active_index - 1) + } else { + Some(active_index) + } +} + +pub fn has_dirty_tabs(dirty_tabs: &[bool]) -> bool { + dirty_tabs.iter().any(|dirty| *dirty) +} + #[cfg(test)] mod tests { - use super::{CloseIntercept, decide_close_intercept}; + use super::{ + CloseIntercept, active_index_after_close, active_index_after_open, decide_close_intercept, + find_tab_index, has_dirty_tabs, + }; #[test] fn allows_close_when_editor_is_clean() { @@ -33,4 +66,56 @@ mod tests { fn ignores_repeated_close_while_prompt_is_open() { assert_eq!(decide_close_intercept(true, true), CloseIntercept::Ignore); } + + #[test] + fn finds_existing_tab_index_by_remote_path() { + let paths = vec!["/tmp/a.txt".to_string(), "/tmp/b.txt".to_string()]; + + assert_eq!(find_tab_index(&paths, "/tmp/b.txt"), Some(1)); + } + + #[test] + fn returns_next_index_for_new_remote_path() { + let paths = vec!["/tmp/a.txt".to_string(), "/tmp/b.txt".to_string()]; + + assert_eq!(active_index_after_open(&paths, "/tmp/c.txt"), 2); + } + + #[test] + fn reuses_existing_index_for_existing_remote_path() { + let paths = vec!["/tmp/a.txt".to_string(), "/tmp/b.txt".to_string()]; + + assert_eq!(active_index_after_open(&paths, "/tmp/a.txt"), 0); + } + + #[test] + fn keeps_active_index_when_closing_tab_after_active_tab() { + assert_eq!(active_index_after_close(0, 2, 3), Some(0)); + } + + #[test] + fn shifts_active_index_left_when_closing_tab_before_active_tab() { + assert_eq!(active_index_after_close(2, 0, 3), Some(1)); + } + + #[test] + fn activates_left_tab_when_closing_last_active_tab() { + assert_eq!(active_index_after_close(2, 2, 3), Some(1)); + } + + #[test] + fn keeps_same_index_when_closing_middle_active_tab_with_right_neighbor() { + assert_eq!(active_index_after_close(1, 1, 3), Some(1)); + } + + #[test] + fn returns_none_when_closing_last_remaining_tab() { + assert_eq!(active_index_after_close(0, 0, 1), None); + } + + #[test] + fn detects_any_dirty_tab() { + assert!(has_dirty_tabs(&[false, true, false])); + assert!(!has_dirty_tabs(&[false, false])); + } } diff --git a/crates/remote_file_editor/src/editor_window.rs b/crates/remote_file_editor/src/editor_window.rs index 6942eb5a00..f6447e71cd 100644 --- a/crates/remote_file_editor/src/editor_window.rs +++ b/crates/remote_file_editor/src/editor_window.rs @@ -2,25 +2,29 @@ use crate::file_policy::{ EditorMode, FilePolicy, MAX_EDITABLE_FILE_SIZE, decode_text_content, determine_file_policy, }; use crate::language::language_for_path; -use crate::{CloseIntercept, decide_close_intercept}; +use crate::{ + CloseIntercept, active_index_after_close, active_index_after_open, decide_close_intercept, +}; use gpui::{ - App, AppContext, Bounds, Context, Entity, InteractiveElement as _, IntoElement, KeyBinding, - ParentElement, PromptLevel, Render, Size as GpuiSize, Styled, Window, WindowBounds, WindowKind, - WindowOptions, actions, div, px, size, + AnyWindowHandle, App, AppContext, Context, Entity, InteractiveElement as _, IntoElement, + KeyBinding, ParentElement, PromptLevel, Render, Styled, WeakEntity, Window, actions, div, px, }; use gpui_component::{ - ActiveTheme as _, Disableable as _, Root, Selectable as _, Sizable as _, Size, TitleBar, - WindowExt, + ActiveTheme as _, Disableable as _, Selectable as _, Sizable as _, Size, TitleBar, WindowExt, button::Button, h_flex, input::{Input, InputEvent, InputState, Search}, notification::Notification, + tab::{Tab, TabBar}, v_flex, }; -use one_core::gpui_tokio::Tokio; +use one_core::{ + gpui_tokio::Tokio, + popup_window::{PopupWindowOptions, open_popup_window}, +}; use rust_i18n::t; use sftp::{RusshSftpClient, SftpClient}; -use std::sync::{Arc, Once}; +use std::sync::{Arc, Mutex as StdMutex, Once, OnceLock}; use tokio::sync::Mutex; actions!(remote_file_editor, [OpenSearch, OpenReplace]); @@ -36,6 +40,13 @@ const REMOTE_EDITOR_REPLACE_SHORTCUT: &str = "cmd-r"; const REMOTE_EDITOR_REPLACE_SHORTCUT: &str = "ctrl-r"; static REMOTE_EDITOR_KEYBINDINGS_INIT: Once = Once::new(); +static REMOTE_EDITOR_WINDOW: OnceLock>> = OnceLock::new(); + +#[derive(Clone)] +struct RemoteEditorWindowRef { + window: AnyWindowHandle, + view: WeakEntity, +} pub fn open_remote_file_editor( remote_path: String, @@ -43,44 +54,28 @@ pub fn open_remote_file_editor( cx: &mut Context, ) { init_keybindings(cx); - let title = t!( - "RemoteFileEditor.title", - name = display_name_from_path(&remote_path) - ) - .to_string(); cx.spawn(async move |_this, cx| { - let title = title.clone(); let remote_path_for_log = remote_path.clone(); let result = cx.update(|cx| { - let mut window_size = size(px(960.0), px(720.0)); - if let Some(display) = cx.primary_display() { - let display_size = display.bounds().size; - window_size.width = window_size.width.min(display_size.width * 0.85); - window_size.height = window_size.height.min(display_size.height * 0.85); + if open_in_existing_window(remote_path.clone(), cx)? { + return Ok(()); } - let window_bounds = Bounds::centered(None, window_size, cx); - let window_opts = WindowOptions { - window_bounds: Some(WindowBounds::Windowed(window_bounds)), - titlebar: Some(TitleBar::title_bar_options()), - window_min_size: Some(GpuiSize { - width: px(640.0), - height: px(480.0), - }), - kind: WindowKind::Normal, - #[cfg(target_os = "linux")] - window_background: gpui::WindowBackgroundAppearance::Transparent, - #[cfg(target_os = "linux")] - window_decorations: Some(gpui::WindowDecorations::Client), - ..Default::default() - }; - cx.open_window(window_opts, move |window, cx| { - window.activate_window(); - window.set_window_title(&title); - let view = - cx.new(|cx| RemoteFileEditorWindow::new(remote_path, client, window, cx)); - cx.new(|cx| Root::new(view, window, cx)) - })?; + let title = editor_window_title(&remote_path); + open_popup_window( + PopupWindowOptions::new(title).size(960.0, 720.0).min_width(640.0).min_height(480.0), + move |window, cx| { + let view = cx.new(|cx| { + RemoteFileEditorWindow::new(remote_path, client, window, cx) + }); + set_editor_window(RemoteEditorWindowRef { + window: window.window_handle(), + view: view.downgrade(), + }); + view + }, + cx, + ); Ok::<_, anyhow::Error>(()) }); @@ -92,6 +87,50 @@ pub fn open_remote_file_editor( .detach(); } +fn open_in_existing_window(remote_path: String, cx: &mut App) -> anyhow::Result { + let Some(editor_window) = current_editor_window() else { + return Ok(false); + }; + + let result = cx.update_window(editor_window.window, |_, window, cx| { + window.activate_window(); + editor_window + .view + .update(cx, |this, cx| { + this.open_or_focus_tab(remote_path, window, cx); + }) + .is_ok() + }); + + match result { + Ok(true) => Ok(true), + Ok(false) | Err(_) => { + clear_editor_window(); + Ok(false) + } + } +} + +fn editor_window_slot() -> &'static StdMutex> { + REMOTE_EDITOR_WINDOW.get_or_init(|| StdMutex::new(None)) +} + +fn current_editor_window() -> Option { + editor_window_slot().lock().ok()?.clone() +} + +fn set_editor_window(window: RemoteEditorWindowRef) { + if let Ok(mut slot) = editor_window_slot().lock() { + *slot = Some(window); + } +} + +fn clear_editor_window() { + if let Ok(mut slot) = editor_window_slot().lock() { + *slot = None; + } +} + fn init_keybindings(cx: &mut App) { REMOTE_EDITOR_KEYBINDINGS_INIT.call_once(|| { cx.bind_keys([ @@ -117,6 +156,14 @@ fn replace_shortcut() -> &'static str { REMOTE_EDITOR_REPLACE_SHORTCUT } +fn editor_window_title(remote_path: &str) -> String { + t!( + "RemoteFileEditor.title", + name = display_name_from_path(remote_path) + ) + .to_string() +} + struct LoadedFile { text: String, policy: FilePolicy, @@ -124,10 +171,16 @@ struct LoadedFile { language: String, } -struct RemoteFileEditorWindow { +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum PendingCloseAction { + Window, + Tab(usize), +} + +struct RemoteEditorTab { + id: u64, remote_path: String, display_name: String, - client: Arc>, editor: Option>, subscriptions: Vec, saved_text: String, @@ -136,23 +189,16 @@ struct RemoteFileEditorWindow { loading: bool, saving: bool, soft_wrap: bool, - close_prompt_open: bool, - close_after_save: bool, status_message: String, load_error: Option, } -impl RemoteFileEditorWindow { - fn new( - remote_path: String, - client: Arc>, - window: &mut Window, - cx: &mut Context, - ) -> Self { - let mut this = Self { +impl RemoteEditorTab { + fn new(id: u64, remote_path: String) -> Self { + Self { + id, display_name: display_name_from_path(&remote_path), remote_path, - client, editor: None, subscriptions: Vec::new(), saved_text: String::new(), @@ -164,13 +210,54 @@ impl RemoteFileEditorWindow { loading: true, saving: false, soft_wrap: false, - close_prompt_open: false, - close_after_save: false, status_message: t!("RemoteFileEditor.status.loading").to_string(), load_error: None, + } + } + + fn is_dirty(&self, cx: &App) -> bool { + self.editor + .as_ref() + .map(|editor| editor.read(cx).text() != self.saved_text.as_str()) + .unwrap_or(false) + } + + fn policy_label(&self) -> String { + match self.policy.mode { + EditorMode::Code => t!("RemoteFileEditor.policy.code").to_string(), + EditorMode::PlainText => t!("RemoteFileEditor.policy.plain_text").to_string(), + } + } +} + +struct RemoteFileEditorWindow { + client: Arc>, + tabs: Vec, + active_tab: usize, + close_prompt_open: bool, + pending_close_action: Option, + close_window_after_saves: bool, + next_tab_id: u64, +} + +impl RemoteFileEditorWindow { + fn new( + remote_path: String, + client: Arc>, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let mut this = Self { + client, + tabs: Vec::new(), + active_tab: 0, + close_prompt_open: false, + pending_close_action: None, + close_window_after_saves: false, + next_tab_id: 1, }; this.register_close_guard(window, cx); - this.reload(window, cx); + this.open_or_focus_tab(remote_path, window, cx); this } @@ -182,25 +269,84 @@ impl RemoteFileEditorWindow { }); } + fn open_or_focus_tab( + &mut self, + remote_path: String, + window: &mut Window, + cx: &mut Context, + ) { + let paths = self.tab_paths(); + let active_index = active_index_after_open(&paths, &remote_path); + if active_index == self.tabs.len() { + let tab_id = self.next_tab_id; + self.next_tab_id += 1; + self.tabs.push(RemoteEditorTab::new(tab_id, remote_path)); + self.active_tab = active_index; + self.reload_tab(active_index, window, cx); + } else { + self.active_tab = active_index; + self.focus_editor(window, cx); + cx.notify(); + } + self.update_window_title(window); + } + + fn tab_paths(&self) -> Vec { + self.tabs + .iter() + .map(|tab| tab.remote_path.clone()) + .collect() + } + + fn tab_index_by_identity(&self, tab_id: u64, remote_path: &str) -> Option { + self.tabs + .iter() + .position(|tab| tab.id == tab_id && tab.remote_path == remote_path) + } + + fn active_tab(&self) -> Option<&RemoteEditorTab> { + self.tabs.get(self.active_tab) + } + + fn active_tab_mut(&mut self) -> Option<&mut RemoteEditorTab> { + self.tabs.get_mut(self.active_tab) + } + + fn update_window_title(&self, window: &mut Window) { + if let Some(tab) = self.active_tab() { + window.set_window_title(&editor_window_title(&tab.remote_path)); + } + } + fn reload(&mut self, window: &mut Window, cx: &mut Context) { - self.loading = true; - self.load_error = None; - self.status_message = t!("RemoteFileEditor.status.loading").to_string(); + self.reload_tab(self.active_tab, window, cx); + } + + fn reload_tab(&mut self, index: usize, window: &mut Window, cx: &mut Context) { + let Some(tab) = self.tabs.get_mut(index) else { + return; + }; + + tab.loading = true; + tab.load_error = None; + tab.status_message = t!("RemoteFileEditor.status.loading").to_string(); cx.notify(); - let remote_path = self.remote_path.clone(); + let tab_id = tab.id; + let remote_path = tab.remote_path.clone(); + let task_remote_path = remote_path.clone(); let client = self.client.clone(); let task = Tokio::spawn(cx, async move { let bytes = { let mut client = client.lock().await; client - .read_file(&remote_path, MAX_EDITABLE_FILE_SIZE) + .read_file(&task_remote_path, MAX_EDITABLE_FILE_SIZE) .await? }; let file_size = bytes.len(); let policy = determine_file_policy(file_size)?; let text = decode_text_content(&bytes)?; - let language = language_for_path(&remote_path, policy.is_large_file).to_string(); + let language = language_for_path(&task_remote_path, policy.is_large_file).to_string(); Ok::<_, anyhow::Error>(LoadedFile { text, policy, @@ -214,24 +360,20 @@ impl RemoteFileEditorWindow { .spawn(cx, async move |cx| match task.await { Ok(Ok(loaded)) => { let _ = view.update_in(cx, |this, window, cx| { - this.apply_loaded_file(loaded, window, cx); + this.apply_loaded_file(tab_id, &remote_path, loaded, window, cx); }); } Ok(Err(error)) => { let message = error.to_string(); let _ = view.update_in(cx, |this, window, cx| { - this.loading = false; - this.load_error = Some(message.clone()); - this.status_message = t!("RemoteFileEditor.status.load_failed").to_string(); + this.apply_load_error(tab_id, &remote_path, message.clone(), cx); window.push_notification(Notification::error(message), cx); }); } Err(error) => { let message = error.to_string(); let _ = view.update_in(cx, |this, window, cx| { - this.loading = false; - this.load_error = Some(message.clone()); - this.status_message = t!("RemoteFileEditor.status.load_failed").to_string(); + this.apply_load_error(tab_id, &remote_path, message.clone(), cx); window.push_notification(Notification::error(message), cx); }); } @@ -241,10 +383,18 @@ impl RemoteFileEditorWindow { fn apply_loaded_file( &mut self, + tab_id: u64, + remote_path: &str, loaded: LoadedFile, window: &mut Window, cx: &mut Context, ) { + let Some(index) = self.tab_index_by_identity(tab_id, remote_path) else { + return; + }; + let Some(tab) = self.tabs.get_mut(index) else { + return; + }; let LoadedFile { text, policy, @@ -253,7 +403,7 @@ impl RemoteFileEditorWindow { } = loaded; let initial_text = text.clone(); - let soft_wrap = self.soft_wrap; + let soft_wrap = tab.soft_wrap; let editor = cx.new(|cx| { let mut state = InputState::new(window, cx) .code_editor(language) @@ -264,8 +414,8 @@ impl RemoteFileEditorWindow { state }); - self.subscriptions.clear(); - self.subscriptions.push( + tab.subscriptions.clear(); + tab.subscriptions.push( cx.subscribe(&editor, |_this, _input, event: &InputEvent, cx| { if matches!(event, InputEvent::Change) { cx.notify(); @@ -273,18 +423,20 @@ impl RemoteFileEditorWindow { }), ); - editor.update(cx, |state: &mut InputState, cx| { - state.focus(window, cx); - }); + if index == self.active_tab { + editor.update(cx, |state: &mut InputState, cx| { + state.focus(window, cx); + }); + } - self.editor = Some(editor); - self.saved_text = text; - self.file_size = file_size; - self.policy = policy; - self.loading = false; - self.saving = false; - self.load_error = None; - self.status_message = if policy.is_large_file { + tab.editor = Some(editor); + tab.saved_text = text; + tab.file_size = file_size; + tab.policy = policy; + tab.loading = false; + tab.saving = false; + tab.load_error = None; + tab.status_message = if policy.is_large_file { t!("RemoteFileEditor.status.loaded_plain_text").to_string() } else { t!("RemoteFileEditor.status.loaded").to_string() @@ -292,30 +444,64 @@ impl RemoteFileEditorWindow { cx.notify(); } + fn apply_load_error( + &mut self, + tab_id: u64, + remote_path: &str, + message: String, + cx: &mut Context, + ) { + let Some(index) = self.tab_index_by_identity(tab_id, remote_path) else { + return; + }; + let Some(tab) = self.tabs.get_mut(index) else { + return; + }; + tab.loading = false; + tab.load_error = Some(message); + tab.status_message = t!("RemoteFileEditor.status.load_failed").to_string(); + cx.notify(); + } + fn save(&mut self, close_after_save: bool, window: &mut Window, cx: &mut Context) { - self.close_after_save |= close_after_save; - let Some(editor) = self.editor.clone() else { - if self.close_after_save { - self.close_after_save = false; - window.remove_window(); + self.save_tab(self.active_tab, close_after_save, window, cx); + } + + fn save_tab( + &mut self, + index: usize, + close_after_save: bool, + window: &mut Window, + cx: &mut Context, + ) { + let Some(tab) = self.tabs.get_mut(index) else { + return; + }; + let Some(editor) = tab.editor.clone() else { + if close_after_save { + self.close_clean_tab(index, window, cx); } return; }; - if self.saving { + if tab.saving { return; } let text = editor.read(cx).text().to_string(); - self.saving = true; - self.status_message = t!("RemoteFileEditor.status.saving").to_string(); + tab.saving = true; + tab.status_message = t!("RemoteFileEditor.status.saving").to_string(); cx.notify(); - let remote_path = self.remote_path.clone(); + let tab_id = tab.id; + let remote_path = tab.remote_path.clone(); + let task_remote_path = remote_path.clone(); let client = self.client.clone(); let task = Tokio::spawn(cx, async move { let mut client = client.lock().await; - client.write_file(&remote_path, text.as_bytes()).await?; + client + .write_file(&task_remote_path, text.as_bytes()) + .await?; Ok::<_, anyhow::Error>(text) }); @@ -324,40 +510,27 @@ impl RemoteFileEditorWindow { .spawn(cx, async move |cx| match task.await { Ok(Ok(saved_text)) => { let _ = view.update_in(cx, |this, window, cx| { - this.saved_text = saved_text; - this.file_size = this.saved_text.len(); - this.saving = false; - this.status_message = t!("RemoteFileEditor.status.saved").to_string(); - let close_after_save = this.close_after_save; - this.close_after_save = false; - if close_after_save { - window.remove_window(); - } else { - window.push_notification( - Notification::success( - t!("RemoteFileEditor.notification.saved").to_string(), - ), - cx, - ); - } - cx.notify(); + this.apply_saved_file( + tab_id, + &remote_path, + saved_text, + close_after_save, + window, + cx, + ); }); } Ok(Err(error)) => { let message = error.to_string(); let _ = view.update_in(cx, |this, window, cx| { - this.saving = false; - this.close_after_save = false; - this.status_message = t!("RemoteFileEditor.status.save_failed").to_string(); + this.apply_save_error(tab_id, &remote_path, message.clone(), cx); window.push_notification(Notification::error(message), cx); }); } Err(error) => { let message = error.to_string(); let _ = view.update_in(cx, |this, window, cx| { - this.saving = false; - this.close_after_save = false; - this.status_message = t!("RemoteFileEditor.status.save_failed").to_string(); + this.apply_save_error(tab_id, &remote_path, message.clone(), cx); window.push_notification(Notification::error(message), cx); }); } @@ -365,19 +538,170 @@ impl RemoteFileEditorWindow { .detach(); } + fn apply_saved_file( + &mut self, + tab_id: u64, + remote_path: &str, + saved_text: String, + close_after_save: bool, + window: &mut Window, + cx: &mut Context, + ) { + let Some(index) = self.tab_index_by_identity(tab_id, remote_path) else { + return; + }; + let Some(tab) = self.tabs.get_mut(index) else { + return; + }; + tab.saved_text = saved_text; + tab.file_size = tab.saved_text.len(); + tab.saving = false; + tab.status_message = t!("RemoteFileEditor.status.saved").to_string(); + + if self.close_window_after_saves && !self.has_dirty_tabs(cx) { + self.close_window_after_saves = false; + clear_editor_window(); + window.remove_window(); + } else if close_after_save { + self.close_clean_tab(index, window, cx); + } else { + window.push_notification( + Notification::success(t!("RemoteFileEditor.notification.saved").to_string()), + cx, + ); + cx.notify(); + } + } + + fn apply_save_error( + &mut self, + tab_id: u64, + remote_path: &str, + _message: String, + cx: &mut Context, + ) { + let Some(index) = self.tab_index_by_identity(tab_id, remote_path) else { + return; + }; + let Some(tab) = self.tabs.get_mut(index) else { + return; + }; + tab.saving = false; + tab.status_message = t!("RemoteFileEditor.status.save_failed").to_string(); + self.close_window_after_saves = false; + cx.notify(); + } + fn handle_window_should_close(&mut self, window: &mut Window, cx: &mut Context) -> bool { - match decide_close_intercept(self.is_dirty(cx), self.close_prompt_open) { - CloseIntercept::Allow => true, + match decide_close_intercept(self.has_dirty_tabs(cx), self.close_prompt_open) { + CloseIntercept::Allow => { + clear_editor_window(); + true + } CloseIntercept::Ignore => false, CloseIntercept::Prompt => { - self.show_unsaved_changes_prompt(window, cx); + if let Some(index) = self.first_dirty_tab(cx) { + self.active_tab = index; + self.update_window_title(window); + self.focus_editor(window, cx); + } + self.show_unsaved_changes_prompt(PendingCloseAction::Window, window, cx); false } } } - fn show_unsaved_changes_prompt(&mut self, window: &mut Window, cx: &mut Context) { + fn request_close_active_tab(&mut self, window: &mut Window, cx: &mut Context) { + self.request_close_tab(self.active_tab, window, cx); + } + + fn request_close_tab(&mut self, index: usize, window: &mut Window, cx: &mut Context) { + if index >= self.tabs.len() { + return; + } + + match decide_close_intercept(self.is_tab_dirty(index, cx), self.close_prompt_open) { + CloseIntercept::Allow => self.close_clean_tab(index, window, cx), + CloseIntercept::Ignore => {} + CloseIntercept::Prompt => { + self.show_unsaved_changes_prompt(PendingCloseAction::Tab(index), window, cx); + } + } + } + + fn close_clean_tab(&mut self, index: usize, window: &mut Window, cx: &mut Context) { + if index >= self.tabs.len() { + return; + } + + let next_active = active_index_after_close(self.active_tab, index, self.tabs.len()); + self.tabs.remove(index); + if let Some(next_active) = next_active { + self.active_tab = next_active; + self.update_window_title(window); + self.focus_editor(window, cx); + cx.notify(); + } else { + clear_editor_window(); + window.remove_window(); + } + } + + fn discard_close_action( + &mut self, + action: PendingCloseAction, + window: &mut Window, + cx: &mut Context, + ) { + match action { + PendingCloseAction::Window => { + clear_editor_window(); + window.remove_window(); + } + PendingCloseAction::Tab(index) => self.close_clean_tab(index, window, cx), + } + } + + fn save_close_action( + &mut self, + action: PendingCloseAction, + window: &mut Window, + cx: &mut Context, + ) { + match action { + PendingCloseAction::Window => self.save_dirty_tabs_and_close_window(window, cx), + PendingCloseAction::Tab(index) => self.save_tab(index, true, window, cx), + } + } + + fn save_dirty_tabs_and_close_window(&mut self, window: &mut Window, cx: &mut Context) { + let dirty_indexes = self + .tabs + .iter() + .enumerate() + .filter_map(|(index, tab)| tab.is_dirty(cx).then_some(index)) + .collect::>(); + + if dirty_indexes.is_empty() { + clear_editor_window(); + window.remove_window(); + return; + } + + self.close_window_after_saves = true; + for index in dirty_indexes { + self.save_tab(index, false, window, cx); + } + } + + fn show_unsaved_changes_prompt( + &mut self, + action: PendingCloseAction, + window: &mut Window, + cx: &mut Context, + ) { self.close_prompt_open = true; + self.pending_close_action = Some(action); let prompt_title = t!("RemoteFileEditor.prompt.unsaved_title").to_string(); let prompt_message = t!("RemoteFileEditor.prompt.unsaved_message").to_string(); let save_label = t!("RemoteFileEditor.action.save").to_string(); @@ -401,13 +725,12 @@ impl RemoteFileEditorWindow { let selection = answer.await.ok(); let _ = cx.update_window(window_handle, |_, window, cx| { let _ = this.update(cx, |this, cx| { + let action = this.pending_close_action.take(); this.close_prompt_open = false; - match selection { - Some(0) => this.save(true, window, cx), - Some(1) => window.remove_window(), - _ => { - this.close_after_save = false; - } + match (selection, action) { + (Some(0), Some(action)) => this.save_close_action(action, window, cx), + (Some(1), Some(action)) => this.discard_close_action(action, window, cx), + _ => {} } }); }); @@ -439,7 +762,7 @@ impl RemoteFileEditorWindow { } fn trigger_replace(&mut self, window: &mut Window, cx: &mut Context) { - let Some(editor) = self.editor.as_ref() else { + let Some(editor) = self.active_tab().and_then(|tab| tab.editor.as_ref()) else { return; }; @@ -449,7 +772,7 @@ impl RemoteFileEditorWindow { } fn focus_editor(&mut self, window: &mut Window, cx: &mut Context) { - let Some(editor) = self.editor.as_ref() else { + let Some(editor) = self.active_tab().and_then(|tab| tab.editor.as_ref()) else { return; }; @@ -459,25 +782,92 @@ impl RemoteFileEditorWindow { } fn toggle_soft_wrap(&mut self, window: &mut Window, cx: &mut Context) { - self.soft_wrap = !self.soft_wrap; - if let Some(editor) = self.editor.as_ref() { + let Some(tab) = self.active_tab_mut() else { + return; + }; + tab.soft_wrap = !tab.soft_wrap; + if let Some(editor) = tab.editor.as_ref() { editor.update(cx, |state, cx| { - state.set_soft_wrap(self.soft_wrap, window, cx); + state.set_soft_wrap(tab.soft_wrap, window, cx); }); } cx.notify(); } - fn is_dirty(&self, cx: &App) -> bool { - self.editor - .as_ref() - .map(|editor| editor.read(cx).text().to_string() != self.saved_text) + fn switch_tab(&mut self, index: usize, window: &mut Window, cx: &mut Context) { + if index >= self.tabs.len() || index == self.active_tab { + return; + } + + self.active_tab = index; + self.update_window_title(window); + self.focus_editor(window, cx); + cx.notify(); + } + + fn is_tab_dirty(&self, index: usize, cx: &App) -> bool { + self.tabs + .get(index) + .map(|tab| tab.is_dirty(cx)) .unwrap_or(false) } + fn has_dirty_tabs(&self, cx: &App) -> bool { + self.tabs.iter().any(|tab| tab.is_dirty(cx)) + } + + fn first_dirty_tab(&self, cx: &App) -> Option { + self.tabs.iter().position(|tab| tab.is_dirty(cx)) + } + + fn render_tabs(&self, cx: &mut Context) -> impl IntoElement { + let mut tab_bar = TabBar::new("remote-file-editor-tabs") + .menu(true) + .with_size(Size::Small) + .selected_index(self.active_tab) + .on_click({ + let view = cx.entity().clone(); + move |index, window, cx| { + let _ = view.update(cx, |this, cx| { + this.switch_tab(*index, window, cx); + }); + } + }); + + for (index, tab) in self.tabs.iter().enumerate() { + let label = if tab.is_dirty(cx) { + format!("* {}", tab.display_name) + } else { + tab.display_name.clone() + }; + tab_bar = tab_bar.child( + Tab::new().label(label).suffix( + Button::new(format!("remote-file-close-tab-{index}")) + .label("×") + .with_size(Size::XSmall) + .disabled(tab.saving) + .on_click(cx.listener(move |this, _, window, cx| { + this.request_close_tab(index, window, cx); + })), + ), + ); + } + + h_flex() + .border_b_1() + .border_color(cx.theme().border) + .bg(cx.theme().tab_bar) + .child(tab_bar) + } + fn render_toolbar(&self, cx: &mut Context) -> impl IntoElement { - let dirty = self.is_dirty(cx); - let disabled = self.loading || self.saving || self.editor.is_none(); + let tab = self.active_tab(); + let dirty = tab.map(|tab| tab.is_dirty(cx)).unwrap_or(false); + let disabled = tab + .map(|tab| tab.loading || tab.saving || tab.editor.is_none()) + .unwrap_or(true); + let loading_or_saving = tab.map(|tab| tab.loading || tab.saving).unwrap_or(true); + let soft_wrap = tab.map(|tab| tab.soft_wrap).unwrap_or(false); h_flex() .gap_2() @@ -518,7 +908,7 @@ impl RemoteFileEditorWindow { Button::new("remote-file-reload") .label(t!("RemoteFileEditor.action.reload")) .with_size(Size::Small) - .disabled(self.loading || self.saving) + .disabled(loading_or_saving) .on_click(cx.listener(|this, _, window, cx| { this.reload(window, cx); })), @@ -526,19 +916,28 @@ impl RemoteFileEditorWindow { .child( Button::new("remote-file-soft-wrap") .label(t!("RemoteFileEditor.action.soft_wrap")) - .selected(self.soft_wrap) + .selected(soft_wrap) .with_size(Size::Small) .disabled(disabled) .on_click(cx.listener(|this, _, window, cx| { this.toggle_soft_wrap(window, cx); })), ) + .child( + Button::new("remote-file-close-active-tab") + .label(t!("RemoteFileEditor.action.close_tab")) + .with_size(Size::Small) + .disabled(loading_or_saving || self.tabs.is_empty()) + .on_click(cx.listener(|this, _, window, cx| { + this.request_close_active_tab(window, cx); + })), + ) .child(div().flex_1()) .child( div() .text_sm() .text_color(cx.theme().muted_foreground) - .child(self.policy_label()), + .child(tab.map(RemoteEditorTab::policy_label).unwrap_or_default()), ) .child( div() @@ -557,6 +956,19 @@ impl RemoteFileEditorWindow { } fn render_status_bar(&self, cx: &mut Context) -> impl IntoElement { + let remote_path = self + .active_tab() + .map(|tab| tab.remote_path.clone()) + .unwrap_or_default(); + let file_size = self + .active_tab() + .map(|tab| tab.file_size) + .unwrap_or_default(); + let status_message = self + .active_tab() + .map(|tab| tab.status_message.clone()) + .unwrap_or_default(); + h_flex() .gap_2() .items_center() @@ -569,25 +981,29 @@ impl RemoteFileEditorWindow { div() .text_sm() .text_color(cx.theme().muted_foreground) - .child(self.remote_path.clone()), + .child(remote_path), ) .child(div().flex_1()) .child( div() .text_sm() .text_color(cx.theme().muted_foreground) - .child(format_size(self.file_size)), + .child(format_size(file_size)), ) .child( div() .text_sm() .text_color(cx.theme().muted_foreground) - .child(self.status_message.clone()), + .child(status_message), ) } fn render_body(&self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - if self.loading { + let Some(tab) = self.active_tab() else { + return v_flex().size_full().into_any_element(); + }; + + if tab.loading { return v_flex() .size_full() .items_center() @@ -596,7 +1012,7 @@ impl RemoteFileEditorWindow { .into_any_element(); } - if let Some(error) = self.load_error.as_ref() { + if let Some(error) = tab.load_error.as_ref() { return v_flex() .size_full() .items_center() @@ -617,7 +1033,7 @@ impl RemoteFileEditorWindow { .into_any_element(); } - match self.editor.as_ref() { + match tab.editor.as_ref() { Some(editor) => v_flex() .size_full() .child(Input::new(editor).size_full()) @@ -625,17 +1041,15 @@ impl RemoteFileEditorWindow { None => v_flex().size_full().into_any_element(), } } - - fn policy_label(&self) -> String { - match self.policy.mode { - EditorMode::Code => t!("RemoteFileEditor.policy.code").to_string(), - EditorMode::PlainText => t!("RemoteFileEditor.policy.plain_text").to_string(), - } - } } impl Render for RemoteFileEditorWindow { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let title = self + .active_tab() + .map(|tab| tab.display_name.clone()) + .unwrap_or_default(); + v_flex() .size_full() .key_context(REMOTE_FILE_EDITOR_CONTEXT) @@ -650,9 +1064,10 @@ impl Render for RemoteFileEditorWindow { .justify_center() .flex_1() .text_sm() - .child(self.display_name.clone()), + .child(title), ), ) + .child(self.render_tabs(cx)) .child(self.render_toolbar(cx)) .child(v_flex().flex_1().child(self.render_body(window, cx))) .child(self.render_status_bar(cx)) @@ -698,4 +1113,16 @@ mod tests { assert_eq!(search_shortcut(), EXPECTED_SEARCH_SHORTCUT); assert_eq!(replace_shortcut(), EXPECTED_REPLACE_SHORTCUT); } + + #[test] + fn display_name_ignores_trailing_slash() { + assert_eq!(display_name_from_path("/tmp/example/"), "example"); + } + + #[test] + fn format_size_uses_binary_units() { + assert_eq!(format_size(42), "42 B"); + assert_eq!(format_size(1024), "1.0 KiB"); + assert_eq!(format_size(1024 * 1024), "1.0 MiB"); + } } diff --git a/crates/remote_file_editor/src/lib.rs b/crates/remote_file_editor/src/lib.rs index e84ca7edf9..83c5b36367 100644 --- a/crates/remote_file_editor/src/lib.rs +++ b/crates/remote_file_editor/src/lib.rs @@ -9,7 +9,10 @@ mod language; #[cfg(feature = "ui")] pub use editor_window::open_remote_file_editor; -pub use close_guard::{CloseIntercept, decide_close_intercept}; +pub use close_guard::{ + CloseIntercept, active_index_after_close, active_index_after_open, decide_close_intercept, + find_tab_index, has_dirty_tabs, +}; pub use file_policy::{ EditorMode, FilePolicy, LARGE_FILE_PLAIN_TEXT_THRESHOLD, MAX_EDITABLE_FILE_SIZE, decode_text_content, determine_file_policy, diff --git a/crates/sftp/src/russh_impl.rs b/crates/sftp/src/russh_impl.rs index c639d06f4b..85ba333337 100644 --- a/crates/sftp/src/russh_impl.rs +++ b/crates/sftp/src/russh_impl.rs @@ -980,7 +980,7 @@ impl SftpClient for RusshSftpClient { // 按路径深度倒序删除目录(先删子目录) let mut dirs: Vec<&FileEntry> = entries.iter().filter(|e| e.is_dir).collect(); - dirs.sort_by(|a, b| b.path.len().cmp(&a.path.len())); + dirs.sort_by_key(|dir| std::cmp::Reverse(dir.path.len())); for dir in dirs { ensure_not_cancelled(&cancelled)?; progress(TransferProgress { @@ -1177,7 +1177,7 @@ impl SftpClient for RusshSftpClient { .map_err(|e| anyhow!("Failed to create local directory {}: {}", local_path, e))?; let mut dirs: Vec<&FileEntry> = entries.iter().filter(|e| e.is_dir).collect(); - dirs.sort_by(|a, b| a.path.len().cmp(&b.path.len())); + dirs.sort_by_key(|dir| dir.path.len()); for dir_entry in dirs { ensure_not_cancelled(&cancelled)?; let relative = dir_entry @@ -1380,7 +1380,7 @@ impl SftpClient for RusshSftpClient { let _ = self.sftp.create_dir(remote_path).await; let mut dirs: Vec<_> = entries.iter().filter(|(_, is_dir, _)| *is_dir).collect(); - dirs.sort_by(|a, b| a.0.as_os_str().len().cmp(&b.0.as_os_str().len())); + dirs.sort_by_key(|dir| dir.0.as_os_str().len()); for (dir_path, _, _) in dirs { ensure_not_cancelled(&cancelled)?; From 938b1557fcc089fbe9a08396e337f23807f16a03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=83=A1=E9=A3=9E?= <1835698775@qq.com> Date: Sun, 10 May 2026 10:35:17 +0800 Subject: [PATCH 13/45] =?UTF-8?q?faet=EF=BC=9A=E5=88=A0=E9=99=A4=E6=97=A0?= =?UTF-8?q?=E7=94=A8=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main/src/home/home_new_connection.rs | 225 --------------------------- 1 file changed, 225 deletions(-) delete mode 100644 main/src/home/home_new_connection.rs diff --git a/main/src/home/home_new_connection.rs b/main/src/home/home_new_connection.rs deleted file mode 100644 index 0b2db2f63a..0000000000 --- a/main/src/home/home_new_connection.rs +++ /dev/null @@ -1,225 +0,0 @@ -use crate::home_tab::HomePage; -use gpui::{App, Context, Entity, ParentElement, SharedString, Styled, Task, Window, div, px}; -use gpui_component::{ - ActiveTheme, IndexPath, WindowExt, h_flex, - list::{ListDelegate, ListItem, ListState}, -}; -use one_core::storage::DatabaseType; -use rust_i18n::t; - -/// 新建连接对话框中的连接类型选项 -#[derive(Clone)] -enum NewConnectionKind { - Workspace, - Ssh, - Terminal, - Redis, - MongoDB, - Serial, - Database(DatabaseType), -} - -impl NewConnectionKind { - fn label(&self) -> String { - match self { - NewConnectionKind::Workspace => t!("Workspace.label").to_string(), - NewConnectionKind::Ssh => "SSH".to_string(), - NewConnectionKind::Terminal => "Terminal".to_string(), - NewConnectionKind::Redis => "Redis".to_string(), - NewConnectionKind::MongoDB => "MongoDB".to_string(), - NewConnectionKind::Serial => t!("Serial.new").to_string(), - NewConnectionKind::Database(db_type) => db_type.as_str().to_string(), - } - } - - fn category(&self) -> &'static str { - match self { - NewConnectionKind::Workspace => "工作区", - NewConnectionKind::Ssh | NewConnectionKind::Terminal | NewConnectionKind::Serial => { - "终端" - } - NewConnectionKind::Redis | NewConnectionKind::MongoDB => "NoSQL", - NewConnectionKind::Database(_) => "数据库", - } - } - - /// 在 HomePage 上执行对应的操作 - fn execute(&self, home: &mut HomePage, window: &mut Window, cx: &mut Context) { - match self { - NewConnectionKind::Workspace => { - home.show_workspace_form(None, window, cx); - } - NewConnectionKind::Ssh => { - home.editing_connection_id = None; - home.show_ssh_form(window, cx); - } - NewConnectionKind::Terminal => { - home.add_terminal_tab(window, cx); - } - NewConnectionKind::Redis => { - home.editing_connection_id = None; - home.show_redis_form(window, cx); - } - NewConnectionKind::MongoDB => { - home.editing_connection_id = None; - home.show_mongodb_form(window, cx); - } - NewConnectionKind::Serial => { - home.editing_connection_id = None; - home.show_serial_form(window, cx); - } - NewConnectionKind::Database(db_type) => { - home.editing_connection_id = None; - home.show_connection_form(*db_type, window, cx); - } - } - } -} - -pub(crate) struct NewConnectionDelegate { - parent: Entity, - items: Vec, - filtered_items: Vec, - selected_index: Option, - search_query: String, -} - -impl NewConnectionDelegate { - pub(crate) fn new(parent: Entity) -> Self { - let mut items = vec![ - NewConnectionKind::Workspace, - NewConnectionKind::Ssh, - NewConnectionKind::Terminal, - NewConnectionKind::Redis, - NewConnectionKind::MongoDB, - NewConnectionKind::Serial, - ]; - - for db_type in DatabaseType::all() { - items.push(NewConnectionKind::Database(*db_type)); - } - - let filtered_items = items.clone(); - - Self { - parent, - items, - filtered_items, - selected_index: None, - search_query: String::new(), - } - } - - fn apply_filter(&mut self) { - if self.search_query.is_empty() { - self.filtered_items = self.items.clone(); - return; - } - let query = self.search_query.to_lowercase(); - self.filtered_items = self - .items - .iter() - .filter(|kind| { - kind.label().to_lowercase().contains(&query) - || kind.category().to_lowercase().contains(&query) - }) - .cloned() - .collect(); - } -} - -impl ListDelegate for NewConnectionDelegate { - type Item = ListItem; - - fn perform_search( - &mut self, - query: &str, - _window: &mut Window, - cx: &mut Context>, - ) -> Task<()> { - self.search_query = query.to_string(); - self.apply_filter(); - cx.notify(); - Task::ready(()) - } - - fn items_count(&self, _section: usize, _cx: &App) -> usize { - self.filtered_items.len() - } - - fn render_item( - &mut self, - ix: IndexPath, - _window: &mut Window, - cx: &mut Context>, - ) -> Option { - let kind = self.filtered_items.get(ix.row)?.clone(); - let parent = self.parent.clone(); - let label = kind.label(); - let category = kind.category(); - - Some( - ListItem::new(ix) - .px_3() - .py_2() - .rounded(px(6.0)) - .on_click(move |_, window, cx| { - parent.update(cx, |this, cx| { - kind.execute(this, window, cx); - }); - window.close_dialog(cx); - }) - .child( - h_flex() - .w_full() - .items_center() - .gap_2() - .child( - div() - .flex_1() - .min_w_0() - .text_sm() - .text_ellipsis() - .whitespace_nowrap() - .child(SharedString::from(label)), - ) - .child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(SharedString::from(category)), - ), - ), - ) - } - - fn set_selected_index( - &mut self, - ix: Option, - _window: &mut Window, - _cx: &mut Context>, - ) { - self.selected_index = ix; - } - - fn confirm( - &mut self, - _secondary: bool, - window: &mut Window, - cx: &mut Context>, - ) { - if let Some(ix) = self.selected_index { - if let Some(kind) = self.filtered_items.get(ix.row).cloned() { - let parent = self.parent.clone(); - parent.update(cx, |this, cx| { - kind.execute(this, window, cx); - }); - window.close_dialog(cx); - } - } - } - - fn cancel(&mut self, window: &mut Window, cx: &mut Context>) { - window.close_dialog(cx); - } -} From d0e858e45fb129715605a538d4d12939ba21d12c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=83=A1=E9=A3=9E?= <1835698775@qq.com> Date: Mon, 11 May 2026 11:22:07 +0800 Subject: [PATCH 14/45] =?UTF-8?q?feat(terminal):=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E7=BB=88=E7=AB=AF=E4=BA=8B=E4=BB=B6=E8=BD=AC=E5=8F=91=E5=92=8C?= =?UTF-8?q?=E5=9D=97=E5=AD=97=E7=AC=A6=E6=B8=B2=E6=9F=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 GpuiEventProxy 中添加窗口尺寸同步和 Wakeup 事件去重逻辑,防止事件堆积 - 实现默认颜色回复,避免 OSC 颜色请求总返回黑色 - 终端事件循环支持 Wakeup 去重标记重置,避免高频输出时阻塞事件转发 - 增加多项终端事件去重单元测试,保证 Wakeup 行为正确 - 新增块状字符 (U+2580..U+259F) 的几何绘制支持,避免字体回退时出现渲染接缝 - RenderCache 新增块字符缓存,重构重建逻辑以支持块字符几何渲染 - 终端渲染阶段绘制块字符几何路径,提高渲染质量和字体兼容性 - 终端视图添加多项块字符几何绘制单元测试,确保坐标计算正确 - 优化按键映射单元测试,覆盖常用键及修饰键序列生成 - 终端视图中新增 SGR 鼠标按钮事件生成及回报,完善鼠标按钮编码和修饰符支持 - 终端主流程改用 wakeup_pending 标记,避免重复 Wakeup 事件引发的性能问题 - 调整窗口尺寸更新接口,确保所有相关模块共享正确终端尺寸信息 --- crates/terminal/src/pty_backend.rs | 195 ++++++++-- crates/terminal/src/terminal.rs | 24 +- crates/terminal_view/src/keys.rs | 156 ++++++++ crates/terminal_view/src/terminal_element.rs | 353 ++++++++++++++++--- crates/terminal_view/src/view.rs | 168 ++++++++- 5 files changed, 814 insertions(+), 82 deletions(-) diff --git a/crates/terminal/src/pty_backend.rs b/crates/terminal/src/pty_backend.rs index d52e9ddcd0..aa3c6d380c 100644 --- a/crates/terminal/src/pty_backend.rs +++ b/crates/terminal/src/pty_backend.rs @@ -3,8 +3,10 @@ use alacritty_terminal::event_loop::{EventLoop, EventLoopSender, Msg}; use alacritty_terminal::sync::FairMutex; use alacritty_terminal::term::{ClipboardType, Term}; use alacritty_terminal::tty::{self, Options as PtyOptions}; +use alacritty_terminal::vte::ansi::{NamedColor, Rgb}; use std::borrow::Cow; -use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; use std::thread; use std::thread::JoinHandle; use tokio::sync::mpsc::UnboundedSender; @@ -80,6 +82,7 @@ impl PtyWriteBack { /// 3. Sends Wakeup event via EventListener pub struct LocalPtyBackend { event_loop_sender: EventLoopSender, + event_proxy: GpuiEventProxy, _event_loop_handle: JoinHandle<()>, } @@ -110,6 +113,7 @@ impl LocalPtyBackend { // 设置 PtyWrite 回写通道,使 DA 等终端响应能写回 PTY event_proxy.set_write_back(PtyWriteBack::Local(event_loop_sender.clone())); + event_proxy.set_window_size(window_size); let handle = thread::spawn(move || { let _ = event_loop.spawn().join(); @@ -117,6 +121,7 @@ impl LocalPtyBackend { Ok(Self { event_loop_sender, + event_proxy, _event_loop_handle: handle, }) } @@ -149,6 +154,7 @@ impl LocalPtyBackend { size.pixel_width, size.pixel_height ); + self.event_proxy.set_window_size(window_size); let _ = self.event_loop_sender.send(Msg::Resize(window_size)); } @@ -163,21 +169,7 @@ impl TerminalBackend for LocalPtyBackend { } fn resize(&self, size: TerminalSize) { - let window_size = WindowSize { - num_lines: size.rows, - num_cols: size.cols, - cell_width: if size.cols > 0 { - size.pixel_width / size.cols - } else { - 8 - }, - cell_height: if size.rows > 0 { - size.pixel_height / size.rows - } else { - 18 - }, - }; - let _ = self.event_loop_sender.send(Msg::Resize(window_size)); + LocalPtyBackend::resize(self, size); } fn shutdown(&self) { @@ -192,14 +184,25 @@ impl TerminalBackend for LocalPtyBackend { pub struct GpuiEventProxy { event_tx: UnboundedSender, /// PtyWrite 回写通道(在后端创建后设置) - write_back: Arc>>, + write_back: Arc>>, + /// 共享窗口尺寸,供 TextAreaSizeRequest 真实回复使用 + window_size: Arc>, + /// Wakeup 去重标记:true 表示已有未消费的 Wakeup 在事件队列里 + wakeup_pending: Arc, } impl GpuiEventProxy { pub fn new(event_tx: UnboundedSender) -> Self { Self { event_tx, - write_back: Arc::new(std::sync::Mutex::new(None)), + write_back: Arc::new(Mutex::new(None)), + window_size: Arc::new(Mutex::new(WindowSize { + num_lines: 24, + num_cols: 80, + cell_width: 8, + cell_height: 18, + })), + wakeup_pending: Arc::new(AtomicBool::new(false)), } } @@ -213,6 +216,26 @@ impl GpuiEventProxy { self.set_write_back(PtyWriteBack::Ssh(sender)); } + /// 同步当前真实窗口尺寸(含 cell 像素),后续 TextAreaSizeRequest 将以此回复 + pub(crate) fn set_window_size(&self, size: WindowSize) { + *self.window_size.lock().unwrap() = size; + } + + /// 当 UI 已经消费 Wakeup 后调用,允许下一次 Wakeup 入队 + pub fn reset_wakeup_pending(&self) { + self.wakeup_pending.store(false, Ordering::Release); + } + + /// 返回 Wakeup 去重标记的句柄,便于事件聚合任务在转发 Wakeup 后立即 reset, + /// 让下一次 PTY 输出能继续触发 Wakeup + pub fn wakeup_pending_handle(&self) -> Arc { + self.wakeup_pending.clone() + } + + fn current_window_size(&self) -> WindowSize { + *self.window_size.lock().unwrap() + } + fn write_back(&self, data: Vec) { if let Some(wb) = self.write_back.lock().unwrap().as_ref() { wb.write(data); @@ -227,22 +250,23 @@ impl EventListener for GpuiEventProxy { self.write_back(text.into_bytes()); return; } - AlacTermEvent::ColorRequest(_index, format_fn) => { - let text = format_fn(alacritty_terminal::vte::ansi::Rgb { r: 0, g: 0, b: 0 }); + AlacTermEvent::ColorRequest(index, format_fn) => { + let text = format_fn(default_color_for_index(index)); self.write_back(text.into_bytes()); return; } AlacTermEvent::TextAreaSizeRequest(format_fn) => { - let text = format_fn(WindowSize { - num_lines: 24, - num_cols: 80, - cell_width: 8, - cell_height: 18, - }); + let text = format_fn(self.current_window_size()); self.write_back(text.into_bytes()); return; } - AlacTermEvent::Wakeup => TerminalEvent::Wakeup, + AlacTermEvent::Wakeup => { + // 去重:已有未消费 Wakeup 时直接丢弃,避免高速输出下事件堆积 + if self.wakeup_pending.swap(true, Ordering::AcqRel) { + return; + } + TerminalEvent::Wakeup + } AlacTermEvent::Title(title) => TerminalEvent::TitleChanged(title), AlacTermEvent::Bell => TerminalEvent::Bell, AlacTermEvent::ClipboardStore(ty, data) => TerminalEvent::ClipboardStore(ty, data), @@ -253,3 +277,120 @@ impl EventListener for GpuiEventProxy { let _ = self.event_tx.send(terminal_event); } } + +/// 为 OSC 4/10/11 等颜色查询提供合理的默认回复,避免一律返回黑色 +fn default_color_for_index(index: usize) -> Rgb { + match index { + // OSC 10:默认前景色 -> 接近白色 + idx if idx == NamedColor::Foreground as usize => Rgb { + r: 0xE4, + g: 0xE4, + b: 0xE4, + }, + // OSC 11:默认背景色 -> 接近深灰 + idx if idx == NamedColor::Background as usize => Rgb { + r: 0x1E, + g: 0x1E, + b: 0x1E, + }, + // OSC 12:光标颜色 + idx if idx == NamedColor::Cursor as usize => Rgb { + r: 0xFF, + g: 0xFF, + b: 0xFF, + }, + _ => Rgb { r: 0, g: 0, b: 0 }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + use tokio::sync::mpsc::unbounded_channel; + + #[test] + fn wakeup_dedup_collapses_repeated_wakeups_until_reset() { + let (tx, mut rx) = unbounded_channel::(); + let proxy = GpuiEventProxy::new(tx); + + proxy.send_event(AlacTermEvent::Wakeup); + proxy.send_event(AlacTermEvent::Wakeup); + proxy.send_event(AlacTermEvent::Wakeup); + + // 多次 Wakeup 只入队一次 + let first = rx.try_recv(); + assert!(matches!(first, Ok(TerminalEvent::Wakeup))); + assert!(rx.try_recv().is_err()); + + // reset 后允许新一轮 Wakeup 入队 + proxy.reset_wakeup_pending(); + proxy.send_event(AlacTermEvent::Wakeup); + let next = rx.try_recv(); + assert!(matches!(next, Ok(TerminalEvent::Wakeup))); + } + + #[test] + fn non_wakeup_events_are_not_swallowed_by_dedup() { + let (tx, mut rx) = unbounded_channel::(); + let proxy = GpuiEventProxy::new(tx); + + // 先压一个 Wakeup 进去拉起去重标记 + proxy.send_event(AlacTermEvent::Wakeup); + // 期间发生 Title/Bell/Exit 等事件,不应被去重逻辑吞掉 + proxy.send_event(AlacTermEvent::Title("shell".to_string())); + proxy.send_event(AlacTermEvent::Bell); + proxy.send_event(AlacTermEvent::Exit); + + let mut got = Vec::new(); + while let Ok(ev) = rx.try_recv() { + got.push(ev); + } + assert_eq!(got.len(), 4); + assert!(matches!(got[0], TerminalEvent::Wakeup)); + assert!(matches!(got[1], TerminalEvent::TitleChanged(ref t) if t == "shell")); + assert!(matches!(got[2], TerminalEvent::Bell)); + assert!(matches!(got[3], TerminalEvent::ChildExit(0))); + } + + #[test] + fn text_area_size_request_uses_current_window_size() { + let (tx, _rx) = unbounded_channel::(); + let proxy = GpuiEventProxy::new(tx); + + // 注入一个回写通道收集 reply 字节 + let captured: Arc>> = Arc::new(Mutex::new(Vec::new())); + let (write_tx, mut write_rx) = unbounded_channel::>(); + proxy.set_ssh_write_back(write_tx); + + proxy.set_window_size(WindowSize { + num_lines: 40, + num_cols: 132, + cell_width: 9, + cell_height: 20, + }); + + proxy.send_event(AlacTermEvent::TextAreaSizeRequest(std::sync::Arc::new( + |size| format!("{}x{}", size.num_cols, size.num_lines), + ))); + + if let Ok(bytes) = write_rx.try_recv() { + captured.lock().unwrap().extend_from_slice(&bytes); + } + let reply = String::from_utf8(captured.lock().unwrap().clone()).unwrap(); + assert_eq!(reply, "132x40"); + } + + #[test] + fn color_request_returns_named_defaults_instead_of_black() { + let fg = default_color_for_index(NamedColor::Foreground as usize); + let bg = default_color_for_index(NamedColor::Background as usize); + let cursor = default_color_for_index(NamedColor::Cursor as usize); + let other = default_color_for_index(NamedColor::Red as usize); + + assert_ne!((fg.r, fg.g, fg.b), (0, 0, 0)); + assert_ne!((bg.r, bg.g, bg.b), (0, 0, 0)); + assert_eq!((cursor.r, cursor.g, cursor.b), (0xFF, 0xFF, 0xFF)); + assert_eq!((other.r, other.g, other.b), (0, 0, 0)); + } +} diff --git a/crates/terminal/src/terminal.rs b/crates/terminal/src/terminal.rs index fa9acc6351..76c095d0ec 100644 --- a/crates/terminal/src/terminal.rs +++ b/crates/terminal/src/terminal.rs @@ -24,6 +24,7 @@ use one_core::storage::models::{ use std::collections::VecDeque; use std::fs; use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex as StdMutex}; use std::time::Duration; use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender, unbounded_channel}; @@ -732,10 +733,10 @@ impl TerminalScrollProxy { impl Terminal { fn new_local_disconnected(error: String, cx: &mut Context) -> Self { let (event_tx, event_rx) = unbounded_channel::(); - let (term, _event_proxy, _colors) = + let (term, event_proxy, _colors) = Self::create_term(DEFAULT_COLS, DEFAULT_ROWS, event_tx.clone()); - Self::spawn_event_loop(event_rx, cx); + Self::spawn_event_loop(event_rx, event_proxy.wakeup_pending_handle(), cx); Self { term, @@ -805,9 +806,9 @@ impl Terminal { #[cfg(target_os = "windows")] escape_args: true, }; - let local_backend = LocalPtyBackend::new(term.clone(), event_proxy, pty_options)?; + let local_backend = LocalPtyBackend::new(term.clone(), event_proxy.clone(), pty_options)?; - Self::spawn_event_loop(event_rx, cx); + Self::spawn_event_loop(event_rx, event_proxy.wakeup_pending_handle(), cx); Self::spawn_local_history_loader(history_shell.as_deref(), cx); Ok(Self { @@ -945,7 +946,7 @@ impl Terminal { let connection_generation = 1; Self::spawn_disconnect_handler(disconnect_rx, connection_generation, cx); - Self::spawn_event_loop(event_rx, cx); + Self::spawn_event_loop(event_rx, event_proxy.wakeup_pending_handle(), cx); Self::spawn_ssh_connect( ssh_session_manager.clone(), config.clone(), @@ -992,13 +993,13 @@ impl Terminal { .expect("StoredConnection 应包含有效的 SerialParams"); let (event_tx, event_rx) = unbounded_channel::(); - let (term, _event_proxy, _colors) = + let (term, event_proxy, _colors) = Self::create_term(DEFAULT_COLS, DEFAULT_ROWS, event_tx.clone()); let (disconnect_tx, disconnect_rx) = tokio::sync::oneshot::channel::<()>(); let connection_generation = 1; Self::spawn_disconnect_handler(disconnect_rx, connection_generation, cx); - Self::spawn_event_loop(event_rx, cx); + Self::spawn_event_loop(event_rx, event_proxy.wakeup_pending_handle(), cx); Self::spawn_serial_connect( serial_params.clone(), term.clone(), @@ -1090,7 +1091,11 @@ impl Terminal { .detach(); } - fn spawn_event_loop(mut event_rx: UnboundedReceiver, cx: &mut Context) { + fn spawn_event_loop( + mut event_rx: UnboundedReceiver, + wakeup_pending: Arc, + cx: &mut Context, + ) { let _entity = cx.entity().downgrade(); let (render_tx, mut render_rx) = futures::channel::mpsc::unbounded::(); @@ -1123,6 +1128,9 @@ impl Terminal { // 最后发送 Wakeup if pending_wakeup { pending_wakeup = false; + // 转发完毕后允许 alacritty 线程的下一次 Wakeup 重新入队, + // 避免高速输出时被 GpuiEventProxy 的去重永久吞掉 + wakeup_pending.store(false, Ordering::Release); if render_tx.unbounded_send(TerminalEvent::Wakeup).is_err() { return; } diff --git a/crates/terminal_view/src/keys.rs b/crates/terminal_view/src/keys.rs index 3453478b88..fe39cd9bbf 100644 --- a/crates/terminal_view/src/keys.rs +++ b/crates/terminal_view/src/keys.rs @@ -331,4 +331,160 @@ mod tests { "\x1ba" ); } + + #[test] + fn enter_emits_carriage_return() { + let enter = Keystroke::parse("enter").unwrap(); + assert_eq!( + to_esc_str(&enter, &TermMode::NONE, false).unwrap().as_ref(), + "\x0d" + ); + } + + #[test] + fn backspace_emits_del_by_default() { + let bs = Keystroke::parse("backspace").unwrap(); + assert_eq!( + to_esc_str(&bs, &TermMode::NONE, false).unwrap().as_ref(), + "\x7f" + ); + } + + #[test] + fn ctrl_backspace_emits_bs() { + let bs = Keystroke::parse("ctrl-backspace").unwrap(); + assert_eq!( + to_esc_str(&bs, &TermMode::NONE, false).unwrap().as_ref(), + "\x08" + ); + } + + #[test] + fn shift_tab_emits_csi_z() { + let shift_tab = Keystroke::parse("shift-tab").unwrap(); + assert_eq!( + to_esc_str(&shift_tab, &TermMode::NONE, false) + .unwrap() + .as_ref(), + "\x1b[Z" + ); + } + + #[test] + fn home_app_cursor_mode_emits_ss3() { + let home = Keystroke::parse("home").unwrap(); + assert_eq!( + to_esc_str(&home, &TermMode::NONE, false).unwrap().as_ref(), + "\x1b[H" + ); + assert_eq!( + to_esc_str(&home, &TermMode::APP_CURSOR, false) + .unwrap() + .as_ref(), + "\x1bOH" + ); + } + + #[test] + fn page_up_down_emit_csi_tilde() { + let pageup = Keystroke::parse("pageup").unwrap(); + let pagedown = Keystroke::parse("pagedown").unwrap(); + assert_eq!( + to_esc_str(&pageup, &TermMode::NONE, false) + .unwrap() + .as_ref(), + "\x1b[5~" + ); + assert_eq!( + to_esc_str(&pagedown, &TermMode::NONE, false) + .unwrap() + .as_ref(), + "\x1b[6~" + ); + } + + #[test] + fn insert_delete_emit_csi_tilde() { + let ins = Keystroke::parse("insert").unwrap(); + let del = Keystroke::parse("delete").unwrap(); + assert_eq!( + to_esc_str(&ins, &TermMode::NONE, false).unwrap().as_ref(), + "\x1b[2~" + ); + assert_eq!( + to_esc_str(&del, &TermMode::NONE, false).unwrap().as_ref(), + "\x1b[3~" + ); + } + + #[test] + fn ctrl_letter_covers_full_alphabet() { + // Ctrl-A => 0x01, Ctrl-Z => 0x1a + for (key, expected) in [("ctrl-a", 0x01u8), ("ctrl-m", 0x0d), ("ctrl-z", 0x1a)] { + let ks = Keystroke::parse(key).unwrap(); + let seq = to_esc_str(&ks, &TermMode::NONE, false).unwrap(); + assert_eq!(seq.as_ref().as_bytes(), &[expected], "{key}"); + } + } + + #[test] + fn ctrl_bracket_and_underscore_emit_c0() { + assert_eq!( + to_esc_str(&Keystroke::parse("ctrl-[").unwrap(), &TermMode::NONE, false) + .unwrap() + .as_ref() + .as_bytes(), + b"\x1b" + ); + assert_eq!( + to_esc_str(&Keystroke::parse("ctrl-_").unwrap(), &TermMode::NONE, false) + .unwrap() + .as_ref() + .as_bytes(), + b"\x1f" + ); + } + + #[test] + fn ctrl_space_emits_nul() { + let ks = Keystroke::parse("ctrl-space").unwrap(); + assert_eq!( + to_esc_str(&ks, &TermMode::NONE, false) + .unwrap() + .as_ref() + .as_bytes(), + b"\x00" + ); + } + + #[test] + fn shift_arrow_in_alt_screen_remains_none_in_normal_mode() { + // 锁定当前行为:normal screen 下 shift-arrow 不发送修饰序列 + let shift_up = Keystroke::parse("shift-up").unwrap(); + assert_eq!(to_esc_str(&shift_up, &TermMode::NONE, false), None); + } + + #[test] + fn ctrl_arrow_emits_csi_with_modifier_param_5() { + // xterm modifier param: ctrl=4 => +1 = 5 + let ctrl_right = Keystroke::parse("ctrl-right").unwrap(); + assert_eq!( + to_esc_str(&ctrl_right, &TermMode::NONE, false) + .unwrap() + .as_ref(), + "\x1b[1;5C" + ); + } + + #[test] + fn alt_arrow_emits_csi_with_modifier_param_3() { + // alt=2 => +1 = 3 + let alt_left = Keystroke::parse("alt-left").unwrap(); + assert_eq!( + to_esc_str(&alt_left, &TermMode::NONE, false) + .unwrap() + .as_ref(), + "\x1b[1;3D" + ); + } } diff --git a/crates/terminal_view/src/terminal_element.rs b/crates/terminal_view/src/terminal_element.rs index 21bb341c6b..922928e2d3 100644 --- a/crates/terminal_view/src/terminal_element.rs +++ b/crates/terminal_view/src/terminal_element.rs @@ -99,6 +99,79 @@ fn is_decorative_character(ch: char) -> bool { ) } +/// 为 Unicode 块字符(U+2580..U+259F)生成几何矩形序列。 +/// +/// 返回的 rect 坐标以 cell 自身宽高的 [0, 1] 归一化系数表示, +/// 调用方在 paint 阶段乘以 cell_width / cell_height 得到像素矩形。 +/// +/// 几何绘制避免依赖字体字形,可解决字体回退时块状字符出现接缝、 +/// 抗锯齿不一致或 line-height gap 导致的视觉断层问题。 +fn block_element_geometry(c: char) -> Option> { + fn rect(x: f32, y: f32, w: f32, h: f32) -> BlockRect { + BlockRect { x, y, w, h } + } + fn lower(fraction: f32) -> Vec { + vec![rect(0.0, 1.0 - fraction, 1.0, fraction)] + } + fn left(fraction: f32) -> Vec { + vec![rect(0.0, 0.0, fraction, 1.0)] + } + const QUAD_UPPER_LEFT: u8 = 1 << 0; + const QUAD_UPPER_RIGHT: u8 = 1 << 1; + const QUAD_LOWER_LEFT: u8 = 1 << 2; + const QUAD_LOWER_RIGHT: u8 = 1 << 3; + fn quadrants(mask: u8) -> Vec { + let mut out = Vec::with_capacity(4); + if mask & QUAD_UPPER_LEFT != 0 { + out.push(rect(0.0, 0.0, 0.5, 0.5)); + } + if mask & QUAD_UPPER_RIGHT != 0 { + out.push(rect(0.5, 0.0, 0.5, 0.5)); + } + if mask & QUAD_LOWER_LEFT != 0 { + out.push(rect(0.0, 0.5, 0.5, 0.5)); + } + if mask & QUAD_LOWER_RIGHT != 0 { + out.push(rect(0.5, 0.5, 0.5, 0.5)); + } + out + } + + Some(match c { + '\u{2580}' => vec![rect(0.0, 0.0, 1.0, 0.5)], // ▀ upper half + '\u{2581}' => lower(1.0 / 8.0), // ▁ + '\u{2582}' => lower(2.0 / 8.0), // ▂ + '\u{2583}' => lower(3.0 / 8.0), // ▃ + '\u{2584}' => lower(4.0 / 8.0), // ▄ + '\u{2585}' => lower(5.0 / 8.0), // ▅ + '\u{2586}' => lower(6.0 / 8.0), // ▆ + '\u{2587}' => lower(7.0 / 8.0), // ▇ + '\u{2588}' => vec![rect(0.0, 0.0, 1.0, 1.0)], // █ full block + '\u{2589}' => left(7.0 / 8.0), // ▉ + '\u{258A}' => left(6.0 / 8.0), // ▊ + '\u{258B}' => left(5.0 / 8.0), // ▋ + '\u{258C}' => left(4.0 / 8.0), // ▌ + '\u{258D}' => left(3.0 / 8.0), // ▍ + '\u{258E}' => left(2.0 / 8.0), // ▎ + '\u{258F}' => left(1.0 / 8.0), // ▏ + '\u{2590}' => vec![rect(0.5, 0.0, 0.5, 1.0)], // ▐ right half + // U+2591..U+2593 阴影块由文本路径处理(依赖字体本身的密度图,更自然) + '\u{2594}' => vec![rect(0.0, 0.0, 1.0, 1.0 / 8.0)], // ▔ upper one-eighth + '\u{2595}' => vec![rect(7.0 / 8.0, 0.0, 1.0 / 8.0, 1.0)], // ▕ right one-eighth + '\u{2596}' => quadrants(QUAD_LOWER_LEFT), + '\u{2597}' => quadrants(QUAD_LOWER_RIGHT), + '\u{2598}' => quadrants(QUAD_UPPER_LEFT), + '\u{2599}' => quadrants(QUAD_UPPER_LEFT | QUAD_LOWER_LEFT | QUAD_LOWER_RIGHT), + '\u{259A}' => quadrants(QUAD_UPPER_LEFT | QUAD_LOWER_RIGHT), + '\u{259B}' => quadrants(QUAD_UPPER_LEFT | QUAD_UPPER_RIGHT | QUAD_LOWER_LEFT), + '\u{259C}' => quadrants(QUAD_UPPER_LEFT | QUAD_UPPER_RIGHT | QUAD_LOWER_RIGHT), + '\u{259D}' => quadrants(QUAD_UPPER_RIGHT), + '\u{259E}' => quadrants(QUAD_UPPER_RIGHT | QUAD_LOWER_LEFT), + '\u{259F}' => quadrants(QUAD_UPPER_RIGHT | QUAD_LOWER_LEFT | QUAD_LOWER_RIGHT), + _ => return None, + }) +} + /// Manages decorations from all addons pub struct DecorationManager { // Decorations indexed by line number @@ -194,6 +267,8 @@ impl DecorationManager { pub struct CachedLine { pub background_rects: Vec<(usize, usize, Hsla)>, pub text_runs: Vec, + /// 块状字符(U+2580..U+259F)使用几何绘制,避免字体回退导致的接缝 + pub block_glyphs: Vec, } #[derive(Clone)] @@ -207,6 +282,25 @@ pub struct CachedTextRun { pub char_count: usize, } +/// 单个 cell 内的几何块字符渲染数据 +/// +/// rects 中的坐标均归一化到 cell 自身的 [0, 1] 范围, +/// paint 时再按当前 cell_width/cell_height 缩放为像素矩形。 +#[derive(Clone)] +pub struct CachedBlockGlyph { + pub column: usize, + pub color: Hsla, + pub rects: Vec, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct BlockRect { + pub x: f32, + pub y: f32, + pub w: f32, + pub h: f32, +} + /// Terminal rendering cache maintained by TerminalView pub struct RenderCache { lines: Vec, @@ -238,6 +332,23 @@ struct CachedCursor { shape: CursorShape, } +enum DamageSnapshot { + Full, + Partial(Vec), +} + +impl DamageSnapshot { + fn from_term_damage(damage: TermDamage<'_>) -> Self { + match damage { + TermDamage::Full => Self::Full, + TermDamage::Partial(iter) => { + let lines = iter.map(|line_damage| line_damage.line).collect(); + Self::Partial(lines) + } + } + } +} + impl RenderCache { pub fn new(num_lines: usize, num_cols: usize, colors: Colors) -> Self { let default_bg = convert_color(Color::Named(NamedColor::Background), &colors); @@ -245,7 +356,8 @@ impl RenderCache { lines: vec![ CachedLine { background_rects: Vec::new(), - text_runs: Vec::new() + text_runs: Vec::new(), + block_glyphs: Vec::new(), }; num_lines ], @@ -278,6 +390,9 @@ impl RenderCache { self.resize(num_lines, num_cols); } + let damage = DamageSnapshot::from_term_damage(term.damage()); + term.reset_damage(); + // Collect decorations from all addons let display_offset = term.grid().display_offset(); self.decoration_manager @@ -297,37 +412,29 @@ impl RenderCache { // 同步主题光标颜色 self.custom_cursor = theme.cursor; - // Force full rebuild when theme colors or decorations changed - let has_decorations = !self.decoration_manager.decorations_by_line.is_empty(); - if fg_changed || bg_changed || has_decorations { - self.rebuild_all(term); - self.update_last_selection(term); - return; - } - - // Check terminal color palette changes + // 在任何 full rebuild 早返回之前同步终端调色板。 let colors = term.colors(); - if !colors_equal(&self.colors, colors) { + let colors_changed = !colors_equal(&self.colors, colors); + if colors_changed { self.colors = colors.clone(); self.default_bg = convert_color(Color::Named(NamedColor::Background), &self.colors); - self.rebuild_all(term); - self.update_last_selection(term); + } + + // 主题颜色变化或存在装饰时保守全量重建。 + let has_decorations = !self.decoration_manager.decorations_by_line.is_empty(); + if fg_changed || bg_changed || colors_changed || has_decorations { + self.rebuild_all_and_update_state(term); return; } - // Collect dirty lines from terminal damage let mut dirty_lines: std::collections::HashSet = std::collections::HashSet::new(); - let damage = term.damage(); match damage { - TermDamage::Full => { - self.rebuild_all(term); - self.update_last_selection(term); + DamageSnapshot::Full => { + self.rebuild_all_and_update_state(term); return; } - TermDamage::Partial(iter) => { - for line_damage in iter { - dirty_lines.insert(line_damage.line); - } + DamageSnapshot::Partial(lines) => { + dirty_lines.extend(lines); } } @@ -377,11 +484,18 @@ impl RenderCache { CachedLine { background_rects: Vec::new(), text_runs: Vec::new(), + block_glyphs: Vec::new(), }, ); self.left_edge_fingerprint.resize(num_lines, 0); } + fn rebuild_all_and_update_state(&mut self, term: &Term) { + self.rebuild_all(term); + self.update_last_selection(term); + self.sync_left_edge_fingerprint(term, 4); + } + fn rebuild_all(&mut self, term: &Term) { let content = term.renderable_content(); let display_offset = content.display_offset; @@ -391,6 +505,7 @@ impl RenderCache { for line in &mut self.lines { line.background_rects.clear(); line.text_runs.clear(); + line.block_glyphs.clear(); } // Group cells by screen line @@ -469,6 +584,7 @@ impl RenderCache { if line_idx < self.num_lines { self.lines[line_idx].background_rects.clear(); self.lines[line_idx].text_runs.clear(); + self.lines[line_idx].block_glyphs.clear(); let cells = std::mem::take(&mut line_cells[line_idx]); self.build_line_cache(line_idx, cells); } @@ -526,8 +642,39 @@ impl RenderCache { term: &Term, probe_cols: usize, ) -> Vec { + let current = self.compute_left_edge_fingerprint(term, probe_cols); + + if self.left_edge_fingerprint.len() != self.num_lines { + self.left_edge_fingerprint.resize(self.num_lines, 0); + } + + let mut changed = Vec::new(); + for (line_idx, (old, new)) in self + .left_edge_fingerprint + .iter() + .zip(current.iter()) + .enumerate() + { + if old != new { + changed.push(line_idx); + } + } + + self.left_edge_fingerprint = current; + changed + } + + fn sync_left_edge_fingerprint(&mut self, term: &Term, probe_cols: usize) { + self.left_edge_fingerprint = self.compute_left_edge_fingerprint(term, probe_cols); + } + + fn compute_left_edge_fingerprint( + &self, + term: &Term, + probe_cols: usize, + ) -> Vec { if self.num_lines == 0 || probe_cols == 0 { - return Vec::new(); + return vec![0; self.num_lines]; } let mut current = vec![0_u64; self.num_lines]; @@ -555,24 +702,7 @@ impl RenderCache { .wrapping_add(piece.wrapping_add(1469598103934665603)); } - if self.left_edge_fingerprint.len() != self.num_lines { - self.left_edge_fingerprint.resize(self.num_lines, 0); - } - - let mut changed = Vec::new(); - for (line_idx, (old, new)) in self - .left_edge_fingerprint - .iter() - .zip(current.iter()) - .enumerate() - { - if old != new { - changed.push(line_idx); - } - } - - self.left_edge_fingerprint = current; - changed + current } fn build_line_cache(&mut self, line_idx: usize, mut cells: Vec) { @@ -671,6 +801,19 @@ impl RenderCache { continue; } + // 块状字符走几何路径,避免不同字体渲染出现接缝 + if let Some(rects) = block_element_geometry(cell.c) { + if let Some(run) = text_run.take() { + line.text_runs.push(run); + } + line.block_glyphs.push(CachedBlockGlyph { + column: cell.column, + color: fg, + rects, + }); + continue; + } + let bold = cell.flags.contains(Flags::BOLD); let italic = cell.flags.contains(Flags::ITALIC); @@ -979,6 +1122,24 @@ impl Element for TerminalElementImpl { } } + // Paint block-element geometry(在文字之前,与背景同样的覆盖关系) + for line_idx in first_visible..visible_end { + let line = &self.lines[line_idx]; + for glyph in &line.block_glyphs { + let cell_origin = tb.cell_origin(line_idx, glyph.column); + for r in &glyph.rects { + let rect = Bounds::new( + Point::new( + cell_origin.x + tb.cell_width * r.x, + cell_origin.y + tb.cell_height * r.y, + ), + size(tb.cell_width * r.w, tb.cell_height * r.h), + ); + window.paint_quad(fill(rect, glyph.color)); + } + } + } + // Paint text (only visible lines, using cached fonts) // 使用 cell_width 确保等宽渲染,避免字符布局漂移 for line_idx in first_visible..visible_end { @@ -1266,3 +1427,113 @@ fn indexed_color_to_hsla(idx: u8) -> Hsla { } } } + +#[cfg(test)] +mod tests { + use super::{BlockRect, block_element_geometry}; + + fn approx_eq(a: f32, b: f32) -> bool { + (a - b).abs() < 1e-5 + } + + fn assert_rect(actual: &BlockRect, x: f32, y: f32, w: f32, h: f32) { + assert!( + approx_eq(actual.x, x) + && approx_eq(actual.y, y) + && approx_eq(actual.w, w) + && approx_eq(actual.h, h), + "expected ({x}, {y}, {w}, {h}) got {actual:?}" + ); + } + + #[test] + fn full_block_covers_entire_cell() { + let rects = block_element_geometry('\u{2588}').expect("full block"); + assert_eq!(rects.len(), 1); + assert_rect(&rects[0], 0.0, 0.0, 1.0, 1.0); + } + + #[test] + fn lower_half_block_fills_bottom_half() { + let rects = block_element_geometry('\u{2584}').expect("lower half"); + assert_eq!(rects.len(), 1); + assert_rect(&rects[0], 0.0, 0.5, 1.0, 0.5); + } + + #[test] + fn upper_half_block_fills_top_half() { + let rects = block_element_geometry('\u{2580}').expect("upper half"); + assert_eq!(rects.len(), 1); + assert_rect(&rects[0], 0.0, 0.0, 1.0, 0.5); + } + + #[test] + fn left_half_block_fills_left_half() { + let rects = block_element_geometry('\u{258C}').expect("left half"); + assert_eq!(rects.len(), 1); + assert_rect(&rects[0], 0.0, 0.0, 0.5, 1.0); + } + + #[test] + fn right_half_block_fills_right_half() { + let rects = block_element_geometry('\u{2590}').expect("right half"); + assert_eq!(rects.len(), 1); + assert_rect(&rects[0], 0.5, 0.0, 0.5, 1.0); + } + + #[test] + fn quadrant_block_lower_left_only() { + let rects = block_element_geometry('\u{2596}').expect("quadrant lower left"); + assert_eq!(rects.len(), 1); + assert_rect(&rects[0], 0.0, 0.5, 0.5, 0.5); + } + + #[test] + fn quadrant_block_diagonal_pair() { + let rects = block_element_geometry('\u{259A}').expect("quadrant diagonal"); + assert_eq!(rects.len(), 2); + // 上左 + 下右 + let mut found_upper_left = false; + let mut found_lower_right = false; + for r in &rects { + if approx_eq(r.x, 0.0) && approx_eq(r.y, 0.0) { + found_upper_left = true; + } + if approx_eq(r.x, 0.5) && approx_eq(r.y, 0.5) { + found_lower_right = true; + } + } + assert!(found_upper_left && found_lower_right); + } + + #[test] + fn shade_blocks_fall_back_to_text_path() { + // U+2591..U+2593 阴影块继续走文本路径,避免几何绘制无法表达密度 + assert!(block_element_geometry('\u{2591}').is_none()); + assert!(block_element_geometry('\u{2592}').is_none()); + assert!(block_element_geometry('\u{2593}').is_none()); + } + + #[test] + fn non_block_characters_return_none() { + // Box drawing 不在本批几何路径内 + assert!(block_element_geometry('─').is_none()); + // 普通字符也不返回几何 + assert!(block_element_geometry('A').is_none()); + } + + #[test] + fn eighth_lower_blocks_use_one_eighth_increments() { + for (i, ch) in [ + '\u{2581}', '\u{2582}', '\u{2583}', '\u{2584}', '\u{2585}', '\u{2586}', '\u{2587}', + ] + .iter() + .enumerate() + { + let fraction = (i + 1) as f32 / 8.0; + let rects = block_element_geometry(*ch).expect("lower eighth"); + assert_eq!(rects.len(), 1); + assert_rect(&rects[0], 0.0, 1.0 - fraction, 1.0, fraction); + } + } +} diff --git a/crates/terminal_view/src/view.rs b/crates/terminal_view/src/view.rs index 5b3c0ebb73..7f2b1448e0 100644 --- a/crates/terminal_view/src/view.rs +++ b/crates/terminal_view/src/view.rs @@ -115,6 +115,44 @@ fn sgr_mouse_wheel_report(lines: i32, col: usize, row: usize) -> Option Some(format!("\x1b[<{};{};{}M", button, col + 1, row + 1)) } +/// 生成 SGR 鼠标按钮报告。 +/// +/// - `button`:xterm 按钮编码(0=左键、1=中键、2=右键,加上 shift/alt/ctrl/拖动等位) +/// - `pressed`:true 用 `M` 表示按下,false 用 `m` 表示释放(SGR 协议规定) +/// - `col` / `row`:0-based,输出转为 1-based +/// +/// 抽出为独立纯函数,便于单元测试和后续扩展(拖动 32 位、wheel-with-modifiers 等)。 +fn sgr_mouse_button_report(button: u8, col: usize, row: usize, pressed: bool) -> String { + let suffix = if pressed { 'M' } else { 'm' }; + format!("\x1b[<{};{};{}{}", button, col + 1, row + 1, suffix) +} + +/// 将 GPUI 鼠标按钮映射为 xterm 按钮基础编码:左=0、中=1、右=2。 +/// 其它按钮(X1/X2 等)当前未在 SGR 报告中使用,返回 None。 +fn mouse_button_code(button: MouseButton) -> Option { + match button { + MouseButton::Left => Some(0), + MouseButton::Middle => Some(1), + MouseButton::Right => Some(2), + _ => None, + } +} + +/// 将修饰键编码到 xterm 鼠标按钮的高位:shift=4、alt=8、control=16。 +fn encode_mouse_modifiers(modifiers: Modifiers) -> u8 { + let mut bits = 0u8; + if modifiers.shift { + bits |= 4; + } + if modifiers.alt { + bits |= 8; + } + if modifiers.control { + bits |= 16; + } + bits +} + fn should_scroll_to_bottom_on_user_input( display_offset: usize, pending_display_offset: &StdCell>, @@ -2695,7 +2733,6 @@ impl TerminalView { self.render_cache .update(&mut term, &self.addon_manager, &self.current_theme); - term.reset_damage(); } // 获取光标可见性 @@ -3068,6 +3105,31 @@ impl TerminalView { } } + /// 当终端启用 SGR 鼠标 + 任意鼠标报告模式时,把按钮按下/释放事件以 SGR 形式 + /// 回报给 PTY。返回 true 表示已经处理,调用方应跳过 selection/dismiss/paste 等本地行为。 + fn try_report_sgr_mouse_button( + &mut self, + button: MouseButton, + position: Point, + modifiers: Modifiers, + pressed: bool, + cx: &mut Context, + ) -> bool { + let mode = self.terminal.read(cx).mode(); + if !(mode.contains(TermMode::SGR_MOUSE) && mode.intersects(TermMode::MOUSE_MODE)) { + return false; + } + let Some(base) = mouse_button_code(button) else { + return false; + }; + let point = self.pixel_to_point(position, self.terminal_bounds, cx); + let encoded = base | encode_mouse_modifiers(modifiers); + let report = + sgr_mouse_button_report(encoded, point.column.0, point.line.0 as usize, pressed); + self.write_to_pty(report.into_bytes(), cx); + true + } + fn handle_mouse_down( &mut self, event: &MouseDownEvent, @@ -3077,6 +3139,11 @@ impl TerminalView { if self.terminal.read(cx).ssh_mfa_request().is_none() { window.focus(&self.focus_handle, cx); } + // SGR 鼠标模式下把按钮按下事件交给 TUI,跳过 selection/URL/dismiss + if self.try_report_sgr_mouse_button(event.button, event.position, event.modifiers, true, cx) + { + return; + } tracing::debug!( target: "terminal.history_prompt", reason = "mouse_down", @@ -3156,10 +3223,20 @@ impl TerminalView { fn handle_middle_mouse_down( &mut self, - _event: &MouseDownEvent, + event: &MouseDownEvent, window: &mut Window, cx: &mut Context, ) { + // SGR 鼠标模式下中键按下走 TUI 报告而不是 middle-click paste + if self.try_report_sgr_mouse_button( + MouseButton::Middle, + event.position, + event.modifiers, + true, + cx, + ) { + return; + } if !self.middle_click_paste { return; } @@ -3220,6 +3297,16 @@ impl TerminalView { _window: &mut Window, cx: &mut Context, ) { + // SGR 鼠标模式下:先回报释放,然后跳过 selection 收尾 + if self.try_report_sgr_mouse_button( + event.button, + event.position, + event.modifiers, + false, + cx, + ) { + return; + } if event.button != MouseButton::Left { return; } @@ -3797,9 +3884,10 @@ impl Element for ResizeEventHandler { #[cfg(test)] mod tests { use super::{ - UnbracketedPasteHazard, detect_unbracketed_paste_hazard, has_trailing_line_continuation, - has_unterminated_shell_quote, history_prompt_available, history_prompt_dropdown_origin, - history_prompt_overlay_bounds, multiline_non_empty_line_count, sgr_mouse_wheel_report, + UnbracketedPasteHazard, detect_unbracketed_paste_hazard, encode_mouse_modifiers, + has_trailing_line_continuation, has_unterminated_shell_quote, history_prompt_available, + history_prompt_dropdown_origin, history_prompt_overlay_bounds, mouse_button_code, + multiline_non_empty_line_count, sgr_mouse_button_report, sgr_mouse_wheel_report, should_defer_inline_history_prompt_input_to_text_system, should_dismiss_history_prompt_for_keystroke, should_dismiss_history_prompt_for_mouse, should_dismiss_history_prompt_for_scroll, should_reset_history_prompt_for_terminal_event, @@ -3807,7 +3895,7 @@ mod tests { }; use crate::history_prompt::{HistoryPromptAccept, HistoryPromptState}; use alacritty_terminal::term::TermMode; - use gpui::{Bounds, Keystroke, MouseButton, Point, px, size}; + use gpui::{Bounds, Keystroke, Modifiers, MouseButton, Point, px, size}; use std::cell::Cell as StdCell; use terminal::terminal::{TerminalConnectionKind, TerminalModelEvent}; @@ -3873,6 +3961,74 @@ mod tests { assert_eq!(sgr_mouse_wheel_report(0, 4, 2), None); } + #[test] + fn sgr_mouse_button_report_uses_capital_m_on_press() { + // 左键按下,列 0、行 0 -> 转 1-based + let s = sgr_mouse_button_report(0, 0, 0, true); + assert_eq!(s, "\x1b[<0;1;1M"); + } + + #[test] + fn sgr_mouse_button_report_uses_lowercase_m_on_release() { + let s = sgr_mouse_button_report(2, 9, 4, false); + // 右键 (button=2) 释放在 1-based col=10 row=5 + assert_eq!(s, "\x1b[<2;10;5m"); + } + + #[test] + fn sgr_mouse_button_report_supports_modifier_encoded_buttons() { + // 左键 + shift (4) + ctrl (16) -> button=20 + let s = sgr_mouse_button_report(20, 0, 0, true); + assert_eq!(s, "\x1b[<20;1;1M"); + } + + #[test] + fn sgr_mouse_button_report_supports_drag_button_codes() { + // 拖动事件:button + 32(xterm 拖动位) + // 左键拖动 = 32 + let s = sgr_mouse_button_report(32, 7, 11, true); + assert_eq!(s, "\x1b[<32;8;12M"); + } + + #[test] + fn mouse_button_code_maps_three_main_buttons() { + assert_eq!(mouse_button_code(MouseButton::Left), Some(0)); + assert_eq!(mouse_button_code(MouseButton::Middle), Some(1)); + assert_eq!(mouse_button_code(MouseButton::Right), Some(2)); + } + + #[test] + fn encode_mouse_modifiers_packs_shift_alt_control() { + let none = Modifiers::default(); + assert_eq!(encode_mouse_modifiers(none), 0); + + let shift = Modifiers { + shift: true, + ..Default::default() + }; + assert_eq!(encode_mouse_modifiers(shift), 4); + + let alt = Modifiers { + alt: true, + ..Default::default() + }; + assert_eq!(encode_mouse_modifiers(alt), 8); + + let ctrl = Modifiers { + control: true, + ..Default::default() + }; + assert_eq!(encode_mouse_modifiers(ctrl), 16); + + let all = Modifiers { + shift: true, + alt: true, + control: true, + ..Default::default() + }; + assert_eq!(encode_mouse_modifiers(all), 28); + } + #[test] fn multiline_non_empty_line_count_ignores_blank_lines() { assert_eq!(multiline_non_empty_line_count("echo 1\n\n echo 2\n"), 2); From 71591924de34524a60884b6dde6a783f77a9aaea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=83=A1=E9=A3=9E?= <1835698775@qq.com> Date: Mon, 11 May 2026 17:06:20 +0800 Subject: [PATCH 15/45] =?UTF-8?q?feat(terminal):=20=E8=AE=B0=E5=BD=95?= =?UTF-8?q?=E5=83=8F=E7=B4=A0=E5=B0=BA=E5=AF=B8=E5=B9=B6=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=20nudge=5Fresize=20=E8=A7=A6=E5=8F=91=20SIGWINCH?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 Terminal 结构中新增 pixel_width 和 pixel_height 字段用于记录像素尺寸 - resize 方法中若单元格尺寸未变则仅更新像素尺寸,避免重复调整行列 - 新增 nudge_resize 方法,重新向 PTY 发送当前尺寸以触发 SIGWINCH - 在 View 模块监测 alt screen 模式切换,进入时调用 nudge_resize - 多处终端视图代码添加详细的调试日志,追踪尺寸与重建状态 - 记录底部若干行内容分布,辅助调试 TUI 应用残留旧画面问题 - 在 RenderCache 和绘制函数中添加额外日志,助力性能与渲染分析 --- crates/terminal/src/terminal.rs | 55 +++++++++++- crates/terminal_view/src/terminal_element.rs | 90 +++++++++++++++++++- crates/terminal_view/src/view.rs | 38 +++++++++ 3 files changed, 180 insertions(+), 3 deletions(-) diff --git a/crates/terminal/src/terminal.rs b/crates/terminal/src/terminal.rs index 76c095d0ec..f384c354e3 100644 --- a/crates/terminal/src/terminal.rs +++ b/crates/terminal/src/terminal.rs @@ -638,6 +638,9 @@ pub struct Terminal { /// 终端尺寸 cols: usize, rows: usize, + /// 最近一次同步给 PTY 的像素尺寸,用于 nudge_resize 重发 SIGWINCH + pixel_width: u16, + pixel_height: u16, /// SSH 配置(用于重连) ssh_config: Option, @@ -747,6 +750,8 @@ impl Terminal { connection_state: ConnectionState::Disconnected { error: Some(error) }, cols: DEFAULT_COLS, rows: DEFAULT_ROWS, + pixel_width: 0, + pixel_height: 0, ssh_config: None, ssh_session_manager: None, ssh_mfa_responder: None, @@ -820,6 +825,8 @@ impl Terminal { connection_state: ConnectionState::Connected, cols: DEFAULT_COLS, rows: DEFAULT_ROWS, + pixel_width: 0, + pixel_height: 0, ssh_config: None, ssh_session_manager: None, ssh_mfa_responder: None, @@ -970,6 +977,8 @@ impl Terminal { connection_state: ConnectionState::Connecting, cols, rows, + pixel_width: 0, + pixel_height: 0, ssh_config: Some(config), ssh_session_manager: Some(ssh_session_manager), ssh_mfa_responder: Some(ssh_mfa_responder), @@ -1018,6 +1027,8 @@ impl Terminal { connection_state: ConnectionState::Connecting, cols: DEFAULT_COLS, rows: DEFAULT_ROWS, + pixel_width: 0, + pixel_height: 0, ssh_config: None, ssh_session_manager: None, ssh_mfa_responder: None, @@ -1540,21 +1551,33 @@ impl Terminal { /// 调整终端大小 pub fn resize(&mut self, cols: usize, rows: usize, pixel_width: u16, pixel_height: u16) { if self.cols == cols && self.rows == rows { + // 单元格行列数未变,但仍记录最新像素尺寸,供 nudge_resize 复用 + self.pixel_width = pixel_width; + self.pixel_height = pixel_height; + tracing::debug!( + target: "terminal_residue", + cols, rows, pixel_width, pixel_height, + "Terminal::resize noop (cells unchanged, pixels cached)" + ); return; } tracing::info!( - "Terminal::resize: {}x{} -> {}x{}, pixel={}x{}", + target: "terminal_residue", + "Terminal::resize: {}x{} -> {}x{}, pixel={}x{}, backend={}", self.cols, self.rows, cols, rows, pixel_width, - pixel_height + pixel_height, + self.backend.is_some() ); self.cols = cols; self.rows = rows; + self.pixel_width = pixel_width; + self.pixel_height = pixel_height; self.term.lock().resize(TermDimensions { cols, rows }); @@ -1568,6 +1591,32 @@ impl Terminal { } } + /// 重新向 PTY 后端发送当前尺寸,不修改 alacritty grid。 + /// + /// 用于在 alt screen 切换等场景下触发 SIGWINCH, + /// 让 TUI 应用(opencode/lazygit/vim 等)重新查询尺寸并刷新整屏画面, + /// 避免出现底部残留旧画面的问题。 + pub fn nudge_resize(&self) { + let Some(ref backend) = self.backend else { + tracing::warn!(target: "terminal_residue", "nudge_resize skipped: no backend"); + return; + }; + tracing::info!( + target: "terminal_residue", + cols = self.cols, + rows = self.rows, + pixel_width = self.pixel_width, + pixel_height = self.pixel_height, + "Terminal::nudge_resize -> backend.resize" + ); + backend.resize(TerminalSize { + rows: self.rows as u16, + cols: self.cols as u16, + pixel_width: self.pixel_width, + pixel_height: self.pixel_height, + }); + } + /// 重新连接 SSH 或串口 pub fn reconnect(&mut self, cx: &mut Context) { if let Some(config) = self.ssh_config.clone() { @@ -2145,6 +2194,8 @@ mod tests { connection_state: ConnectionState::Connected, cols: 80, rows: 24, + pixel_width: 0, + pixel_height: 0, ssh_config: None, ssh_session_manager: None, ssh_mfa_responder: None, diff --git a/crates/terminal_view/src/terminal_element.rs b/crates/terminal_view/src/terminal_element.rs index 922928e2d3..77e848c34b 100644 --- a/crates/terminal_view/src/terminal_element.rs +++ b/crates/terminal_view/src/terminal_element.rs @@ -12,7 +12,7 @@ use alacritty_terminal::grid::Dimensions; use alacritty_terminal::selection::SelectionRange; use alacritty_terminal::term::cell::Flags; use alacritty_terminal::term::color::Colors; -use alacritty_terminal::term::{RenderableContent, Term, TermDamage}; +use alacritty_terminal::term::{RenderableContent, Term, TermDamage, TermMode}; use alacritty_terminal::vte::ansi::{Color, CursorShape, NamedColor, Rgb}; use gpui::*; use std::collections::HashMap; @@ -387,6 +387,14 @@ impl RenderCache { // Handle resize if num_lines != self.num_lines || num_cols != self.num_cols { + tracing::info!( + target: "terminal_residue", + old_lines = self.num_lines, + old_cols = self.num_cols, + new_lines = num_lines, + new_cols = num_cols, + "RenderCache::resize" + ); self.resize(num_lines, num_cols); } @@ -423,6 +431,15 @@ impl RenderCache { // 主题颜色变化或存在装饰时保守全量重建。 let has_decorations = !self.decoration_manager.decorations_by_line.is_empty(); if fg_changed || bg_changed || colors_changed || has_decorations { + tracing::debug!( + target: "terminal_residue", + fg_changed, + bg_changed, + colors_changed, + has_decorations, + num_lines, + "rebuild_all (forced by theme/decoration)" + ); self.rebuild_all_and_update_state(term); return; } @@ -430,10 +447,23 @@ impl RenderCache { let mut dirty_lines: std::collections::HashSet = std::collections::HashSet::new(); match damage { DamageSnapshot::Full => { + tracing::debug!( + target: "terminal_residue", + num_lines, + "rebuild_all (TermDamage::Full)" + ); self.rebuild_all_and_update_state(term); return; } DamageSnapshot::Partial(lines) => { + if !lines.is_empty() { + tracing::debug!( + target: "terminal_residue", + damaged = ?lines, + num_lines, + "Partial damage" + ); + } dirty_lines.extend(lines); } } @@ -540,6 +570,34 @@ impl RenderCache { // Update cursor from a fresh content let content = term.renderable_content(); self.update_cursor_from_content(&content); + + // 调试日志:统计 cache 重建后各行的内容分布。 + // 关注底部最后 8 行,若 TUI 仅画了上半部,底部 8 行的 text/bg 应该为空。 + let total = self.lines.len(); + let non_empty_lines = self + .lines + .iter() + .filter(|l| !l.text_runs.is_empty() || !l.background_rects.is_empty()) + .count(); + let mut tail_summary = Vec::new(); + let tail_start = total.saturating_sub(8); + for idx in tail_start..total { + let l = &self.lines[idx]; + tail_summary.push(format!( + "[{idx}] bg={} text={} chars={}", + l.background_rects.len(), + l.text_runs.len(), + l.text_runs.iter().map(|r| r.char_count).sum::(), + )); + } + tracing::debug!( + target: "terminal_residue", + total_lines = total, + non_empty_lines, + in_alt_screen = content.mode.contains(TermMode::ALT_SCREEN), + tail = tail_summary.join(" | "), + "rebuild_all done" + ); } /// Rebuild specified lines @@ -1093,6 +1151,16 @@ impl Element for TerminalElementImpl { let intersection = content_mask.intersect(&terminal_bounds); if intersection.size.height <= px(0.) || intersection.size.width <= px(0.) { + tracing::debug!( + target: "terminal_residue", + lines = self.lines.len(), + num_cols = self.num_cols, + cell_w = ?tb.cell_width, + cell_h = ?tb.cell_height, + origin = ?tb.origin, + content_mask = ?content_mask, + "paint skipped (no intersection)" + ); return; // 完全不可见,跳过渲染 } @@ -1110,6 +1178,26 @@ impl Element for TerminalElementImpl { .ceil() as usize; let visible_end = last_visible.min(self.lines.len()); + // 仅在统计行数 / 像素差异时记录一次,避免每帧爆量 + let cm_h: f32 = content_mask.size.height.into(); + let tb_h: f32 = terminal_height.into(); + if (cm_h - tb_h).abs() > 0.5 || self.lines.len() < visible_end { + tracing::debug!( + target: "terminal_residue", + lines = self.lines.len(), + num_cols = self.num_cols, + cell_w = ?tb.cell_width, + cell_h = ?tb.cell_height, + origin = ?tb.origin, + terminal_bounds_h = ?terminal_height, + content_mask = ?content_mask, + first_visible, + visible_end, + bg_alpha = self.custom_background.a, + "paint metrics" + ); + } + // Paint backgrounds (only visible lines) for line_idx in first_visible..visible_end { let line = &self.lines[line_idx]; diff --git a/crates/terminal_view/src/view.rs b/crates/terminal_view/src/view.rs index 7f2b1448e0..723c5aaa77 100644 --- a/crates/terminal_view/src/view.rs +++ b/crates/terminal_view/src/view.rs @@ -464,6 +464,12 @@ pub struct TerminalView { cell_width: Pixels, last_size: Option<(usize, usize)>, + /// 上一帧 alacritty 是否处于 alt screen 模式。 + /// + /// 用于检测主屏与备用屏切换:进入 alt screen 时主动调用 nudge_resize + /// 重发当前尺寸给 PTY,触发 SIGWINCH,让 TUI 应用刷新整屏画面, + /// 避免出现底部残留上一次渲染内容的问题。 + last_alt_screen: bool, scroll_lines_accumulated: f32, mouse_state: MouseState, @@ -815,6 +821,7 @@ impl TerminalView { // 初始化为 None,确保首次渲染时会触发 resize, // 将正确的终端尺寸发送给 PTY last_size: None, + last_alt_screen: false, scroll_lines_accumulated: 0.0, mouse_state: MouseState::default(), addon_manager: Self::create_addon_manager(), @@ -2680,6 +2687,16 @@ impl TerminalView { let new_size = (cols, rows); if self.last_size != Some(new_size) { + tracing::info!( + target: "terminal_residue", + old = ?self.last_size, + new = ?new_size, + bounds_w = ?bounds.size.width, + bounds_h = ?bounds.size.height, + cell_width = ?self.cell_width, + line_height = ?self.line_height, + "resize_if_needed -> Terminal::resize" + ); self.last_size = Some(new_size); self.terminal.update(cx, |terminal, _| { terminal.resize( @@ -3516,6 +3533,27 @@ impl Render for TerminalView { let history_size = self.terminal.read(cx).term().lock().history_size(); let show_scrollbar = !terminal_mode.contains(TermMode::ALT_SCREEN) && history_size > 0; + // 检测主屏 ↔ alt screen 切换。 + // 进入 alt screen 时(opencode/lazygit/vim 等 TUI 启动),主动重发当前尺寸到 PTY, + // 触发 SIGWINCH 让 TUI 重新查询尺寸并刷新整屏,避免底部残留旧画面。 + // 仅在 last_size 已就绪时(说明 PTY 已收到过正确尺寸)才 nudge, + // 避免覆盖即将到来的首次 resize_if_needed。 + let alt_screen = terminal_mode.contains(TermMode::ALT_SCREEN); + if alt_screen != self.last_alt_screen { + tracing::info!( + target: "terminal_residue", + from = self.last_alt_screen, + to = alt_screen, + last_size = ?self.last_size, + "alt_screen mode transition" + ); + self.last_alt_screen = alt_screen; + if alt_screen && self.last_size.is_some() { + tracing::info!(target: "terminal_residue", "nudge_resize fired on enter alt_screen"); + self.terminal.update(cx, |terminal, _| terminal.nudge_resize()); + } + } + div() .size_full() .flex() From 9cb88c79ff9b2de4e403ad55fd32981409a3ef2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=83=A1=E9=A3=9E?= <1835698775@qq.com> Date: Mon, 11 May 2026 18:32:22 +0800 Subject: [PATCH 16/45] chore(main): bump version to 0.4.1 --- main/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main/Cargo.toml b/main/Cargo.toml index fd5b434252..37a2a9a835 100644 --- a/main/Cargo.toml +++ b/main/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "main" -version = "0.4.0" +version = "0.4.1" publish.workspace = true edition.workspace = true From cbe319be4f2c1d8417f85d1714b0e221008bc8e3 Mon Sep 17 00:00:00 2001 From: swz128 Date: Mon, 11 May 2026 19:41:25 +0800 Subject: [PATCH 17/45] =?UTF-8?q?fix(edit=5Ftable):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E9=80=89=E4=B8=AD=E5=8D=95=E5=85=83=E6=A0=BC=E6=97=B6=E5=86=85?= =?UTF-8?q?=E5=AE=B9=E5=9B=A0=E8=BE=B9=E6=A1=86=E6=8C=A4=E5=8E=8B=E4=BA=A7?= =?UTF-8?q?=E7=94=9F=E5=81=8F=E7=A7=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 选中单元格使用 border_2 绘制高亮边框,边框会占用盒模型内部空间, 通过 content_box_inset 挤压内容区,导致文字位置跳动。改为在施加 选中边框后等量减少对应方向的 padding,保持内容区原点不变。 --- crates/one_ui/src/edit_table/state.rs | 39 +++++++++++++++++++-------- 1 file changed, 28 insertions(+), 11 deletions(-) diff --git a/crates/one_ui/src/edit_table/state.rs b/crates/one_ui/src/edit_table/state.rs index f8d1e3bda6..054aaeb19f 100644 --- a/crates/one_ui/src/edit_table/state.rs +++ b/crates/one_ui/src/edit_table/state.rs @@ -1936,6 +1936,9 @@ where let is_editing = row_ix.is_some() && self.editing_cell == Some((row_ix.unwrap(), col_ix)); let selection_border_color = cx.theme().table_active_border; + let is_single_select_active = + (is_active_cell || is_select_cell) && !is_editing && !is_multi_selection; + let mut cell = div() .id(cell_id) .w(col_width) @@ -1964,10 +1967,9 @@ where this.border_r_2().border_color(selection_border_color) }) // 活动单元格额外添加完整边框(仅在单选时显示) - .when( - (is_active_cell || is_select_cell) && !is_editing && !is_multi_selection, - |this| this.border_2().border_color(selection_border_color), - ) + .when(is_single_select_active, |this| { + this.border_2().border_color(selection_border_color) + }) // 编辑状态的单元格 .when(is_editing, |this| { this.bg(cx.theme().background) @@ -1984,14 +1986,29 @@ where } } else { cell = cell.table_cell_size(self.options.size); - cell = match col_padding { - Some(padding) => cell - .pl(padding.left) - .pr(padding.right) - .pt(padding.top) - .pb(padding.bottom), - None => cell, + + let size_pad = self.options.size.table_cell_padding(); + let (target_pt, target_pb, target_pl, target_pr) = match col_padding { + Some(p) => (p.top, p.bottom, p.left, p.right), + None => ( + size_pad.top, + size_pad.bottom, + size_pad.left, + size_pad.right, + ), }; + + // 选中时 border 占用内部空间会挤压内容区,减少等量 padding 补偿 + let has_t = border_top || is_single_select_active; + let has_b = border_bottom || is_single_select_active; + let has_l = border_left || is_single_select_active; + let has_r = border_right || is_single_select_active; + let b = px(2.); + cell = cell + .pt(if has_t { (target_pt - b).max(px(0.)) } else { target_pt }) + .pb(if has_b { (target_pb - b).max(px(0.)) } else { target_pb }) + .pl(if has_l { (target_pl - b).max(px(0.)) } else { target_pl }) + .pr(if has_r { (target_pr - b).max(px(0.)) } else { target_pr }); } cell From ab8afce415a532f40659c4ea8ab892a725c51791 Mon Sep 17 00:00:00 2001 From: swz128 Date: Tue, 12 May 2026 17:09:15 +0800 Subject: [PATCH 18/45] =?UTF-8?q?fix(edit=5Ftable):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=8F=8C=E5=87=BB=E7=BC=96=E8=BE=91=E6=97=B6=E5=8D=95=E5=85=83?= =?UTF-8?q?=E6=A0=BC=E5=86=85=E5=AE=B9=E4=BD=8D=E7=A7=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 编辑模式和显示模式走了两套不同的布局路径,前者完全跳过 table_cell_size 和边框补偿,渲染的 Input 组件又自带 py/px, 导致文本位置不一致。 改动: - 统一 render_cell 的容器布局,两种模式共用 table_cell_size + 边框补偿 - Input 新增 bare() 模式,跳过自带的 padding/height/items_center, 让父容器完全控制布局,保留所有键盘/鼠标事件处理 --- crates/one_ui/src/edit_table/delegate.rs | 1 + crates/one_ui/src/edit_table/state.rs | 58 ++++++++++++++---------- crates/ui/src/input/input.rs | 23 +++++++--- 3 files changed, 50 insertions(+), 32 deletions(-) diff --git a/crates/one_ui/src/edit_table/delegate.rs b/crates/one_ui/src/edit_table/delegate.rs index 6052211cc2..efcadd2723 100644 --- a/crates/one_ui/src/edit_table/delegate.rs +++ b/crates/one_ui/src/edit_table/delegate.rs @@ -43,6 +43,7 @@ impl CellEditor { .h_full() .text_base() .appearance(false) + .bare() .into_any_element(), CellEditor::DatePicker(picker) => DatePicker::new(picker) .w_full() diff --git a/crates/one_ui/src/edit_table/state.rs b/crates/one_ui/src/edit_table/state.rs index 054aaeb19f..e308949ac7 100644 --- a/crates/one_ui/src/edit_table/state.rs +++ b/crates/one_ui/src/edit_table/state.rs @@ -1980,35 +1980,43 @@ where this.bg(cx.theme().warning.opacity(0.15)) }); + // 统一布局:编辑和显示模式使用相同的容器 padding + cell = cell.table_cell_size(self.options.size); + + let size_pad = self.options.size.table_cell_padding(); + let (target_pt, target_pb, target_pl, target_pr) = match col_padding { + Some(p) => (p.top, p.bottom, p.left, p.right), + None => ( + size_pad.top, + size_pad.bottom, + size_pad.left, + size_pad.right, + ), + }; + + // 边框补偿:编辑态始终有 border_2;显示态仅选中时有 + let (has_t, has_b, has_l, has_r) = if is_editing { + (true, true, true, true) + } else { + ( + border_top || is_single_select_active, + border_bottom || is_single_select_active, + border_left || is_single_select_active, + border_right || is_single_select_active, + ) + }; + let b = px(2.); + cell = cell + .pt(if has_t { (target_pt - b).max(px(0.)) } else { target_pt }) + .pb(if has_b { (target_pb - b).max(px(0.)) } else { target_pb }) + .pl(if has_l { (target_pl - b).max(px(0.)) } else { target_pl }) + .pr(if has_r { (target_pr - b).max(px(0.)) } else { target_pr }); + + // 编辑模式:嵌入轻量编辑器(无自带样式,由容器控制布局) if is_editing { if let Some(editor) = &self.editing_input { cell = cell.child(editor.render(window, cx)); } - } else { - cell = cell.table_cell_size(self.options.size); - - let size_pad = self.options.size.table_cell_padding(); - let (target_pt, target_pb, target_pl, target_pr) = match col_padding { - Some(p) => (p.top, p.bottom, p.left, p.right), - None => ( - size_pad.top, - size_pad.bottom, - size_pad.left, - size_pad.right, - ), - }; - - // 选中时 border 占用内部空间会挤压内容区,减少等量 padding 补偿 - let has_t = border_top || is_single_select_active; - let has_b = border_bottom || is_single_select_active; - let has_l = border_left || is_single_select_active; - let has_r = border_right || is_single_select_active; - let b = px(2.); - cell = cell - .pt(if has_t { (target_pt - b).max(px(0.)) } else { target_pt }) - .pb(if has_b { (target_pb - b).max(px(0.)) } else { target_pb }) - .pl(if has_l { (target_pl - b).max(px(0.)) } else { target_pl }) - .pr(if has_r { (target_pr - b).max(px(0.)) } else { target_pr }); } cell diff --git a/crates/ui/src/input/input.rs b/crates/ui/src/input/input.rs index 46816bfc2e..2333fbd8b2 100644 --- a/crates/ui/src/input/input.rs +++ b/crates/ui/src/input/input.rs @@ -49,6 +49,7 @@ pub struct Input { focus_bordered: bool, tab_index: isize, selected: bool, + bare: bool, } impl Sizable for Input { @@ -87,6 +88,7 @@ impl Input { focus_bordered: true, tab_index: 0, selected: false, + bare: false, } } @@ -148,6 +150,13 @@ impl Input { self } + /// 纯编辑器模式:去掉 Input 自带的 padding、height、items_center 等布局样式, + /// 完全由父容器控制布局。用于嵌入表格单元格等场景。 + pub fn bare(mut self) -> Self { + self.bare = true; + self + } + /// Set the tab index for the input, default is 0. pub fn tab_index(mut self, index: isize) -> Self { self.tab_index = index; @@ -373,14 +382,14 @@ impl RenderOnce for Input { .on_mouse_move(window.listener_for(&self.state, InputState::on_mouse_move)) .on_scroll_wheel(window.listener_for(&self.state, InputState::on_scroll_wheel)) .size_full() - .line_height(LINE_HEIGHT) - .input_px(self.size) - .input_py(self.size) - .input_h(self.size) + .when(!self.bare, |this| this.line_height(LINE_HEIGHT)) .input_text_size(self.size) + .when(!self.bare, |this| this.input_px(self.size)) + .when(!self.bare, |this| this.input_py(self.size)) + .when(!self.bare, |this| this.input_h(self.size)) .when(!self.disabled, |this| this.cursor_text()) - .items_center() - .when(state.mode.is_multi_line(), |this| { + .when(!self.bare, |this| this.items_center()) + .when(state.mode.is_multi_line() && !self.bare, |this| { this.h_auto() .when_some(self.height, |this, height| this.h(height)) }) @@ -398,7 +407,7 @@ impl RenderOnce for Input { }) }) }) - .items_center() + .when(!self.bare, |this| this.items_center()) .gap(gap_x) .refine_style(&self.style) .children(prefix) From bf9b852a35ce17fd238f49507aa21977c24cab1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=83=A1=E9=A3=9E?= <1835698775@qq.com> Date: Tue, 12 May 2026 17:22:36 +0800 Subject: [PATCH 19/45] =?UTF-8?q?feat(terminal):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E5=85=B3=E9=97=AD=20shell=20integration=20=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 SSH 连接配置中新增 disable_shell_integration 字段支持用户关闭 shell 集成注入 - 修改 ssh_backend 逻辑,关闭时跳过安装 shell integration,走裸 request_shell 路径 - 设计 zsh 和 bash wrapper,保留完整用户 shell 行为并集成环境恢复和 source 机制 - 对关闭集成场景增加单测,确保只启动交互 shell channel 不写缓存 - 终端 UI 界面新增禁用 shell 集成功能选项及描述提示 - 修正鼠标 SGR 事件处理,支持 shift 拖拽文本选区穿透,兼容多终端约定 - 更新 Cargo 版本号至 v0.4.1 --- Cargo.lock | 2 +- crates/core/src/storage/models.rs | 3 + crates/terminal/src/ssh_backend.rs | 356 ++++++++++++++++-- crates/terminal/src/terminal.rs | 4 + .../terminal_view/locales/terminal_view.yml | 8 + crates/terminal_view/src/ssh_form_window.rs | 38 +- crates/terminal_view/src/view.rs | 13 +- 7 files changed, 396 insertions(+), 28 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 709f2f5655..5e70b6599e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6037,7 +6037,7 @@ checksum = "670fdfda89751bc4a84ac13eaa63e205cf0fd22b4c9a5fbfa085b63c1f1d3a30" [[package]] name = "main" -version = "0.4.0" +version = "0.4.1" dependencies = [ "anyhow", "base64 0.22.1", diff --git a/crates/core/src/storage/models.rs b/crates/core/src/storage/models.rs index 1e187acf26..61a8c49ae5 100644 --- a/crates/core/src/storage/models.rs +++ b/crates/core/src/storage/models.rs @@ -220,6 +220,9 @@ pub struct SshParams { /// 初始化脚本 #[serde(skip_serializing_if = "Option::is_none")] pub init_script: Option, + /// 关闭 shell integration 注入(走裸 request_shell,牺牲 prompt hook / 命令记录 / vim 鼠标) + #[serde(skip_serializing_if = "Option::is_none")] + pub disable_shell_integration: Option, /// 跳板机配置 #[serde(skip_serializing_if = "Option::is_none")] pub jump_server: Option, diff --git a/crates/terminal/src/ssh_backend.rs b/crates/terminal/src/ssh_backend.rs index 91e28fcc6f..87940ce0c9 100644 --- a/crates/terminal/src/ssh_backend.rs +++ b/crates/terminal/src/ssh_backend.rs @@ -88,18 +88,43 @@ fn build_shell_integration_setup_script( let home_marker = shell_single_quote(home_marker); let session_marker = shell_single_quote(session_marker); let shell_marker = shell_single_quote(shell_marker); + + // zsh wrapper 设计:让 ZDOTDIR 始终保持 session_dir/zsh,在该目录下放完整的 4 个 wrapper + // 文件,每个 fan-out 到 $ONETCLI_ORIG_ZDOTDIR 下的同名文件,保留完整 login shell 行为; + // 仅在 .zshrc 末尾追加 integration source,然后还原 ZDOTDIR 给后续 sub-shell。 let zshenv = shell_single_quote( - "ZDOTDIR=\"${ONETCLI_ORIG_ZDOTDIR:-$HOME}\"\n\ - [[ -f \"$ZDOTDIR/.zshenv\" ]] && . \"$ZDOTDIR/.zshenv\"\n", + "[[ -n \"${ONETCLI_ORIG_ZDOTDIR:-}\" ]] && [ -f \"$ONETCLI_ORIG_ZDOTDIR/.zshenv\" ] \ + && . \"$ONETCLI_ORIG_ZDOTDIR/.zshenv\"\n", + ); + let zprofile = shell_single_quote( + "[[ -n \"${ONETCLI_ORIG_ZDOTDIR:-}\" ]] && [ -f \"$ONETCLI_ORIG_ZDOTDIR/.zprofile\" ] \ + && . \"$ONETCLI_ORIG_ZDOTDIR/.zprofile\"\n", ); let zshrc = shell_single_quote(&format!( - "ZDOTDIR=\"${{ONETCLI_ORIG_ZDOTDIR:-$HOME}}\"\n\ - [[ -f \"$ZDOTDIR/.zshrc\" ]] && . \"$ZDOTDIR/.zshrc\"\n\ - . \"{integration_source}\"\n" + "[[ -n \"${{ONETCLI_ORIG_ZDOTDIR:-}}\" ]] && [ -f \"$ONETCLI_ORIG_ZDOTDIR/.zshrc\" ] \ + && . \"$ONETCLI_ORIG_ZDOTDIR/.zshrc\"\n\ + . \"{integration_source}\"\n\ + ZDOTDIR=\"${{ONETCLI_ORIG_ZDOTDIR:-$HOME}}\"\n" )); + let zlogin = shell_single_quote( + "[[ -n \"${ONETCLI_ORIG_ZDOTDIR:-}\" ]] && [ -f \"$ONETCLI_ORIG_ZDOTDIR/.zlogin\" ] \ + && . \"$ONETCLI_ORIG_ZDOTDIR/.zlogin\"\n", + ); + // bash wrapper:`exec bash --rcfile X -i` 是 interactive non-login,跳过 /etc/profile 与 + // ~/.bash_profile 等。这里手动模拟 login chain,然后再显式 source ~/.bashrc + integration。 + // ONETCLI_LOGIN_SIMULATED guard 防止 .bash_profile 内 `exec bash -l` 等场景二次进入时重复 + // 加载 profile 链。 let bashrc = shell_single_quote(&format!( - "[ -f \"$HOME/.bashrc\" ] && . \"$HOME/.bashrc\"\n\ - . \"{integration_source}\"\n" + "if [ -z \"${{ONETCLI_LOGIN_SIMULATED:-}}\" ]; then\n\ + \x20\x20\x20\x20export ONETCLI_LOGIN_SIMULATED=1\n\ + \x20\x20\x20\x20[ -r /etc/profile ] && . /etc/profile\n\ + \x20\x20\x20\x20for __onetcli_profile in \"$HOME/.bash_profile\" \"$HOME/.bash_login\" \"$HOME/.profile\"; do\n\ + \x20\x20\x20\x20\x20\x20\x20\x20if [ -r \"$__onetcli_profile\" ]; then . \"$__onetcli_profile\"; break; fi\n\ + \x20\x20\x20\x20done\n\ + \x20\x20\x20\x20unset __onetcli_profile\n\ + fi\n\ + [ -r \"$HOME/.bashrc\" ] && . \"$HOME/.bashrc\"\n\ + . \"{integration_source}\"\n" )); format!( @@ -112,7 +137,9 @@ fn build_shell_integration_setup_script( "mkdir -p \"$zsh_dir\" \"$bash_dir\"\n", "printf %s {script} > \"$integration_path\"\n", "printf %s {zshenv} > \"$zsh_dir/.zshenv\"\n", + "printf %s {zprofile} > \"$zsh_dir/.zprofile\"\n", "printf %s {zshrc} > \"$zsh_dir/.zshrc\"\n", + "printf %s {zlogin} > \"$zsh_dir/.zlogin\"\n", "printf %s {bashrc} > \"$bash_dir/.bashrc\"\n", "printf '%s%s\\n' {home_marker} \"$HOME\"\n", "printf '%s%s\\n' {session_marker} \"$session_dir\"\n", @@ -122,7 +149,9 @@ fn build_shell_integration_setup_script( session_key = session_key, script = script, zshenv = zshenv, + zprofile = zprofile, zshrc = zshrc, + zlogin = zlogin, bashrc = bashrc, success_marker = success_marker, home_marker = home_marker, @@ -172,11 +201,16 @@ impl SshBackend { notify_tx: UnboundedSender<()>, on_disconnect: Option>, init_commands: Option, + disable_shell_integration: bool, ) -> anyhow::Result { - let (client, mut channel) = - Self::establish_channel(&session_manager, &pty_config, connection_id) - .await - .map_err(add_connect_error_context)?; + let (client, mut channel) = Self::establish_channel( + &session_manager, + &pty_config, + connection_id, + disable_shell_integration, + ) + .await + .map_err(add_connect_error_context)?; // 关联变量,避免 clippy 警告未使用。 let _keep_client = client; @@ -314,6 +348,7 @@ impl SshBackend { session_manager: &Arc, pty_config: &PtyConfig, connection_id: Option, + disable_shell_integration: bool, ) -> anyhow::Result<(Arc>, ssh::RusshChannel)> { let mut attempt = 0usize; loop { @@ -322,7 +357,14 @@ impl SshBackend { let result = { let mut guard = client.lock().await; - Self::prepare_ssh_channel(&mut *guard, pty_config, connection_id, cached).await + Self::prepare_ssh_channel( + &mut *guard, + pty_config, + connection_id, + cached, + disable_shell_integration, + ) + .await }; match result { @@ -352,8 +394,13 @@ impl SshBackend { pty_config: &PtyConfig, connection_id: Option, cached: Option, + disable_shell_integration: bool, ) -> anyhow::Result<(C::Channel, Option)> { - let (setup, new_setup) = if let Some(cached) = cached { + let (setup, new_setup) = if disable_shell_integration { + // 用户在连接配置里显式关闭了 shell integration:跳过安装,走裸 request_shell 路径, + // 不向 manager 写入任何缓存,确保下次连接如果用户改回开启时还能正常走 setup。 + (None, None) + } else if let Some(cached) = cached { (Some(cached), None) } else { // 首次连接:尝试安装 integration,失败降级为"无 integration"分支。 @@ -753,9 +800,14 @@ mod tests { let (interactive_channel, interactive_state) = MockChannel::new([], false); let mut client = MockClient::new([setup_channel, interactive_channel]); - let result = - SshBackend::prepare_ssh_channel(&mut client, &PtyConfig::default(), Some(42), None) - .await; + let result = SshBackend::prepare_ssh_channel( + &mut client, + &PtyConfig::default(), + Some(42), + None, + false, + ) + .await; let (_channel, new_setup) = result.expect("安装 shell integration 不应占用交互 shell 的 channel"); @@ -797,9 +849,14 @@ mod tests { let (interactive_channel, interactive_state) = MockChannel::new([], false); let mut client = MockClient::new([setup_channel, interactive_channel]); - let result = - SshBackend::prepare_ssh_channel(&mut client, &PtyConfig::default(), Some(42), None) - .await; + let result = SshBackend::prepare_ssh_channel( + &mut client, + &PtyConfig::default(), + Some(42), + None, + false, + ) + .await; let (_channel, new_setup) = result.expect("bash shell wrapper 应通过独立交互 channel 启动"); assert!(new_setup.is_some()); @@ -902,10 +959,15 @@ mod tests { let (interactive_channel, interactive_state) = MockChannel::new([], false); let mut client = MockClient::new([setup_channel, interactive_channel]); - let (_ch, new_setup) = - SshBackend::prepare_ssh_channel(&mut client, &PtyConfig::default(), Some(42), None) - .await - .expect("setup 失败时 prepare_ssh_channel 不应整体失败"); + let (_ch, new_setup) = SshBackend::prepare_ssh_channel( + &mut client, + &PtyConfig::default(), + Some(42), + None, + false, + ) + .await + .expect("setup 失败时 prepare_ssh_channel 不应整体失败"); assert!( new_setup.is_none(), @@ -940,6 +1002,7 @@ mod tests { &PtyConfig::default(), Some(42), Some(cached), + false, ) .await .expect("缓存命中时应直接复用 setup 结果"); @@ -963,6 +1026,34 @@ mod tests { ); } + #[tokio::test] + async fn prepare_ssh_channel_skips_setup_when_disabled() { + // 用户在连接配置里显式关闭 shell integration:不开 setup channel,只开 1 个 interactive + // channel 走裸 PTY + shell;且不向 manager 写入任何缓存。 + let (interactive_channel, interactive_state) = MockChannel::new([], false); + let mut client = MockClient::new([interactive_channel]); + + let (_ch, new_setup) = SshBackend::prepare_ssh_channel( + &mut client, + &PtyConfig::default(), + Some(42), + None, + true, + ) + .await + .expect("禁用 shell integration 时仍应建立 interactive channel"); + + assert!( + new_setup.is_none(), + "禁用路径不应向 manager 写入任何 integration 缓存" + ); + assert_eq!( + recorded_ops(&interactive_state), + vec![ChannelOp::RequestPty, ChannelOp::RequestShell], + "禁用路径只跑 pty + shell,不调 set_env / exec wrapper" + ); + } + #[tokio::test] async fn try_install_shell_integration_times_out_in_ten_seconds() { // 测试里用短 timeout 验证逻辑;生产路径仍走 10s 常量。 @@ -1053,16 +1144,50 @@ mod tests { ); assert!( session_dir.join("zsh/.zshenv").is_file(), - "应写入 zsh session wrapper" + "应写入 zsh session wrapper (.zshenv)" + ); + assert!( + session_dir.join("zsh/.zprofile").is_file(), + "应写入 zsh session wrapper (.zprofile)" ); assert!( session_dir.join("zsh/.zshrc").is_file(), "应写入 zshrc session wrapper" ); + assert!( + session_dir.join("zsh/.zlogin").is_file(), + "应写入 zsh session wrapper (.zlogin)" + ); assert!( session_dir.join("bash/.bashrc").is_file(), "应写入 bash session wrapper" ); + + let zshrc_wrapper = + fs::read_to_string(session_dir.join("zsh/.zshrc")).expect("应读取 zshrc wrapper"); + assert!( + zshrc_wrapper.contains("shell_integration.sh"), + ".zshrc wrapper 应在末尾 source integration: {zshrc_wrapper}" + ); + assert!( + zshrc_wrapper.contains("ZDOTDIR=\"${ONETCLI_ORIG_ZDOTDIR:-$HOME}\""), + ".zshrc wrapper 应在末尾还原 ZDOTDIR: {zshrc_wrapper}" + ); + + let bashrc_wrapper = + fs::read_to_string(session_dir.join("bash/.bashrc")).expect("应读取 bashrc wrapper"); + assert!( + bashrc_wrapper.contains("ONETCLI_LOGIN_SIMULATED"), + ".bashrc wrapper 应包含 ONETCLI_LOGIN_SIMULATED guard 模拟 login chain: {bashrc_wrapper}" + ); + assert!( + bashrc_wrapper.contains("/etc/profile"), + ".bashrc wrapper 应模拟 login shell 加载 /etc/profile: {bashrc_wrapper}" + ); + assert!( + bashrc_wrapper.contains(".bash_profile"), + ".bashrc wrapper 应模拟 login shell 尝试 ~/.bash_profile: {bashrc_wrapper}" + ); assert_eq!( fs::read_to_string(&bashrc_path).expect("应保留用户 bashrc"), "# user bashrc\n" @@ -1130,6 +1255,189 @@ mod tests { let _ = fs::remove_dir_all(&temp_dir); } + #[cfg(unix)] + #[test] + fn bash_wrapper_runs_bash_profile_chain_and_integration() { + if Command::new("bash").arg("--version").output().is_err() { + eprintln!("跳过 bash wrapper 测试:当前环境未安装 bash"); + return; + } + let temp_dir = std::env::temp_dir().join(format!( + "onetcli-bash-wrapper-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system time should be after unix epoch") + .as_nanos() + )); + fs::create_dir_all(&temp_dir).expect("应创建临时目录"); + + let home_dir = temp_dir.join("home"); + fs::create_dir_all(&home_dir).expect("应创建 home 目录"); + fs::write( + home_dir.join(".bash_profile"), + "export __ONETCLI_BASH_PROFILE_LOADED=1\n", + ) + .expect("应写入用户 .bash_profile"); + fs::write( + home_dir.join(".bashrc"), + "[[ $- != *i* ]] && return\nexport __ONETCLI_USER_BASHRC=1\n", + ) + .expect("应写入用户 .bashrc"); + + let script = "export __ONETCLI_INTEGRATION_LOADED=1\n"; + let command = build_shell_integration_setup_script( + script, + "42", + "__TEST_OK__", + "__HOME__=", + "__SESSION__=", + "__SHELL__=", + ); + let setup = Command::new("sh") + .arg("-c") + .arg(&command) + .env("HOME", &home_dir) + .output() + .expect("应执行 setup 脚本"); + assert!( + setup.status.success(), + "setup 脚本应成功: {}", + String::from_utf8_lossy(&setup.stderr) + ); + + let wrapper = home_dir.join(".config/onetcli/sessions/42/bash/.bashrc"); + let output = Command::new("bash") + .arg("--rcfile") + .arg(&wrapper) + .arg("-i") + .arg("-c") + .arg( + "echo profile=$__ONETCLI_BASH_PROFILE_LOADED \ + rc=$__ONETCLI_USER_BASHRC \ + integration=$__ONETCLI_INTEGRATION_LOADED \ + login=$ONETCLI_LOGIN_SIMULATED", + ) + .env("HOME", &home_dir) + .env("PS1", "$ ") + .output() + .expect("应执行 bash wrapper"); + + assert!( + output.status.success(), + "bash wrapper 应成功执行: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("profile=1"), + "bash wrapper 应模拟 login shell 加载 .bash_profile,实际: {stdout}" + ); + assert!( + stdout.contains("rc=1"), + "bash wrapper 应显式 source 用户 .bashrc,实际: {stdout}" + ); + assert!( + stdout.contains("integration=1"), + "bash wrapper 应在末尾 source shell integration,实际: {stdout}" + ); + assert!( + stdout.contains("login=1"), + "bash wrapper 应设置 ONETCLI_LOGIN_SIMULATED guard,实际: {stdout}" + ); + + let _ = fs::remove_dir_all(&temp_dir); + } + + #[cfg(unix)] + #[test] + fn zsh_wrapper_loads_user_files_integration_and_restores_zdotdir() { + if Command::new("zsh").arg("--version").output().is_err() { + eprintln!("跳过 zsh wrapper 测试:当前环境未安装 zsh"); + return; + } + let temp_dir = std::env::temp_dir().join(format!( + "onetcli-zsh-wrapper-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system time should be after unix epoch") + .as_nanos() + )); + fs::create_dir_all(&temp_dir).expect("应创建临时目录"); + + let home_dir = temp_dir.join("home"); + fs::create_dir_all(&home_dir).expect("应创建 home 目录"); + fs::write(home_dir.join(".zshenv"), "export __ONETCLI_USER_ZSHENV=1\n") + .expect("应写入用户 .zshenv"); + fs::write(home_dir.join(".zshrc"), "export __ONETCLI_USER_ZSHRC=1\n") + .expect("应写入用户 .zshrc"); + + let script = "export __ONETCLI_INTEGRATION_LOADED=1\n"; + let command = build_shell_integration_setup_script( + script, + "42", + "__TEST_OK__", + "__HOME__=", + "__SESSION__=", + "__SHELL__=", + ); + let setup = Command::new("sh") + .arg("-c") + .arg(&command) + .env("HOME", &home_dir) + .output() + .expect("应执行 setup 脚本"); + assert!( + setup.status.success(), + "setup 脚本应成功: {}", + String::from_utf8_lossy(&setup.stderr) + ); + + let zsh_dir = home_dir.join(".config/onetcli/sessions/42/zsh"); + let output = Command::new("zsh") + .arg("-i") + .arg("-c") + .arg( + "echo zshenv=$__ONETCLI_USER_ZSHENV \ + zshrc=$__ONETCLI_USER_ZSHRC \ + integration=$__ONETCLI_INTEGRATION_LOADED \ + zdotdir=$ZDOTDIR", + ) + .env("HOME", &home_dir) + .env("ZDOTDIR", &zsh_dir) + .env("ONETCLI_ORIG_ZDOTDIR", &home_dir) + .output() + .expect("应执行 zsh wrapper"); + + assert!( + output.status.success(), + "zsh wrapper 应成功执行: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("zshenv=1"), + "zsh wrapper 应通过 fan-out 加载用户 .zshenv,实际: {stdout}" + ); + assert!( + stdout.contains("zshrc=1"), + "zsh wrapper 应通过 fan-out 加载用户 .zshrc,实际: {stdout}" + ); + assert!( + stdout.contains("integration=1"), + "zsh wrapper 应在 .zshrc 末尾 source shell integration,实际: {stdout}" + ); + assert!( + stdout.contains(&format!("zdotdir={}", home_dir.display())), + "zsh wrapper 应在 .zshrc 末尾把 ZDOTDIR 还原为 $HOME,实际: {stdout}" + ); + + let _ = fs::remove_dir_all(&temp_dir); + } + #[test] fn parse_osc_payload_decodes_recorded_command() { let payload = "1337;Command=Z2l0IHN0YXR1cw=="; diff --git a/crates/terminal/src/terminal.rs b/crates/terminal/src/terminal.rs index f384c354e3..6defe2c8e9 100644 --- a/crates/terminal/src/terminal.rs +++ b/crates/terminal/src/terminal.rs @@ -100,6 +100,8 @@ pub enum TerminalConnectionKind { pub struct SshTerminalConfig { pub ssh_config: SshConnectConfig, pub pty_config: PtyConfig, + /// 关闭 shell integration 注入:走裸 request_shell,失去 OSC 集成。 + pub disable_shell_integration: bool, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -942,6 +944,7 @@ impl Terminal { let config = SshTerminalConfig { ssh_config, pty_config, + disable_shell_integration: ssh_params.disable_shell_integration.unwrap_or(false), }; let ssh_session_manager = Arc::new(SshSessionManager::new(config.ssh_config.clone())); @@ -1232,6 +1235,7 @@ impl Terminal { notify_tx, disconnect_tx, init_commands, + config.disable_shell_integration, ) .await }); diff --git a/crates/terminal_view/locales/terminal_view.yml b/crates/terminal_view/locales/terminal_view.yml index 1f0ee91a4a..46a488aaf3 100644 --- a/crates/terminal_view/locales/terminal_view.yml +++ b/crates/terminal_view/locales/terminal_view.yml @@ -316,6 +316,14 @@ SSH: en: Default working directory zh-CN: 默认工作目录 zh-HK: 默認工作目錄 + disable_shell_integration: + en: Disable Shell Integration + zh-CN: 禁用 Shell 集成 + zh-HK: 禁用 Shell 集成 + disable_shell_integration_desc: + en: Run native login shell without OSC injection (no prompt hook, command recording, or vim mouse) + zh-CN: 走裸 login shell,不注入 OSC(失去命令记录 / prompt hook / vim 鼠标) + zh-HK: 走裸 login shell,不注入 OSC(失去命令記錄 / prompt hook / vim 鼠標) # 其他设置 remark: en: Remark diff --git a/crates/terminal_view/src/ssh_form_window.rs b/crates/terminal_view/src/ssh_form_window.rs index 9ace24e54d..4a9481641f 100644 --- a/crates/terminal_view/src/ssh_form_window.rs +++ b/crates/terminal_view/src/ssh_form_window.rs @@ -169,6 +169,9 @@ pub struct SshFormWindow { // 云同步开关 sync_enabled: bool, + // 关闭 shell integration 注入(走裸 request_shell,失去 OSC 集成) + disable_shell_integration: bool, + is_testing: bool, test_result: Option>, } @@ -335,6 +338,7 @@ impl SshFormWindow { let mut enable_proxy = false; let mut proxy_type = ProxyTypeSelection::default(); let mut sync_enabled = true; // 默认启用云同步 + let mut disable_shell_integration = false; if let Some(ref conn) = config.editing_connection { // 加载同步状态 @@ -392,6 +396,7 @@ impl SshFormWindow { if let Some(ref script) = params.init_script { init_script_input.update(cx, |s, cx| s.set_value(script, window, cx)); } + disable_shell_integration = params.disable_shell_integration.unwrap_or(false); // 加载跳板机设置 if let Some(ref jump) = params.jump_server { @@ -484,6 +489,7 @@ impl SshFormWindow { remark_input, last_tested_signature: None, sync_enabled, + disable_shell_integration, is_testing: false, test_result: None, } @@ -648,6 +654,11 @@ impl SshFormWindow { keepalive_max, default_directory, init_script, + disable_shell_integration: if self.disable_shell_integration { + Some(true) + } else { + None + }, jump_server, proxy, }) @@ -1061,7 +1072,7 @@ impl SshFormWindow { } /// 渲染初始化标签页 - fn render_init_tab(&self) -> impl IntoElement { + fn render_init_tab(&self, cx: &mut Context) -> impl IntoElement { v_flex() .gap_2() .child(self.render_form_row( @@ -1071,6 +1082,28 @@ impl SshFormWindow { .child( self.render_form_row(&t!("SSH.init_script"), Input::new(&self.init_script_input)), ) + .child( + self.render_form_row( + &t!("SSH.disable_shell_integration"), + h_flex() + .gap_2() + .child( + Checkbox::new("disable-shell-integration") + .checked(self.disable_shell_integration) + .on_click(cx.listener(|this, _, _, cx| { + this.disable_shell_integration = + !this.disable_shell_integration; + cx.notify(); + })), + ) + .child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child(t!("SSH.disable_shell_integration_desc").to_string()), + ), + ), + ) } /// 渲染跳板机标签页 @@ -1319,7 +1352,7 @@ impl Render for SshFormWindow { .overflow_y_scroll() .child(match active_tab { 0 => self.render_basic_tab(cx).into_any_element(), - 1 => self.render_init_tab().into_any_element(), + 1 => self.render_init_tab(cx).into_any_element(), 2 => self.render_jump_server_tab(cx).into_any_element(), 3 => self.render_proxy_tab(cx).into_any_element(), 4 => self.render_advanced_tab().into_any_element(), @@ -1392,6 +1425,7 @@ mod tests { keepalive_max: Some(3), default_directory: Some("/tmp".to_string()), init_script: Some("pwd".to_string()), + disable_shell_integration: None, jump_server: None, proxy: None, } diff --git a/crates/terminal_view/src/view.rs b/crates/terminal_view/src/view.rs index 723c5aaa77..1c5c326683 100644 --- a/crates/terminal_view/src/view.rs +++ b/crates/terminal_view/src/view.rs @@ -3124,6 +3124,11 @@ impl TerminalView { /// 当终端启用 SGR 鼠标 + 任意鼠标报告模式时,把按钮按下/释放事件以 SGR 形式 /// 回报给 PTY。返回 true 表示已经处理,调用方应跳过 selection/dismiss/paste 等本地行为。 + /// + /// 特殊穿透:Shift+Left 永远走终端自身的文本选区,不向 TUI 转发 —— 这是 xterm/iTerm/ + /// kitty/wezterm 等的通用约定,让用户在 vim/tmux 等捕获鼠标的应用里仍能复制文本。 + /// 同理 mouse_up 时,如果当前正在终端选区(由 shift+drag 启动),也跳过 release 回报, + /// 避免在 release 阶段 shift 已松开就把 release 事件错发给 TUI、丢掉 selection 收尾。 fn try_report_sgr_mouse_button( &mut self, button: MouseButton, @@ -3132,6 +3137,11 @@ impl TerminalView { pressed: bool, cx: &mut Context, ) -> bool { + if button == MouseButton::Left + && (modifiers.shift || (!pressed && self.mouse_state.selecting)) + { + return false; + } let mode = self.terminal.read(cx).mode(); if !(mode.contains(TermMode::SGR_MOUSE) && mode.intersects(TermMode::MOUSE_MODE)) { return false; @@ -3550,7 +3560,8 @@ impl Render for TerminalView { self.last_alt_screen = alt_screen; if alt_screen && self.last_size.is_some() { tracing::info!(target: "terminal_residue", "nudge_resize fired on enter alt_screen"); - self.terminal.update(cx, |terminal, _| terminal.nudge_resize()); + self.terminal + .update(cx, |terminal, _| terminal.nudge_resize()); } } From 04857c068e52d737eeb91ed7b7dfbfdfbcaf8ba5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=83=A1=E9=A3=9E?= <1835698775@qq.com> Date: Fri, 8 May 2026 10:39:11 +0800 Subject: [PATCH 20/45] =?UTF-8?q?refactor(db):=20=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E5=BA=93=E6=8F=92=E4=BB=B6=E7=9A=84=20capabi?= =?UTF-8?q?lities=20=E6=8E=A5=E5=8F=A3=E5=B9=B6=E9=87=8D=E6=9E=84=E7=9B=B8?= =?UTF-8?q?=E5=85=B3=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将各数据库插件中支持特性的接口替换为统一的 capabilities() 方法 - 修改相关代码逻辑改用 capabilities 字段判断数据库特性支持 - 为 IPC 插件新增通用的 optional_metadata 方法简化元数据请求 - 合并多驱动能力信息,提供统一的能力合并函数 merge_capabilities - 调整 IpcDriverManifest 以支持顶层声明 capabilities 字段并优先使用 - 更新测试以验证 capabilities 的正确性和兼容性 - 移除多余的 supports_xxx、uses_schema_as_database 等老接口实现与调用 - 修改 manager 及 plugin 使用新能力接口,保持行为一致 - 优化插件能力相关测试,确保仍支持函数和存储过程能力默认值 - 修正 IPC 客户端对 UnsupportedMethod 错误的映射,提升错误处理一致性 --- crates/db/src/clickhouse/plugin.rs | 30 +- crates/db/src/duckdb/plugin.rs | 23 +- crates/db/src/ipc/client.rs | 394 ++++++++++++++++-- crates/db/src/ipc/plugin.rs | 263 ++++++++++-- crates/db/src/ipc/registry.rs | 57 ++- crates/db/src/manager.rs | 20 +- crates/db/src/mssql/plugin.rs | 32 +- crates/db/src/mysql/plugin.rs | 26 +- crates/db/src/oracle/plugin.rs | 32 +- crates/db/src/plugin.rs | 69 ++- crates/db/src/plugin_manifest.rs | 3 + crates/db/src/postgresql/plugin.rs | 34 +- crates/db/src/sqlite/plugin.rs | 28 +- crates/db/tests/ipc_concurrency.rs | 1 + crates/db/tests/ipc_duckdb_driver.rs | 1 + crates/db/tests/ipc_mock_driver.rs | 1 + .../src/chatdb/db_connection_selector.rs | 14 +- crates/db_view/src/database_objects_tab.rs | 2 +- crates/db_view/src/database_view_plugin.rs | 87 ++-- crates/db_view/src/db_tree_view.rs | 2 +- crates/db_view/src/sql_editor_view.rs | 5 +- crates/db_view/src/table_designer_tab.rs | 6 +- crates/duckdb_driver/src/metadata.rs | 25 +- crates/duckdb_driver/src/server.rs | 13 +- 24 files changed, 877 insertions(+), 291 deletions(-) diff --git a/crates/db/src/clickhouse/plugin.rs b/crates/db/src/clickhouse/plugin.rs index 50fddcfebd..c555c13796 100644 --- a/crates/db/src/clickhouse/plugin.rs +++ b/crates/db/src/clickhouse/plugin.rs @@ -18,8 +18,8 @@ use crate::manifest_helpers::{ use crate::plugin::{DatabaseOperationRequest, DatabasePlugin, SqlCompletionInfo}; use crate::plugin_manifest::{ DatabaseActionId, DatabaseActionManifest, DatabaseActionPlacement, DatabaseActionToolbarScope, - DatabaseFormFieldType, DatabaseFormKind, DatabaseFormManifest, DatabaseUiCapabilities, - DatabaseUiManifest, + DatabaseCapabilities, DatabaseFormFieldType, DatabaseFormKind, DatabaseFormManifest, + DatabaseUiCapabilities, DatabaseUiManifest, }; use crate::types::*; @@ -505,6 +505,15 @@ impl DatabasePlugin for ClickHousePlugin { format!("`{}`", identifier.replace("`", "``")) } + fn capabilities(&self) -> DatabaseCapabilities { + DatabaseUiCapabilities { + supports_functions: true, + supports_table_engine: true, + table_engines: self.engines(), + ..DatabaseUiCapabilities::default() + } + } + fn ui_manifest(&self) -> DatabaseUiManifest { CLICKHOUSE_UI_MANIFEST.clone() } @@ -688,10 +697,6 @@ impl DatabasePlugin for ClickHousePlugin { } } - fn supports_sequences(&self) -> bool { - false - } - // === Database/Schema Level Operations === fn sql_dialect(&self) -> Box { @@ -1112,10 +1117,6 @@ impl DatabasePlugin for ClickHousePlugin { // === Function Operations === - fn supports_procedures(&self) -> bool { - false - } - async fn list_procedures( &self, _connection: &dyn DbConnection, @@ -1585,6 +1586,15 @@ mod tests { assert_eq!(plugin.quote_identifier("col`umn"), "`col``umn`"); } + #[test] + fn test_capabilities() { + let capabilities = create_plugin().capabilities(); + assert!(capabilities.supports_functions); + assert!(!capabilities.supports_procedures); + assert!(!capabilities.supports_sequences); + assert_eq!(capabilities.table_engines, clickhouse_engine_names()); + } + #[test] fn test_ui_manifest_smoke() { let manifest = create_plugin().ui_manifest(); diff --git a/crates/db/src/duckdb/plugin.rs b/crates/db/src/duckdb/plugin.rs index b58cf3ab84..055f5b6185 100644 --- a/crates/db/src/duckdb/plugin.rs +++ b/crates/db/src/duckdb/plugin.rs @@ -17,7 +17,8 @@ use crate::manifest_helpers::{DatabaseActionDescriptorExt, action, action_with_s use crate::plugin::{DatabaseOperationRequest, DatabasePlugin, SqlCompletionInfo}; use crate::plugin_manifest::{ DatabaseActionId, DatabaseActionManifest, DatabaseActionPlacement, DatabaseActionToolbarScope, - DatabaseFormFieldType, DatabaseFormKind, DatabaseFormManifest, DatabaseUiManifest, + DatabaseCapabilities, DatabaseFormFieldType, DatabaseFormKind, DatabaseFormManifest, + DatabaseUiCapabilities, DatabaseUiManifest, }; use crate::sqlite::SqlitePlugin; use crate::types::*; @@ -753,6 +754,10 @@ impl DatabasePlugin for DuckDbPlugin { DatabaseType::DuckDB } + fn capabilities(&self) -> DatabaseCapabilities { + DatabaseUiCapabilities::default() + } + fn ui_manifest(&self) -> DatabaseUiManifest { DUCKDB_UI_MANIFEST.clone() } @@ -1192,10 +1197,6 @@ impl DatabasePlugin for DuckDbPlugin { }) } - fn supports_functions(&self) -> bool { - false - } - async fn list_functions( &self, connection: &dyn DbConnection, @@ -1212,10 +1213,6 @@ impl DatabasePlugin for DuckDbPlugin { self.sqlite.list_functions_view(connection, database).await } - fn supports_procedures(&self) -> bool { - false - } - async fn list_procedures( &self, connection: &dyn DbConnection, @@ -1468,6 +1465,14 @@ mod tests { DuckDbPlugin::new() } + #[test] + fn test_capabilities() { + let capabilities = create_plugin().capabilities(); + assert!(!capabilities.supports_functions); + assert!(!capabilities.supports_procedures); + assert!(!capabilities.supports_sequences); + } + #[test] fn test_ui_manifest_smoke() { let manifest = create_plugin().ui_manifest(); diff --git a/crates/db/src/ipc/client.rs b/crates/db/src/ipc/client.rs index 9db1ad2664..fda3d5f1c8 100644 --- a/crates/db/src/ipc/client.rs +++ b/crates/db/src/ipc/client.rs @@ -5,54 +5,106 @@ use interprocess::local_socket::{ tokio::{Stream as LocalSocketStream, prelude::*}, }; use ipc::{ - IpcRequest, IpcResponse, + IpcErrorCode, IpcRequest, IpcResponse, framing::{recv_msg_async, send_msg_async}, }; use serde::de::DeserializeOwned; use serde_json::Value; +use std::collections::HashMap; use std::process::Stdio; +use std::sync::Arc; +use std::sync::Mutex as StdMutex; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::time::Duration; -use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::io::{AsyncBufReadExt, BufReader, ReadHalf, WriteHalf, split}; use tokio::process::{Child, Command}; -use tokio::time::{Instant, error::Elapsed, sleep, timeout}; +use tokio::sync::{Mutex, oneshot}; +use tokio::task::JoinHandle; +use tokio::time::{Instant, sleep, timeout}; use tracing::warn; const REQUEST_TIMEOUT_MS: u64 = 30_000; +/// 通过该环境变量把 client 生成的动态 socket 名透传给 driver 子进程。 +/// +/// driver 启动时优先读这个变量来决定 listen 名,从而支持「同 driver 多实例」 +/// 场景:每个 ExternalDbConnection 都拿到独立的 socket,互不冲突。 +pub const SOCKET_ENV_VAR: &str = "ONETCLI_IPC_SOCKET"; + +/// 客户端「写半 / 路由表 / 关闭标记」共享状态。 +/// +/// - `writer`: tokio::sync::Mutex 串行化「写一帧」操作。 +/// - `pending`: std::sync::Mutex 持锁时间极短(insert/remove HashMap),且允许在 +/// Drop 中同步 lock,这是 cancel-safety 的关键。 +/// - `next_id`: AtomicU64,无锁分配 request id。 +/// - `closed`: AtomicBool,reader task 退出后置位,后续 caller 立即失败。 +struct ClientShared { + writer: Mutex>, + pending: StdMutex>>, + next_id: AtomicU64, + closed: AtomicBool, +} + +/// JSON-RPC over IPC client。 +/// +/// 单 stream 多 caller 并发:writer mutex 串行化写,reader task 把响应按 +/// `request_id` 路由到对应 caller 的 oneshot。caller drop / timeout / 写失败 +/// 均不会泄漏 pending 表条目(由 PendingGuard 的 RAII Drop 保证)。 pub struct JsonRpcClient { - child: Option, - stream: LocalSocketStream, - next_id: u64, + shared: Arc, + reader_task: JoinHandle<()>, + /// 子进程 owner;包在 std Mutex 里以让 `JsonRpcClient: Sync`。 + /// `kill_on_drop=true` 保证 child 被 drop 时进程被 OS 回收。 + child: StdMutex>, } impl JsonRpcClient { pub async fn start(driver: &IpcDriverManifest) -> Result { + // command 为空 → 测试 / 预 listen 模式:server 已绑定 transport.name,直接连。 + // 否则 → 生产模式:每实例生成独立 socket 名,通过 env var 透传给 driver。 + let socket_name = if driver.entry.command.trim().is_empty() { + driver.transport.name.clone() + } else { + make_socket_name(driver) + }; + let mut child = if driver.entry.command.trim().is_empty() { None } else { - Some(spawn_driver_process(driver).await?) - }; - let stream = match connect_local_socket( - &driver.transport.name, - driver.transport.connect_timeout_ms(), - ) - .await - { - Ok(stream) => stream, - Err(error) => { - shutdown_child(&mut child).await; - return Err(error); - } + Some(spawn_driver_process(driver, &socket_name).await?) }; + let stream = + match connect_local_socket(&socket_name, driver.transport.connect_timeout_ms()).await { + Ok(stream) => stream, + Err(error) => { + shutdown_child(&mut child).await; + return Err(error); + } + }; + + let (read_half, write_half) = split(stream); + + let shared = Arc::new(ClientShared { + writer: Mutex::new(write_half), + pending: StdMutex::new(HashMap::new()), + next_id: AtomicU64::new(1), + closed: AtomicBool::new(false), + }); + + let reader_shared = Arc::clone(&shared); + let reader_task = tokio::spawn(async move { + reader_loop(read_half, reader_shared).await; + }); + Ok(Self { - child, - stream, - next_id: 1, + shared, + reader_task, + child: StdMutex::new(child), }) } - pub async fn request(&mut self, method: &str, params: Value) -> Result + pub async fn request(&self, method: &str, params: Value) -> Result where T: DeserializeOwned, { @@ -61,27 +113,144 @@ impl JsonRpcClient { .map_err(|error| DbError::query_with_source("invalid external driver response", error)) } - pub async fn request_value(&mut self, method: &str, params: Value) -> Result { - let id = self.next_id; - self.next_id = self.next_id.saturating_add(1); + pub async fn request_value(&self, method: &str, params: Value) -> Result { + if self.shared.closed.load(Ordering::Acquire) { + return Err(DbError::connection("driver disconnected")); + } + + let id = self.shared.next_id.fetch_add(1, Ordering::Relaxed); + let (tx, rx) = oneshot::channel(); + + // 注册 pending,double-check closed 防止 reader 已 drain。 + { + let mut pending = self.shared.pending.lock().expect("pending mutex poisoned"); + if self.shared.closed.load(Ordering::Acquire) { + return Err(DbError::connection("driver disconnected")); + } + pending.insert(id, tx); + } + + // RAII guard:future cancel / timeout / 写失败时从 pending 拿掉 sender,避免泄漏。 + let mut guard = PendingGuard { + shared: Arc::clone(&self.shared), + id, + armed: true, + }; + + // 写一帧;writer mutex 仅在写期间持锁,写完立刻释放允许下个 caller 写。 let request = IpcRequest::new(id, method, params); + let send_result = { + let mut writer = self.shared.writer.lock().await; + send_msg_async(&mut *writer, &request).await + }; + if let Err(error) = send_result { + return Err(DbError::query_with_source( + "failed to write IPC request", + error, + )); + // guard.drop → remove pending entry + } - send_msg_async(&mut self.stream, &request) - .await - .map_err(|error| DbError::query_with_source("failed to write IPC request", error))?; + // 等回复。 + match timeout(Duration::from_millis(REQUEST_TIMEOUT_MS), rx).await { + Ok(Ok(response)) => { + guard.armed = false; // reader 已 take 走 sender,不需再清理 + validate_response(response, id) + } + Ok(Err(_)) => { + guard.armed = false; // reader 关闭已 drain pending + Err(DbError::connection("driver disconnected")) + } + Err(_) => { + // timeout:guard.drop 清理 sender,reader 后到的 response 静默丢弃 + Err(DbError::query("timed out waiting for IPC response")) + } + } + } - timeout( - Duration::from_millis(REQUEST_TIMEOUT_MS), - recv_msg_async::<_, IpcResponse>(&mut self.stream), - ) - .await - .map_err(request_timeout_error)? - .map_err(|error| DbError::query_with_source("failed to read IPC response", error)) - .and_then(|response| validate_response(response, id)) + /// 显式关闭:abort reader,kill + wait child。 + /// 通常在 ExternalDbConnection::disconnect 末尾调用,确保子进程退出后才返回。 + pub async fn shutdown(&self) { + close_and_drain(&self.shared); + self.reader_task.abort(); + let mut taken = { + let mut guard = self.child.lock().expect("child mutex poisoned"); + guard.take() + }; + shutdown_child(&mut taken).await; + } + + /// reader task 是否已经退出(stream EOF / error / abort)。 + /// + /// 一旦置位,所有后续 `request` 调用都会立即得到 disconnected 错误。 + /// ExternalDbConnection 用这个信号触发 client eviction(P0-4)。 + pub fn is_closed(&self) -> bool { + self.shared.closed.load(Ordering::Acquire) + } +} + +impl Drop for JsonRpcClient { + fn drop(&mut self) { + // 兜底:abort reader task,child 由 kill_on_drop=true 自动回收。 + // 不在 Drop 里 await,避免阻塞 runtime。 + self.reader_task.abort(); + } +} + +/// RAII 保护 pending 表条目的 cancel-safety。 +/// +/// 当 caller 的 future 被 cancel / timeout / 写失败时,Drop 自动移除 pending sender, +/// 避免内存泄漏与 reader 找不到对应 caller 时的隐性丢弃。 +struct PendingGuard { + shared: Arc, + id: u64, + armed: bool, +} + +impl Drop for PendingGuard { + fn drop(&mut self) { + if !self.armed { + return; + } + if let Ok(mut pending) = self.shared.pending.lock() { + pending.remove(&self.id); + } + } +} + +fn close_and_drain(shared: &ClientShared) { + shared.closed.store(true, Ordering::Release); + if let Ok(mut pending) = shared.pending.lock() { + pending.clear(); + // oneshot::Sender 被 drop → caller 的 rx 收 RecvError → 报 disconnected + } +} + +async fn reader_loop(mut reader: ReadHalf, shared: Arc) { + /// 无论 reader_loop 怎么退出(EOF / Err / task abort),都标记 closed + drain + /// pending,把所有 caller 唤醒为 disconnected。 + struct CloseGuard { + shared: Arc, + } + impl Drop for CloseGuard { + fn drop(&mut self) { + close_and_drain(&self.shared); + } } + let _guard = CloseGuard { + shared: Arc::clone(&shared), + }; - pub async fn shutdown(&mut self) { - shutdown_child(&mut self.child).await; + while let Ok(response) = recv_msg_async::<_, IpcResponse>(&mut reader).await { + let sender = match shared.pending.lock() { + Ok(mut pending) => pending.remove(&response.request_id), + Err(_) => break, // pending mutex poisoned — 走 CloseGuard 兜底 + }; + if let Some(sender) = sender { + // caller 已超时 / cancel drop 了 rx 时 send 失败 — 静默忽略 + let _ = sender.send(response); + } + // 找不到 sender:caller 已 timeout / cancel,response 静默丢弃 } } @@ -101,6 +270,9 @@ fn validate_response(response: IpcResponse, expected_id: u64) -> Result) { } } -fn request_timeout_error(error: Elapsed) -> DbError { - DbError::query_with_source("timed out waiting for IPC response", error) +/// 为 driver 生成本次启动的最终 socket 名。 +/// +/// 使用短前缀避免 macOS `sockaddr_un.sun_path` 容量限制。 +fn make_socket_name(driver: &IpcDriverManifest) -> String { + format!( + "onetcli-{}-{}.sock", + driver.id, + uuid::Uuid::new_v4().simple() + ) } -async fn spawn_driver_process(driver: &IpcDriverManifest) -> Result { +/// 构造 driver 启动 Command,设置 `ONETCLI_IPC_SOCKET` env var 把动态 socket +/// 名透传给子进程。抽出独立函数便于在 Drop / multi-instance 测试中验证 env。 +fn build_driver_command(driver: &IpcDriverManifest, socket_name: &str) -> Command { let mut command = Command::new(&driver.entry.command); command .args(&driver.entry.args) + .env(SOCKET_ENV_VAR, socket_name) .current_dir(driver.command_working_dir()) + // 关键:确保 client 异常 drop 时子进程被回收,不变孤儿。 + // 详见 P0-3 改造 — 仅 `Child::kill().await` 不足以应对 panic / runtime abort 场景。 + .kill_on_drop(true) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::piped()); + command +} +async fn spawn_driver_process( + driver: &IpcDriverManifest, + socket_name: &str, +) -> Result { + let mut command = build_driver_command(driver, socket_name); let mut child = command.spawn().map_err(|error| { DbError::connection_with_source( format!("failed to start external driver '{}'", driver.id), @@ -227,4 +419,122 @@ mod tests { assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("boom")); } + + #[test] + fn maps_unsupported_method_to_not_supported() { + let response = IpcResponse::error(7, IpcErrorCode::UnsupportedMethod, "missing"); + let result = validate_response(response, 7); + assert!(matches!(result, Err(DbError::NotSupported(message)) if message == "missing")); + } + + #[test] + fn make_socket_name_generates_distinct_names_with_manifest_prefix() { + let driver = make_test_manifest("driver.sock"); + let first = make_socket_name(&driver); + let second = make_socket_name(&driver); + + assert_ne!(first, second); + assert!(first.starts_with("onetcli-socket-test-")); + assert!(second.starts_with("onetcli-socket-test-")); + assert!(first.ends_with(".sock")); + assert!(second.ends_with(".sock")); + } + + fn make_test_manifest(socket_name: &str) -> IpcDriverManifest { + IpcDriverManifest { + id: "socket-test".into(), + name: "Socket Test".into(), + description: String::new(), + version: String::new(), + entry: crate::ipc::registry::IpcDriverEntry { + command: "sleep".into(), + args: vec!["30".into()], + working_dir: None, + }, + transport: crate::ipc::registry::IpcDriverTransport::local_socket(socket_name), + dialect: Default::default(), + capabilities: None, + ui: Default::default(), + manifest_dir: std::path::PathBuf::from("/tmp"), + } + } +} + +#[cfg(all(test, unix))] +mod lifecycle_tests { + use super::*; + use crate::ipc::registry::{IpcDriverEntry, IpcDriverManifest, IpcDriverTransport}; + use std::path::PathBuf; + use std::time::Duration; + + /// 构造一个跑 `sleep 30` 的 manifest,作为「永远不会主动退出」的 driver 占位。 + fn make_sleep_manifest() -> IpcDriverManifest { + IpcDriverManifest { + id: "lifecycle-test".into(), + name: "Lifecycle Test".into(), + description: String::new(), + version: String::new(), + entry: IpcDriverEntry { + command: "sleep".into(), + args: vec!["30".into()], + working_dir: None, + }, + transport: IpcDriverTransport::local_socket("onetcli-lifecycle-test.sock"), + dialect: Default::default(), + capabilities: None, + ui: Default::default(), + manifest_dir: PathBuf::from("/tmp"), + } + } + + /// 通过 `kill -0 ` 检测 unix 进程是否仍存活。 + fn process_alive(pid: u32) -> bool { + std::process::Command::new("kill") + .args(["-0", &pid.to_string()]) + .output() + .map(|o| o.status.success()) + .unwrap_or(false) + } + + /// 兜底回收:测试失败时也不要把 sleep 进程留给 CI。 + fn force_kill(pid: u32) { + let _ = std::process::Command::new("kill") + .args(["-9", &pid.to_string()]) + .status(); + } + + #[tokio::test] + async fn spawn_driver_process_kills_child_when_handle_drops() { + let manifest = make_sleep_manifest(); + let socket_name = manifest.transport.name.clone(); + let child = spawn_driver_process(&manifest, &socket_name) + .await + .expect("spawn driver child process"); + let pid = child.id().expect("child pid should be available"); + + assert!( + process_alive(pid), + "child should be alive immediately after spawn" + ); + + drop(child); + + // 给 OS 至多 2 秒时间发送信号并清理 zombie。 + let mut reaped = false; + for _ in 0..20 { + if !process_alive(pid) { + reaped = true; + break; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + + if !reaped { + force_kill(pid); + } + assert!( + reaped, + "child pid={pid} should be killed within 2s after Child handle drops" + ); + } } diff --git a/crates/db/src/ipc/plugin.rs b/crates/db/src/ipc/plugin.rs index 6c5284f3c9..2dc70ac517 100644 --- a/crates/db/src/ipc/plugin.rs +++ b/crates/db/src/ipc/plugin.rs @@ -8,7 +8,7 @@ use crate::ipc::connection::ExternalDbConnection; use crate::ipc::protocol::{database_metadata_params, table_metadata_params}; use crate::ipc::registry::{EXTERNAL_DRIVER_ID_PARAM, IpcDriverManifest, IpcDriverRegistry}; use crate::plugin::{DatabasePlugin, SqlCompletionInfo}; -use crate::plugin_manifest::DatabaseUiManifest; +use crate::plugin_manifest::{DatabaseCapabilities, DatabaseUiCapabilities, DatabaseUiManifest}; use crate::types::*; use anyhow::{Result, anyhow}; use async_trait::async_trait; @@ -57,6 +57,22 @@ impl ExternalDatabasePlugin { )), } } + + async fn optional_metadata( + &self, + connection: &dyn DbConnection, + method: &str, + params: serde_json::Value, + ) -> Result> + where + T: serde::de::DeserializeOwned, + { + match self.metadata(connection, method, params).await { + Ok(value) => Ok(Some(value)), + Err(error) if is_not_supported(&error) => Ok(None), + Err(error) => Err(error), + } + } } impl Default for ExternalDatabasePlugin { @@ -120,29 +136,20 @@ impl DatabasePlugin for ExternalDatabasePlugin { .await { Ok(databases) => Ok(databases), - Err(_) => Ok(names_to_databases(self.list_databases(connection).await?)), + Err(error) if is_not_supported(&error) => { + Ok(names_to_databases(self.list_databases(connection).await?)) + } + Err(error) => Err(error), } } - fn supports_schema(&self) -> bool { - self.registry - .drivers() - .iter() - .any(|driver| driver.dialect.supports_schema) - } - - fn uses_schema_as_database(&self) -> bool { - self.registry - .drivers() - .iter() - .any(|driver| driver.dialect.uses_schema_as_database) - } - - fn supports_sequences(&self) -> bool { - self.registry - .drivers() - .iter() - .any(|driver| driver.dialect.supports_sequences) + fn capabilities(&self) -> DatabaseCapabilities { + merge_capabilities( + self.registry + .drivers() + .iter() + .map(IpcDriverManifest::effective_capabilities), + ) } fn sql_dialect(&self) -> Box { @@ -301,20 +308,89 @@ impl DatabasePlugin for ExternalDatabasePlugin { )) } + async fn list_foreign_keys( + &self, + connection: &dyn DbConnection, + database: &str, + schema: Option, + table: &str, + ) -> Result> { + Ok(self + .optional_metadata( + connection, + "metadata.list_foreign_keys", + table_metadata_params(database, schema, table), + ) + .await? + .unwrap_or_default()) + } + + async fn list_table_triggers( + &self, + connection: &dyn DbConnection, + database: &str, + schema: Option, + table: &str, + ) -> Result> { + Ok(self + .optional_metadata( + connection, + "metadata.list_table_triggers", + table_metadata_params(database, schema, table), + ) + .await? + .unwrap_or_default()) + } + + async fn list_table_checks( + &self, + connection: &dyn DbConnection, + database: &str, + schema: Option, + table: &str, + ) -> Result> { + Ok(self + .optional_metadata( + connection, + "metadata.list_table_checks", + table_metadata_params(database, schema, table), + ) + .await? + .unwrap_or_default()) + } + async fn list_functions( &self, - _connection: &dyn DbConnection, - _database: &str, + connection: &dyn DbConnection, + database: &str, ) -> Result> { - Ok(Vec::new()) + Ok(self + .optional_metadata( + connection, + "metadata.list_functions", + database_metadata_params(database, None), + ) + .await? + .unwrap_or_default()) } async fn list_functions_view( &self, - _connection: &dyn DbConnection, - _database: &str, + connection: &dyn DbConnection, + database: &str, ) -> Result { - Ok(ObjectView::default()) + let rows = self + .list_functions(connection, database) + .await? + .into_iter() + .map(|function| vec![function.name, function.return_type.unwrap_or_default()]) + .collect(); + Ok(object_view( + DbNodeType::Function, + "Functions", + vec!["Name", "Return Type"], + rows, + )) } fn ui_manifest(&self) -> DatabaseUiManifest { @@ -327,51 +403,110 @@ impl DatabasePlugin for ExternalDatabasePlugin { async fn list_procedures( &self, - _connection: &dyn DbConnection, - _database: &str, + connection: &dyn DbConnection, + database: &str, ) -> Result> { - Ok(Vec::new()) + Ok(self + .optional_metadata( + connection, + "metadata.list_procedures", + database_metadata_params(database, None), + ) + .await? + .unwrap_or_default()) } async fn list_procedures_view( &self, - _connection: &dyn DbConnection, - _database: &str, + connection: &dyn DbConnection, + database: &str, ) -> Result { - Ok(ObjectView::default()) + let rows = self + .list_procedures(connection, database) + .await? + .into_iter() + .map(|procedure| vec![procedure.name, procedure.parameters.join(", ")]) + .collect(); + Ok(object_view( + DbNodeType::Procedure, + "Procedures", + vec!["Name", "Parameters"], + rows, + )) } async fn list_triggers( &self, - _connection: &dyn DbConnection, - _database: &str, + connection: &dyn DbConnection, + database: &str, ) -> Result> { - Ok(Vec::new()) + Ok(self + .optional_metadata( + connection, + "metadata.list_triggers", + database_metadata_params(database, None), + ) + .await? + .unwrap_or_default()) } async fn list_triggers_view( &self, - _connection: &dyn DbConnection, - _database: &str, + connection: &dyn DbConnection, + database: &str, ) -> Result { - Ok(ObjectView::default()) + let rows = self + .list_triggers(connection, database) + .await? + .into_iter() + .map(|trigger| vec![trigger.name, trigger.table_name, trigger.event]) + .collect(); + Ok(object_view( + DbNodeType::Trigger, + "Triggers", + vec!["Name", "Table", "Event"], + rows, + )) } async fn list_sequences( &self, - _connection: &dyn DbConnection, - _database: &str, - _schema: Option, + connection: &dyn DbConnection, + database: &str, + schema: Option, ) -> Result> { - Ok(Vec::new()) + Ok(self + .optional_metadata( + connection, + "metadata.list_sequences", + database_metadata_params(database, schema), + ) + .await? + .unwrap_or_default()) } async fn list_sequences_view( &self, - _connection: &dyn DbConnection, - _database: &str, + connection: &dyn DbConnection, + database: &str, ) -> Result { - Ok(ObjectView::default()) + let rows = self + .list_sequences(connection, database, None) + .await? + .into_iter() + .map(|sequence| { + vec![ + sequence.name, + sequence.increment.unwrap_or_default().to_string(), + ] + }) + .collect(); + Ok(object_view( + DbNodeType::Sequence, + "Sequences", + vec!["Name", "Increment"], + rows, + )) } fn build_column_definition(&self, column: &ColumnInfo, include_name: bool) -> String { @@ -516,6 +651,42 @@ fn names_to_databases(names: Vec) -> Vec { .collect() } +fn is_not_supported(error: &anyhow::Error) -> bool { + error + .downcast_ref::() + .is_some_and(|error| matches!(error, DbError::NotSupported(_))) +} + +fn merge_capabilities( + capabilities: impl IntoIterator, +) -> DatabaseCapabilities { + capabilities + .into_iter() + .fold(DatabaseUiCapabilities::default(), |mut merged, current| { + merged.supports_schema |= current.supports_schema; + merged.uses_schema_as_database |= current.uses_schema_as_database; + merged.supports_sequences |= current.supports_sequences; + merged.supports_functions |= current.supports_functions; + merged.supports_procedures |= current.supports_procedures; + merged.supports_triggers |= current.supports_triggers; + merged.supports_table_engine |= current.supports_table_engine; + merged.supports_table_charset |= current.supports_table_charset; + merged.supports_table_collation |= current.supports_table_collation; + merged.supports_auto_increment |= current.supports_auto_increment; + merged.supports_tablespace |= current.supports_tablespace; + merged.supports_unsigned |= current.supports_unsigned; + merged.supports_enum_values |= current.supports_enum_values; + merged.show_charset_in_column_detail |= current.show_charset_in_column_detail; + merged.show_collation_in_column_detail |= current.show_collation_in_column_detail; + for engine in current.table_engines { + if !merged.table_engines.contains(&engine) { + merged.table_engines.push(engine); + } + } + merged + }) +} + fn object_view( db_node_type: DbNodeType, title: impl Into, diff --git a/crates/db/src/ipc/registry.rs b/crates/db/src/ipc/registry.rs index 7ebf800c1c..db08d659b5 100644 --- a/crates/db/src/ipc/registry.rs +++ b/crates/db/src/ipc/registry.rs @@ -1,5 +1,5 @@ use crate::connection::DbError; -use crate::plugin_manifest::DatabaseUiManifest; +use crate::plugin_manifest::{DatabaseCapabilities, DatabaseUiManifest}; use one_core::storage::get_config_dir; use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; @@ -20,6 +20,8 @@ pub struct IpcDriverManifest { #[serde(default)] pub dialect: IpcDriverDialect, #[serde(default)] + pub capabilities: Option, + #[serde(default)] pub ui: IpcDriverUi, #[serde(skip)] pub manifest_dir: PathBuf, @@ -103,6 +105,23 @@ impl IpcDriverManifest { .unwrap_or_else(|| self.manifest_dir.clone()) } + pub fn effective_capabilities(&self) -> DatabaseCapabilities { + let mut capabilities = self + .ui + .form + .as_ref() + .map(|manifest| manifest.capabilities.clone()) + .unwrap_or_else(|| DatabaseCapabilities { + supports_functions: true, + supports_procedures: true, + ..DatabaseCapabilities::default() + }); + capabilities.supports_schema |= self.dialect.supports_schema; + capabilities.supports_sequences |= self.dialect.supports_sequences; + capabilities.uses_schema_as_database |= self.dialect.uses_schema_as_database; + self.capabilities.clone().unwrap_or(capabilities) + } + fn validate(&self) -> Result<(), DbError> { if self.id.trim().is_empty() || self.name.trim().is_empty() { return Err(DbError::connection( @@ -244,4 +263,40 @@ mod tests { assert_eq!(registry.drivers().len(), 1); assert_eq!(registry.find("demo").unwrap().name, "Demo"); } + + #[test] + fn parses_top_level_capabilities() { + let manifest: IpcDriverManifest = serde_json::from_str( + r#"{"id":"demo","name":"Demo","entry":{"command":"python3"},"transport":{"name":"demo.sock"},"dialect":{"supports_schema":false},"capabilities":{"supports_schema":true,"supports_functions":true}}"#, + ) + .unwrap(); + + let capabilities = manifest.effective_capabilities(); + assert!(capabilities.supports_schema); + assert!(capabilities.supports_functions); + } + + #[test] + fn falls_back_to_legacy_dialect_capabilities() { + let manifest: IpcDriverManifest = serde_json::from_str( + r#"{"id":"demo","name":"Demo","entry":{"command":"python3"},"transport":{"name":"demo.sock"},"dialect":{"supports_schema":true,"supports_sequences":true}}"#, + ) + .unwrap(); + + let capabilities = manifest.effective_capabilities(); + assert!(capabilities.supports_schema); + assert!(capabilities.supports_sequences); + assert!(capabilities.supports_functions); + assert!(capabilities.supports_procedures); + } + + #[test] + fn falls_back_to_legacy_ui_form_capabilities() { + let manifest: IpcDriverManifest = serde_json::from_str( + r#"{"id":"demo","name":"Demo","entry":{"command":"python3"},"transport":{"name":"demo.sock"},"ui":{"form":{"schema_version":1,"capabilities":{"supports_triggers":true},"forms":[],"actions":{"actions":[]}}}}"#, + ) + .unwrap(); + + assert!(manifest.effective_capabilities().supports_triggers); + } } diff --git a/crates/db/src/manager.rs b/crates/db/src/manager.rs index 9b613d71f0..3e832ce2e0 100644 --- a/crates/db/src/manager.rs +++ b/crates/db/src/manager.rs @@ -11,6 +11,7 @@ use crate::mssql::MsSqlPlugin; use crate::mysql::MySqlPlugin; use crate::oracle::OraclePlugin; use crate::plugin::DatabasePlugin; +use crate::plugin_manifest::DatabaseCapabilities; use crate::postgresql::PostgresPlugin; use crate::sqlite::SqlitePlugin; use crate::{ @@ -1550,20 +1551,11 @@ impl GlobalDbState { }) } - /// Check if database type supports schemas - pub fn supports_schema(&self, database_type: &DatabaseType) -> bool { + pub fn capabilities(&self, database_type: &DatabaseType) -> DatabaseCapabilities { self.db_manager .get_plugin(database_type) - .map(|plugin| plugin.supports_schema()) - .unwrap_or(false) - } - - /// Check if database type uses schemas as top-level nodes (like Oracle) - pub fn uses_schema_as_database(&self, database_type: &DatabaseType) -> bool { - self.db_manager - .get_plugin(database_type) - .map(|plugin| plugin.uses_schema_as_database()) - .unwrap_or(false) + .map(|plugin| plugin.capabilities()) + .unwrap_or_default() } /// List schemas in a database (with caching) @@ -1986,7 +1978,7 @@ impl GlobalDbState { let view = match node.node_type { DbNodeType::Connection => { if node.children_loaded { - if plugin.uses_schema_as_database() { + if plugin.capabilities().uses_schema_as_database { plugin.list_schemas_view(&*conn, &database).await.ok() } else { plugin.list_databases_view(&*conn).await.ok() @@ -1996,7 +1988,7 @@ impl GlobalDbState { } } DbNodeType::Database => { - if plugin.supports_schema() { + if plugin.capabilities().supports_schema { plugin.list_schemas_view(&*conn, &database).await.ok() } else { plugin.list_tables_view(&*conn, &database, None).await.ok() diff --git a/crates/db/src/mssql/plugin.rs b/crates/db/src/mssql/plugin.rs index d4218ccfa7..a2b97c3a43 100644 --- a/crates/db/src/mssql/plugin.rs +++ b/crates/db/src/mssql/plugin.rs @@ -21,8 +21,8 @@ use crate::mssql::connection::MssqlDbConnection; use crate::plugin::{DatabasePlugin, SqlCompletionInfo}; use crate::plugin_manifest::{ DatabaseActionId, DatabaseActionManifest, DatabaseActionPlacement, DatabaseActionToolbarScope, - DatabaseFormFieldType, DatabaseFormKind, DatabaseFormManifest, DatabaseUiCapabilities, - DatabaseUiManifest, FormSelectOption, ReferenceDataKind, + DatabaseCapabilities, DatabaseFormFieldType, DatabaseFormKind, DatabaseFormManifest, + DatabaseUiCapabilities, DatabaseUiManifest, FormSelectOption, ReferenceDataKind, }; use crate::types::*; @@ -588,6 +588,18 @@ impl DatabasePlugin for MsSqlPlugin { format!("[{}]", identifier.replace("]", "]]")) } + fn capabilities(&self) -> DatabaseCapabilities { + DatabaseUiCapabilities { + supports_schema: true, + supports_sequences: true, + supports_functions: true, + supports_procedures: true, + supports_triggers: true, + supports_table_collation: true, + ..DatabaseUiCapabilities::default() + } + } + fn ui_manifest(&self) -> DatabaseUiManifest { MSSQL_UI_MANIFEST.clone() } @@ -885,14 +897,6 @@ impl DatabasePlugin for MsSqlPlugin { } } - fn supports_schema(&self) -> bool { - true - } - - fn supports_sequences(&self) -> bool { - true - } - fn sql_dialect(&self) -> Box { Box::new(sqlparser::dialect::MsSqlDialect {}) } @@ -2555,15 +2559,15 @@ mod tests { } #[test] - fn test_supports_schema() { + fn test_capabilities_support_schema() { let plugin = create_plugin(); - assert!(plugin.supports_schema()); + assert!(plugin.capabilities().supports_schema); } #[test] - fn test_supports_sequences() { + fn test_capabilities_support_sequences() { let plugin = create_plugin(); - assert!(plugin.supports_sequences()); + assert!(plugin.capabilities().supports_sequences); } #[test] diff --git a/crates/db/src/mysql/plugin.rs b/crates/db/src/mysql/plugin.rs index 9c4fed62ef..c9f58f5a44 100644 --- a/crates/db/src/mysql/plugin.rs +++ b/crates/db/src/mysql/plugin.rs @@ -15,10 +15,10 @@ use crate::mysql::connection::MysqlDbConnection; use crate::plugin::{DatabasePlugin, SqlCompletionInfo}; use crate::plugin_manifest::{ DatabaseActionDescriptor, DatabaseActionId, DatabaseActionManifest, DatabaseActionPlacement, - DatabaseActionTarget, DatabaseActionToolbarScope, DatabaseFormField, DatabaseFormFieldType, - DatabaseFormKind, DatabaseFormManifest, DatabaseFormTab, DatabaseUiCapabilities, - DatabaseUiManifest, FormDefaultRule, FormSelectOption, FormValueCondition, FormVisibilityRule, - ReferenceDataKind, + DatabaseActionTarget, DatabaseActionToolbarScope, DatabaseCapabilities, DatabaseFormField, + DatabaseFormFieldType, DatabaseFormKind, DatabaseFormManifest, DatabaseFormTab, + DatabaseUiCapabilities, DatabaseUiManifest, FormDefaultRule, FormSelectOption, + FormValueCondition, FormVisibilityRule, ReferenceDataKind, }; use crate::types::*; @@ -1004,6 +1004,24 @@ impl DatabasePlugin for MySqlPlugin { }.with_standard_sql() } + fn capabilities(&self) -> DatabaseCapabilities { + DatabaseUiCapabilities { + supports_functions: true, + supports_procedures: true, + supports_triggers: true, + supports_table_engine: true, + supports_table_charset: true, + supports_table_collation: true, + supports_auto_increment: true, + supports_unsigned: true, + supports_enum_values: true, + show_charset_in_column_detail: true, + show_collation_in_column_detail: true, + table_engines: self.engines(), + ..DatabaseUiCapabilities::default() + } + } + fn ui_manifest(&self) -> DatabaseUiManifest { MYSQL_UI_MANIFEST.clone() } diff --git a/crates/db/src/oracle/plugin.rs b/crates/db/src/oracle/plugin.rs index eb16d94334..c5f340f8cb 100644 --- a/crates/db/src/oracle/plugin.rs +++ b/crates/db/src/oracle/plugin.rs @@ -22,8 +22,8 @@ use crate::oracle::connection::OracleDbConnection; use crate::plugin::{DatabasePlugin, SqlCompletionInfo}; use crate::plugin_manifest::{ DatabaseActionId, DatabaseActionManifest, DatabaseActionPlacement, DatabaseActionToolbarScope, - DatabaseFormFieldType, DatabaseFormKind, DatabaseFormManifest, DatabaseUiCapabilities, - DatabaseUiManifest, + DatabaseCapabilities, DatabaseFormFieldType, DatabaseFormKind, DatabaseFormManifest, + DatabaseUiCapabilities, DatabaseUiManifest, }; use crate::types::*; @@ -485,6 +485,18 @@ impl DatabasePlugin for OraclePlugin { format!("\"{}\"", identifier.replace("\"", "\"\"")) } + fn capabilities(&self) -> DatabaseCapabilities { + DatabaseUiCapabilities { + uses_schema_as_database: true, + supports_sequences: true, + supports_functions: true, + supports_procedures: true, + supports_triggers: true, + supports_tablespace: true, + ..DatabaseUiCapabilities::default() + } + } + fn ui_manifest(&self) -> DatabaseUiManifest { ORACLE_UI_MANIFEST.clone() } @@ -493,10 +505,6 @@ impl DatabasePlugin for OraclePlugin { Box::new(sqlparser::dialect::OracleDialect {}) } - fn supports_sequences(&self) -> bool { - true - } - fn supports_rowid(&self) -> bool { true } @@ -581,10 +589,6 @@ impl DatabasePlugin for OraclePlugin { }) } - fn uses_schema_as_database(&self) -> bool { - true - } - async fn list_schemas( &self, connection: &dyn DbConnection, @@ -2146,15 +2150,15 @@ mod tests { } #[test] - fn test_supports_sequences() { + fn test_capabilities_support_sequences() { let plugin = create_plugin(); - assert!(plugin.supports_sequences()); + assert!(plugin.capabilities().supports_sequences); } #[test] - fn test_supports_schema() { + fn test_capabilities_do_not_support_schema() { let plugin = create_plugin(); - assert!(!plugin.supports_schema()); + assert!(!plugin.capabilities().supports_schema); } #[test] diff --git a/crates/db/src/plugin.rs b/crates/db/src/plugin.rs index 7ae220ea83..913cae770d 100644 --- a/crates/db/src/plugin.rs +++ b/crates/db/src/plugin.rs @@ -9,7 +9,8 @@ use crate::import_export::{ }, }; use crate::plugin_manifest::{ - DatabaseUiCapabilities, DatabaseUiManifest, FormSelectOption, ReferenceDataKind, + DatabaseCapabilities, DatabaseUiCapabilities, DatabaseUiManifest, FormSelectOption, + ReferenceDataKind, }; use crate::streaming_parser::StreamingSqlParser; use crate::types::*; @@ -142,22 +143,6 @@ pub trait DatabasePlugin: Send + Sync { connection: &dyn DbConnection, ) -> Result>; - /// Whether this database supports schemas (e.g., PostgreSQL, MSSQL) - fn supports_schema(&self) -> bool { - false - } - - /// Whether this database uses schemas as top-level nodes instead of databases. - /// Oracle uses this because it connects via service_name and then lists schemas (users). - fn uses_schema_as_database(&self) -> bool { - false - } - - /// Whether this database supports sequences (e.g., PostgreSQL, Oracle, MSSQL) - fn supports_sequences(&self) -> bool { - false - } - /// Whether this database supports rowid for row identification (e.g., Oracle, SQLite) fn supports_rowid(&self) -> bool { false @@ -425,10 +410,6 @@ pub trait DatabasePlugin: Send + Sync { // === Function Operations === - fn supports_functions(&self) -> bool { - true - } - async fn list_functions( &self, connection: &dyn DbConnection, @@ -441,23 +422,17 @@ pub trait DatabasePlugin: Send + Sync { database: &str, ) -> Result; - fn supports_procedures(&self) -> bool { - true + fn capabilities(&self) -> DatabaseCapabilities { + DatabaseUiCapabilities { + supports_functions: true, + supports_procedures: true, + table_engines: self.engines(), + ..DatabaseUiCapabilities::default() + } } fn ui_manifest(&self) -> DatabaseUiManifest { - DatabaseUiManifest { - capabilities: DatabaseUiCapabilities { - supports_schema: self.supports_schema(), - uses_schema_as_database: self.uses_schema_as_database(), - supports_sequences: self.supports_sequences(), - supports_functions: self.supports_functions(), - supports_procedures: self.supports_procedures(), - table_engines: self.engines(), - ..DatabaseUiCapabilities::default() - }, - ..DatabaseUiManifest::default() - } + DatabaseUiManifest::default() } fn resolve_reference_data( @@ -569,7 +544,7 @@ pub trait DatabasePlugin: Send + Sync { let id = &node.id; let schemas; let mut metadata: HashMap = HashMap::new(); - if self.uses_schema_as_database() { + if self.capabilities().uses_schema_as_database { schemas = self.list_schemas(connection, "").await?; metadata.insert("database".to_string(), "".to_string()); } else { @@ -693,8 +668,10 @@ pub trait DatabasePlugin: Send + Sync { } nodes.push(views_folder); + let capabilities = self.capabilities(); + // Functions folder - if self.supports_functions() { + if capabilities.supports_functions { let functions = self .list_functions(connection, database) .await @@ -730,7 +707,7 @@ pub trait DatabasePlugin: Send + Sync { } // Procedures folder - if self.supports_procedures() { + if capabilities.supports_procedures { let procedures = self .list_procedures(connection, database) .await @@ -766,7 +743,7 @@ pub trait DatabasePlugin: Send + Sync { } // Sequences folder (only for databases that support sequences) - if self.supports_sequences() { + if capabilities.supports_sequences { let sequences = self .list_sequences(connection, database, schema) .await @@ -848,14 +825,14 @@ pub trait DatabasePlugin: Send + Sync { let id = &node.id; match node.node_type { DbNodeType::Connection => { - if self.uses_schema_as_database() { + if self.capabilities().uses_schema_as_database { self.build_schema_tree(connection, node).await } else { self.build_database_tree(connection, node).await } } DbNodeType::Database => { - if self.supports_schema() { + if self.capabilities().supports_schema { self.build_schema_tree(connection, node).await } else { self.build_database_or_schema_children(connection, node, None) @@ -2814,6 +2791,16 @@ mod tests { use sqlparser::dialect::MySqlDialect; use sqlparser::parser::Parser; + // ==================== capabilities tests ==================== + + #[test] + fn default_capabilities_support_functions_and_procedures() { + let plugin = MySqlPlugin::new(); + let capabilities = DatabasePlugin::capabilities(&plugin); + assert!(capabilities.supports_functions); + assert!(capabilities.supports_procedures); + } + // ==================== is_query_stmt tests (AST-based) ==================== #[test] diff --git a/crates/db/src/plugin_manifest.rs b/crates/db/src/plugin_manifest.rs index cc64549b30..620badfc61 100644 --- a/crates/db/src/plugin_manifest.rs +++ b/crates/db/src/plugin_manifest.rs @@ -24,6 +24,7 @@ impl Default for DatabaseUiManifest { } #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] pub struct DatabaseUiCapabilities { pub supports_schema: bool, pub uses_schema_as_database: bool, @@ -43,6 +44,8 @@ pub struct DatabaseUiCapabilities { pub table_engines: Vec, } +pub type DatabaseCapabilities = DatabaseUiCapabilities; + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum DatabaseFormKind { Connection, diff --git a/crates/db/src/postgresql/plugin.rs b/crates/db/src/postgresql/plugin.rs index 0c6c90a376..ab75a66cdd 100644 --- a/crates/db/src/postgresql/plugin.rs +++ b/crates/db/src/postgresql/plugin.rs @@ -20,8 +20,8 @@ use crate::manifest_helpers::{ use crate::plugin::{DatabasePlugin, SqlCompletionInfo}; use crate::plugin_manifest::{ DatabaseActionId, DatabaseActionManifest, DatabaseActionPlacement, DatabaseActionToolbarScope, - DatabaseFormFieldType, DatabaseFormKind, DatabaseFormManifest, DatabaseUiCapabilities, - DatabaseUiManifest, + DatabaseCapabilities, DatabaseFormFieldType, DatabaseFormKind, DatabaseFormManifest, + DatabaseUiCapabilities, DatabaseUiManifest, }; use crate::postgresql::connection::PostgresDbConnection; use crate::types::*; @@ -681,6 +681,20 @@ impl DatabasePlugin for PostgresPlugin { format!("\"{}\"", identifier.replace("\"", "\"\"")) } + fn capabilities(&self) -> DatabaseCapabilities { + DatabaseUiCapabilities { + supports_schema: true, + supports_sequences: true, + supports_functions: true, + supports_procedures: true, + supports_triggers: true, + supports_table_charset: true, + supports_table_collation: true, + supports_tablespace: true, + ..DatabaseUiCapabilities::default() + } + } + fn ui_manifest(&self) -> DatabaseUiManifest { POSTGRESQL_UI_MANIFEST.clone() } @@ -997,14 +1011,6 @@ impl DatabasePlugin for PostgresPlugin { } } - fn supports_schema(&self) -> bool { - true - } - - fn supports_sequences(&self) -> bool { - true - } - fn sql_dialect(&self) -> Box { Box::new(sqlparser::dialect::PostgreSqlDialect {}) } @@ -2226,15 +2232,15 @@ mod tests { } #[test] - fn test_supports_schema() { + fn test_capabilities_support_schema() { let plugin = create_plugin(); - assert!(plugin.supports_schema()); + assert!(plugin.capabilities().supports_schema); } #[test] - fn test_supports_sequences() { + fn test_capabilities_support_sequences() { let plugin = create_plugin(); - assert!(plugin.supports_sequences()); + assert!(plugin.capabilities().supports_sequences); } #[test] diff --git a/crates/db/src/sqlite/plugin.rs b/crates/db/src/sqlite/plugin.rs index 8cdbac90fd..00b62dfbdb 100644 --- a/crates/db/src/sqlite/plugin.rs +++ b/crates/db/src/sqlite/plugin.rs @@ -15,8 +15,8 @@ use crate::manifest_helpers::{DatabaseActionDescriptorExt, action, action_with_s use crate::plugin::{DatabasePlugin, SqlCompletionInfo}; use crate::plugin_manifest::{ DatabaseActionId, DatabaseActionManifest, DatabaseActionPlacement, DatabaseActionToolbarScope, - DatabaseFormFieldType, DatabaseFormKind, DatabaseFormManifest, DatabaseUiCapabilities, - DatabaseUiManifest, + DatabaseCapabilities, DatabaseFormFieldType, DatabaseFormKind, DatabaseFormManifest, + DatabaseUiCapabilities, DatabaseUiManifest, }; use crate::sqlite::SqliteDbConnection; use crate::types::*; @@ -572,6 +572,13 @@ impl DatabasePlugin for SqlitePlugin { DatabaseType::SQLite } + fn capabilities(&self) -> DatabaseCapabilities { + DatabaseUiCapabilities { + supports_auto_increment: true, + ..DatabaseUiCapabilities::default() + } + } + fn ui_manifest(&self) -> DatabaseUiManifest { SQLITE_UI_MANIFEST.clone() } @@ -1013,10 +1020,6 @@ impl DatabasePlugin for SqlitePlugin { }) } - fn supports_functions(&self) -> bool { - false - } - async fn list_functions( &self, _connection: &dyn DbConnection, @@ -1042,10 +1045,6 @@ impl DatabasePlugin for SqlitePlugin { }) } - fn supports_procedures(&self) -> bool { - false - } - async fn list_procedures( &self, _connection: &dyn DbConnection, @@ -1460,6 +1459,15 @@ mod tests { assert_eq!(plugin.quote_identifier("col\"umn"), "\"col\"\"umn\""); } + #[test] + fn test_capabilities() { + let capabilities = create_plugin().capabilities(); + assert!(capabilities.supports_auto_increment); + assert!(!capabilities.supports_functions); + assert!(!capabilities.supports_procedures); + assert!(!capabilities.supports_sequences); + } + #[test] fn test_ui_manifest_smoke() { let manifest = create_plugin().ui_manifest(); diff --git a/crates/db/tests/ipc_concurrency.rs b/crates/db/tests/ipc_concurrency.rs index fdce037d3a..7ec9814bc8 100644 --- a/crates/db/tests/ipc_concurrency.rs +++ b/crates/db/tests/ipc_concurrency.rs @@ -39,6 +39,7 @@ fn make_manifest(socket_name: String) -> IpcDriverManifest { working_dir: None, }, dialect: Default::default(), + capabilities: None, ui: Default::default(), transport: IpcDriverTransport::local_socket(socket_name), manifest_dir: PathBuf::new(), diff --git a/crates/db/tests/ipc_duckdb_driver.rs b/crates/db/tests/ipc_duckdb_driver.rs index 314fbe21af..f93403deb7 100644 --- a/crates/db/tests/ipc_duckdb_driver.rs +++ b/crates/db/tests/ipc_duckdb_driver.rs @@ -53,6 +53,7 @@ async fn duckdb_driver_ipc_full_integration() { working_dir: None, }, dialect: Default::default(), + capabilities: None, ui: Default::default(), transport: IpcDriverTransport::local_socket(socket), manifest_dir: temp.path().to_path_buf(), diff --git a/crates/db/tests/ipc_mock_driver.rs b/crates/db/tests/ipc_mock_driver.rs index 83bac13b18..9b81779df2 100644 --- a/crates/db/tests/ipc_mock_driver.rs +++ b/crates/db/tests/ipc_mock_driver.rs @@ -36,6 +36,7 @@ async fn external_connection_uses_mock_local_socket_driver() { working_dir: None, }, dialect: Default::default(), + capabilities: None, ui: Default::default(), transport: IpcDriverTransport::local_socket(socket_name), manifest_dir: PathBuf::new(), diff --git a/crates/db_view/src/chatdb/db_connection_selector.rs b/crates/db_view/src/chatdb/db_connection_selector.rs index 66242ed202..a2d6e36f52 100644 --- a/crates/db_view/src/chatdb/db_connection_selector.rs +++ b/crates/db_view/src/chatdb/db_connection_selector.rs @@ -223,14 +223,6 @@ impl DbConnectionSelector { )) } - pub fn supports_schema(&self) -> bool { - self.supports_schema - } - - pub fn uses_schema_as_database(&self) -> bool { - self.uses_schema_as_database - } - fn snapshot(&self) -> DbConnectionSelectorSnapshot { DbConnectionSelectorSnapshot { connections: self.connections.clone(), @@ -427,9 +419,9 @@ impl DbConnectionSelector { self.loading_schemas = false; let global_db_state = cx.global::().clone(); - self.supports_schema = global_db_state.supports_schema(&connection.database_type); - self.uses_schema_as_database = - global_db_state.uses_schema_as_database(&connection.database_type); + let capabilities = global_db_state.capabilities(&connection.database_type); + self.supports_schema = capabilities.supports_schema; + self.uses_schema_as_database = capabilities.uses_schema_as_database; self.register_connection(connection.id.clone(), cx); self.emit_selection(cx); diff --git a/crates/db_view/src/database_objects_tab.rs b/crates/db_view/src/database_objects_tab.rs index 7315033a07..fac8de84f7 100644 --- a/crates/db_view/src/database_objects_tab.rs +++ b/crates/db_view/src/database_objects_tab.rs @@ -899,7 +899,7 @@ impl DatabaseObjects { }); let toolbar_buttons = - build_toolbar_buttons_for(database_type, node_type, data_db_node_type); + build_toolbar_buttons_for(database_type, node_type, data_db_node_type, cx); for btn_config in toolbar_buttons { let button = match btn_config.button_type { diff --git a/crates/db_view/src/database_view_plugin.rs b/crates/db_view/src/database_view_plugin.rs index caa167a8e2..53ca970517 100644 --- a/crates/db_view/src/database_view_plugin.rs +++ b/crates/db_view/src/database_view_plugin.rs @@ -1,18 +1,10 @@ use db::DbNodeType; -use db::clickhouse::ClickHousePlugin; -use db::duckdb::DuckDbPlugin; -use db::ipc::ExternalDatabasePlugin; use db::ipc::{EXTERNAL_DRIVER_ID_PARAM, IpcDriverManifest, IpcDriverRegistry}; -use db::mssql::MsSqlPlugin; -use db::mysql::MySqlPlugin; -use db::oracle::OraclePlugin; use db::plugin::DatabasePlugin; use db::plugin_manifest::{ DatabaseActionDescriptor, DatabaseActionId, DatabaseActionPlacement, - DatabaseActionToolbarScope, DatabaseFormKind, DatabaseUiManifest, + DatabaseActionToolbarScope, DatabaseCapabilities, DatabaseFormKind, DatabaseUiManifest, }; -use db::postgresql::PostgresPlugin; -use db::sqlite::SqlitePlugin; use gpui::{App, AppContext, Entity, Window}; use gpui_component::IconName; use one_core::storage::DatabaseType; @@ -201,13 +193,15 @@ impl Default for ColumnEditorCapabilities { struct ManifestDatabaseViewPlugin { database_type: DatabaseType, manifest: DatabaseUiManifest, + capabilities: DatabaseCapabilities, } impl ManifestDatabaseViewPlugin { - fn new(database_type: DatabaseType) -> Self { + fn new(database_type: DatabaseType, plugin: &dyn DatabasePlugin) -> Self { Self { database_type, - manifest: build_ui_manifest(database_type), + manifest: plugin.ui_manifest(), + capabilities: plugin.capabilities(), } } @@ -304,15 +298,15 @@ impl ManifestDatabaseViewPlugin { } fn get_table_designer_capabilities(&self) -> TableDesignerCapabilities { - to_table_designer_capabilities(&self.manifest.capabilities) + to_table_designer_capabilities(&self.capabilities) } fn get_engines(&self) -> Vec { - self.manifest.capabilities.table_engines.clone() + self.capabilities.table_engines.clone() } fn get_column_editor_capabilities(&self) -> ColumnEditorCapabilities { - to_column_editor_capabilities(&self.manifest.capabilities) + to_column_editor_capabilities(&self.capabilities) } fn build_context_menu(&self, node_id: &str, node_type: DbNodeType) -> Vec { @@ -404,8 +398,16 @@ impl ManifestDatabaseViewPlugin { } } -fn manifest_plugin(database_type: DatabaseType) -> ManifestDatabaseViewPlugin { - ManifestDatabaseViewPlugin::new(database_type) +fn manifest_plugin( + database_type: DatabaseType, + cx: &impl AppContext, +) -> ManifestDatabaseViewPlugin { + let plugin = cx.read_global::(|state, _| { + state + .get_plugin(&database_type) + .expect("database plugin should exist") + }); + ManifestDatabaseViewPlugin::new(database_type, plugin.as_ref()) } fn action_to_context_menu_item( @@ -567,7 +569,7 @@ pub fn create_connection_form_for( window: &mut Window, cx: &mut App, ) -> Entity { - manifest_plugin(database_type).create_connection_form(window, cx) + manifest_plugin(database_type, cx).create_connection_form(window, cx) } pub fn create_external_connection_form_for( @@ -654,7 +656,7 @@ pub fn create_database_editor_view_for_new( window: &mut Window, cx: &mut App, ) -> Entity { - manifest_plugin(database_type).create_database_editor_view(connection_id, window, cx) + manifest_plugin(database_type, cx).create_database_editor_view(connection_id, window, cx) } pub fn create_database_editor_view_for_edit_type( @@ -664,7 +666,7 @@ pub fn create_database_editor_view_for_edit_type( window: &mut Window, cx: &mut App, ) -> Entity { - manifest_plugin(database_type).create_database_editor_view_for_edit( + manifest_plugin(database_type, cx).create_database_editor_view_for_edit( connection_id, database_name, window, @@ -679,7 +681,7 @@ pub fn create_schema_editor_view_for( window: &mut Window, cx: &mut App, ) -> Option> { - manifest_plugin(database_type).create_schema_editor_view( + manifest_plugin(database_type, cx).create_schema_editor_view( connection_id, database_name, window, @@ -691,8 +693,9 @@ pub fn build_context_menu_for( database_type: DatabaseType, node_id: &str, node_type: DbNodeType, + cx: &impl AppContext, ) -> Vec { - let mut items = manifest_plugin(database_type).build_context_menu(node_id, node_type); + let mut items = manifest_plugin(database_type, cx).build_context_menu(node_id, node_type); append_er_diagram_item(&mut items, node_id, node_type); items } @@ -701,8 +704,9 @@ pub fn build_toolbar_buttons_for( database_type: DatabaseType, node_type: DbNodeType, data_node_type: DbNodeType, + cx: &impl AppContext, ) -> Vec { - manifest_plugin(database_type).build_toolbar_buttons(node_type, data_node_type) + manifest_plugin(database_type, cx).build_toolbar_buttons(node_type, data_node_type) } fn append_er_diagram_item(items: &mut Vec, node_id: &str, node_type: DbNodeType) { @@ -722,29 +726,20 @@ fn append_er_diagram_item(items: &mut Vec, node_id: &str, node_ pub fn get_table_designer_capabilities_for( database_type: DatabaseType, + cx: &impl AppContext, ) -> TableDesignerCapabilities { - manifest_plugin(database_type).get_table_designer_capabilities() + manifest_plugin(database_type, cx).get_table_designer_capabilities() } -pub fn get_column_editor_capabilities_for(database_type: DatabaseType) -> ColumnEditorCapabilities { - manifest_plugin(database_type).get_column_editor_capabilities() +pub fn get_column_editor_capabilities_for( + database_type: DatabaseType, + cx: &impl AppContext, +) -> ColumnEditorCapabilities { + manifest_plugin(database_type, cx).get_column_editor_capabilities() } -pub fn get_engines_for(database_type: DatabaseType) -> Vec { - manifest_plugin(database_type).get_engines() -} - -fn build_ui_manifest(database_type: DatabaseType) -> DatabaseUiManifest { - match database_type { - DatabaseType::MySQL => MySqlPlugin::new().ui_manifest(), - DatabaseType::PostgreSQL => PostgresPlugin::new().ui_manifest(), - DatabaseType::MSSQL => MsSqlPlugin::new().ui_manifest(), - DatabaseType::Oracle => OraclePlugin::new().ui_manifest(), - DatabaseType::ClickHouse => ClickHousePlugin::new().ui_manifest(), - DatabaseType::SQLite => SqlitePlugin::new().ui_manifest(), - DatabaseType::DuckDB => DuckDbPlugin::new().ui_manifest(), - DatabaseType::External => ExternalDatabasePlugin::new().ui_manifest(), - } +pub fn get_engines_for(database_type: DatabaseType, cx: &impl AppContext) -> Vec { + manifest_plugin(database_type, cx).get_engines() } fn map_tree_event(action_id: DatabaseActionId, node_id: &str) -> Option { @@ -902,6 +897,12 @@ fn action_id(action: &DatabaseActionDescriptor) -> &'static str { #[cfg(test)] mod tests { use super::*; + use db::mysql::MySqlPlugin; + + fn mysql_manifest_plugin() -> ManifestDatabaseViewPlugin { + let plugin = MySqlPlugin::new(); + ManifestDatabaseViewPlugin::new(DatabaseType::MySQL, &plugin) + } fn has_label(items: &[ContextMenuItem], expected: &str) -> bool { items.iter().any(|item| match item { @@ -915,7 +916,7 @@ mod tests { #[test] fn mysql_table_context_menu_keeps_design_table_action() { - let items = build_context_menu_for(DatabaseType::MySQL, "node-1", DbNodeType::Table); + let items = mysql_manifest_plugin().build_context_menu("node-1", DbNodeType::Table); assert!( has_label(&items, &translate("Table.design_table")), @@ -925,7 +926,7 @@ mod tests { #[test] fn mysql_table_context_menu_keeps_dump_sql_submenu() { - let items = build_context_menu_for(DatabaseType::MySQL, "node-1", DbNodeType::Table); + let items = mysql_manifest_plugin().build_context_menu("node-1", DbNodeType::Table); let dump_submenu = items.iter().find_map(|item| match item { ContextMenuItem::Submenu { label, items, .. } @@ -956,7 +957,7 @@ mod tests { #[test] fn mysql_database_context_menu_restores_legacy_order_and_separators() { - let items = build_context_menu_for(DatabaseType::MySQL, "node-1", DbNodeType::Database); + let items = mysql_manifest_plugin().build_context_menu("node-1", DbNodeType::Database); let labels: Vec = items .iter() diff --git a/crates/db_view/src/db_tree_view.rs b/crates/db_view/src/db_tree_view.rs index d540c82202..5b58fe8529 100644 --- a/crates/db_view/src/db_tree_view.rs +++ b/crates/db_view/src/db_tree_view.rs @@ -2637,7 +2637,7 @@ impl DbTreeView { let is_active = conn_active && (node.node_type != DbNodeType::Database || node.children_loaded); - let menu_items = build_context_menu_for(node.database_type, node_id, node.node_type); + let menu_items = build_context_menu_for(node.database_type, node_id, node.node_type, cx); if !menu_items.is_empty() { // 渲染 plugin 提供的菜单,传入连接激活状态 menu = Self::render_context_menu_items(menu, menu_items, is_active, view, window, cx); diff --git a/crates/db_view/src/sql_editor_view.rs b/crates/db_view/src/sql_editor_view.rs index 23d93ff1d6..9b36d926fa 100644 --- a/crates/db_view/src/sql_editor_view.rs +++ b/crates/db_view/src/sql_editor_view.rs @@ -81,8 +81,9 @@ impl SqlEditorTab { cx.new(|cx| SelectState::new(SearchableVec::new(vec![]), None, window, cx)); let global_state = cx.global::().clone(); - let supports_schema = global_state.supports_schema(&database_type); - let uses_schema_as_database = global_state.uses_schema_as_database(&database_type); + let capabilities = global_state.capabilities(&database_type); + let supports_schema = capabilities.supports_schema; + let uses_schema_as_database = capabilities.uses_schema_as_database; let connection_id_str = connection_id.into(); let should_load_file = file_path.is_some(); diff --git a/crates/db_view/src/table_designer_tab.rs b/crates/db_view/src/table_designer_tab.rs index ef75575776..be16117716 100644 --- a/crates/db_view/src/table_designer_tab.rs +++ b/crates/db_view/src/table_designer_tab.rs @@ -319,11 +319,11 @@ impl TableDesigner { Vec, ColumnEditorCapabilities, ) = { - let engines = get_engines_for(config.database_type) + let engines = get_engines_for(config.database_type, cx) .into_iter() .map(|name| EngineSelectItem { name }) .collect(); - let capabilities = get_column_editor_capabilities_for(config.database_type); + let capabilities = get_column_editor_capabilities_for(config.database_type, cx); (engines, capabilities) }; @@ -1175,7 +1175,7 @@ impl TableDesigner { } fn render_options(&self, cx: &Context) -> AnyElement { - let capabilities = get_table_designer_capabilities_for(self.config.database_type); + let capabilities = get_table_designer_capabilities_for(self.config.database_type, cx); v_flex() .size_full() diff --git a/crates/duckdb_driver/src/metadata.rs b/crates/duckdb_driver/src/metadata.rs index e9abcfc841..75c00c7da4 100644 --- a/crates/duckdb_driver/src/metadata.rs +++ b/crates/duckdb_driver/src/metadata.rs @@ -54,17 +54,24 @@ struct ViewInfo { comment: Option, } -pub fn handle(session: &DuckDbSession, method: &str, params: &Value) -> Result { +pub fn handle(session: &DuckDbSession, method: &str, params: &Value) -> Result> { let connection = session.connection()?; match method { - "metadata.list_databases" => Ok(json!(vec!["main"])), - "metadata.list_databases_detailed" => to_value(list_databases_detailed()), - "metadata.list_schemas" => to_value(list_schemas(connection)?), - "metadata.list_tables" => to_value(list_tables(connection, params)?), - "metadata.list_columns" => to_value(list_columns(connection, params)?), - "metadata.list_indexes" => to_value(list_indexes(connection, params)?), - "metadata.list_views" => to_value(list_views(connection, params)?), - _ => anyhow::bail!("unsupported metadata method: {method}"), + "metadata.list_databases" => Ok(Some(json!(vec!["main"]))), + "metadata.list_databases_detailed" => to_value(list_databases_detailed()).map(Some), + "metadata.list_schemas" => to_value(list_schemas(connection)?).map(Some), + "metadata.list_tables" => to_value(list_tables(connection, params)?).map(Some), + "metadata.list_columns" => to_value(list_columns(connection, params)?).map(Some), + "metadata.list_indexes" => to_value(list_indexes(connection, params)?).map(Some), + "metadata.list_views" => to_value(list_views(connection, params)?).map(Some), + "metadata.list_functions" + | "metadata.list_procedures" + | "metadata.list_triggers" + | "metadata.list_sequences" + | "metadata.list_foreign_keys" + | "metadata.list_table_triggers" + | "metadata.list_table_checks" => Ok(Some(json!([]))), + _ => Ok(None), } } diff --git a/crates/duckdb_driver/src/server.rs b/crates/duckdb_driver/src/server.rs index 1ea2ffb06b..b5aba48ae6 100644 --- a/crates/duckdb_driver/src/server.rs +++ b/crates/duckdb_driver/src/server.rs @@ -45,7 +45,15 @@ async fn handle_connection(mut stream: Stream) -> Result<()> { let response = match handle_request(&mut session, &request) { Ok(result) => IpcResponse::result(request_id, result), - Err(error) => IpcResponse::error(request_id, IpcErrorCode::Internal, error.to_string()), + Err(error) => { + let message = error.to_string(); + let code = if message.starts_with("unsupported method:") { + IpcErrorCode::UnsupportedMethod + } else { + IpcErrorCode::Internal + }; + IpcResponse::error(request_id, code, message) + } }; send_msg_async(&mut stream, &response).await?; @@ -80,7 +88,8 @@ fn handle_request(session: &mut DuckDbSession, request: &IpcRequest) -> Result { - crate::metadata::handle(session, method, &request.params) + crate::metadata::handle(session, method, &request.params)? + .ok_or_else(|| anyhow::anyhow!("unsupported method: {method}")) } method => anyhow::bail!("unsupported method: {method}"), } From 572cdc9b334a1d499e4c29ab0f4753658f712c0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=83=A1=E9=A3=9E?= <1835698775@qq.com> Date: Fri, 8 May 2026 15:21:53 +0800 Subject: [PATCH 21/45] =?UTF-8?q?feat:=20ferrum-flow=E4=BD=BF=E7=94=A8?= =?UTF-8?q?=E5=AE=98=E6=96=B9=E4=BE=9D=E8=B5=96=EF=BC=8C=E4=B8=8D=E5=86=8D?= =?UTF-8?q?fork?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 1 + Cargo.toml | 5 +- crates/db_view/src/er_diagram/mod.rs | 42 +- crates/ferrum-flow/Cargo.toml | 25 - crates/ferrum-flow/README.md | 240 ---- crates/ferrum-flow/examples/basic.rs | 24 - crates/ferrum-flow/examples/basic2.rs | 58 - crates/ferrum-flow/examples/bench.rs | 71 -- crates/ferrum-flow/examples/extension.rs | 77 -- crates/ferrum-flow/examples/plugin.rs | 142 --- crates/ferrum-flow/examples/theme.rs | 85 -- crates/ferrum-flow/src/canvas.rs | 942 -------------- .../ferrum-flow/src/canvas/node_renderer.rs | 182 --- crates/ferrum-flow/src/canvas/port_cache.rs | 117 -- crates/ferrum-flow/src/canvas/types.rs | 51 - crates/ferrum-flow/src/canvas/undo.rs | 341 ------ crates/ferrum-flow/src/command_interop.rs | 208 ---- crates/ferrum-flow/src/edge.rs | 123 -- crates/ferrum-flow/src/graph.rs | 378 ------ crates/ferrum-flow/src/graph/store.rs | 102 -- crates/ferrum-flow/src/lib.rs | 35 - crates/ferrum-flow/src/node.rs | 760 ------------ crates/ferrum-flow/src/plugin.rs | 1089 ----------------- crates/ferrum-flow/src/plugin/sync.rs | 88 -- crates/ferrum-flow/src/plugin/utils.rs | 85 -- crates/ferrum-flow/src/plugin_testing.rs | 142 --- crates/ferrum-flow/src/plugins/align.rs | 166 --- crates/ferrum-flow/src/plugins/background.rs | 206 ---- .../src/plugins/clipboard/clipboard_ops.rs | 172 --- .../src/plugins/clipboard/copied_subgraph.rs | 8 - .../ferrum-flow/src/plugins/clipboard/mod.rs | 10 - .../src/plugins/clipboard/plugin.rs | 58 - .../ferrum-flow/src/plugins/context_menu.rs | 455 ------- crates/ferrum-flow/src/plugins/delete.rs | 278 ----- .../ferrum-flow/src/plugins/edge/command.rs | 163 --- crates/ferrum-flow/src/plugins/edge/mod.rs | 286 ----- crates/ferrum-flow/src/plugins/fit_all.rs | 114 -- .../src/plugins/focus_selection.rs | 63 - crates/ferrum-flow/src/plugins/history.rs | 39 - crates/ferrum-flow/src/plugins/minimap.rs | 443 ------- crates/ferrum-flow/src/plugins/mod.rs | 44 - .../ferrum-flow/src/plugins/node/command.rs | 178 --- .../src/plugins/node/drag_events.rs | 31 - .../src/plugins/node/interaction.rs | 230 ---- crates/ferrum-flow/src/plugins/node/mod.rs | 131 -- .../ferrum-flow/src/plugins/port/command.rs | 158 --- .../src/plugins/port/interaction.rs | 458 ------- crates/ferrum-flow/src/plugins/port/mod.rs | 16 - crates/ferrum-flow/src/plugins/port/utils.rs | 81 -- .../ferrum-flow/src/plugins/port/validator.rs | 111 -- .../src/plugins/select_all_viewport.rs | 68 - .../ferrum-flow/src/plugins/selection/mod.rs | 340 ----- crates/ferrum-flow/src/plugins/snap_guides.rs | 244 ---- crates/ferrum-flow/src/plugins/toast.rs | 172 --- crates/ferrum-flow/src/plugins/viewport.rs | 152 --- .../ferrum-flow/src/plugins/viewport_frame.rs | 166 --- .../ferrum-flow/src/plugins/zoom_controls.rs | 314 ----- crates/ferrum-flow/src/port_screen.rs | 60 - crates/ferrum-flow/src/shared_state.rs | 55 - crates/ferrum-flow/src/theme.rs | 139 --- crates/ferrum-flow/src/viewport.rs | 192 --- 61 files changed, 5 insertions(+), 11209 deletions(-) delete mode 100644 crates/ferrum-flow/Cargo.toml delete mode 100644 crates/ferrum-flow/README.md delete mode 100644 crates/ferrum-flow/examples/basic.rs delete mode 100644 crates/ferrum-flow/examples/basic2.rs delete mode 100644 crates/ferrum-flow/examples/bench.rs delete mode 100644 crates/ferrum-flow/examples/extension.rs delete mode 100644 crates/ferrum-flow/examples/plugin.rs delete mode 100644 crates/ferrum-flow/examples/theme.rs delete mode 100644 crates/ferrum-flow/src/canvas.rs delete mode 100644 crates/ferrum-flow/src/canvas/node_renderer.rs delete mode 100644 crates/ferrum-flow/src/canvas/port_cache.rs delete mode 100644 crates/ferrum-flow/src/canvas/types.rs delete mode 100644 crates/ferrum-flow/src/canvas/undo.rs delete mode 100644 crates/ferrum-flow/src/command_interop.rs delete mode 100644 crates/ferrum-flow/src/edge.rs delete mode 100644 crates/ferrum-flow/src/graph.rs delete mode 100644 crates/ferrum-flow/src/graph/store.rs delete mode 100644 crates/ferrum-flow/src/lib.rs delete mode 100644 crates/ferrum-flow/src/node.rs delete mode 100644 crates/ferrum-flow/src/plugin.rs delete mode 100644 crates/ferrum-flow/src/plugin/sync.rs delete mode 100644 crates/ferrum-flow/src/plugin/utils.rs delete mode 100644 crates/ferrum-flow/src/plugin_testing.rs delete mode 100644 crates/ferrum-flow/src/plugins/align.rs delete mode 100644 crates/ferrum-flow/src/plugins/background.rs delete mode 100644 crates/ferrum-flow/src/plugins/clipboard/clipboard_ops.rs delete mode 100644 crates/ferrum-flow/src/plugins/clipboard/copied_subgraph.rs delete mode 100644 crates/ferrum-flow/src/plugins/clipboard/mod.rs delete mode 100644 crates/ferrum-flow/src/plugins/clipboard/plugin.rs delete mode 100644 crates/ferrum-flow/src/plugins/context_menu.rs delete mode 100644 crates/ferrum-flow/src/plugins/delete.rs delete mode 100644 crates/ferrum-flow/src/plugins/edge/command.rs delete mode 100644 crates/ferrum-flow/src/plugins/edge/mod.rs delete mode 100644 crates/ferrum-flow/src/plugins/fit_all.rs delete mode 100644 crates/ferrum-flow/src/plugins/focus_selection.rs delete mode 100644 crates/ferrum-flow/src/plugins/history.rs delete mode 100644 crates/ferrum-flow/src/plugins/minimap.rs delete mode 100644 crates/ferrum-flow/src/plugins/mod.rs delete mode 100644 crates/ferrum-flow/src/plugins/node/command.rs delete mode 100644 crates/ferrum-flow/src/plugins/node/drag_events.rs delete mode 100644 crates/ferrum-flow/src/plugins/node/interaction.rs delete mode 100644 crates/ferrum-flow/src/plugins/node/mod.rs delete mode 100644 crates/ferrum-flow/src/plugins/port/command.rs delete mode 100644 crates/ferrum-flow/src/plugins/port/interaction.rs delete mode 100644 crates/ferrum-flow/src/plugins/port/mod.rs delete mode 100644 crates/ferrum-flow/src/plugins/port/utils.rs delete mode 100644 crates/ferrum-flow/src/plugins/port/validator.rs delete mode 100644 crates/ferrum-flow/src/plugins/select_all_viewport.rs delete mode 100644 crates/ferrum-flow/src/plugins/selection/mod.rs delete mode 100644 crates/ferrum-flow/src/plugins/snap_guides.rs delete mode 100644 crates/ferrum-flow/src/plugins/toast.rs delete mode 100644 crates/ferrum-flow/src/plugins/viewport.rs delete mode 100644 crates/ferrum-flow/src/plugins/viewport_frame.rs delete mode 100644 crates/ferrum-flow/src/plugins/zoom_controls.rs delete mode 100644 crates/ferrum-flow/src/port_screen.rs delete mode 100644 crates/ferrum-flow/src/shared_state.rs delete mode 100644 crates/ferrum-flow/src/theme.rs delete mode 100644 crates/ferrum-flow/src/viewport.rs diff --git a/Cargo.lock b/Cargo.lock index 868dab6595..ed2e23b43c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3253,6 +3253,7 @@ dependencies = [ [[package]] name = "ferrum-flow" version = "0.2.1" +source = "git+https://github.com/tu6ge/ferrum-flow.git?rev=507cab7505b8dfa6a1e4a121e3c503e3f2f882d6#507cab7505b8dfa6a1e4a121e3c503e3f2f882d6" dependencies = [ "anyhow", "futures", diff --git a/Cargo.toml b/Cargo.toml index 0a8a73a016..13bc73b180 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,8 +23,7 @@ members = [ "crates/er_flow", "crates/terminal", "crates/terminal_view", - "crates/ferrum-flow", - "main", "crates/ssh", "crates/sftp", "crates/sftp_view", "crates/one_ui", "crates/redis_view", "crates/mongodb_view", "crates/remote_file_editor"] + "main", "crates/ssh", "crates/sftp", "crates/sftp_view", "crates/one_ui", "crates/redis_view", "crates/license_tool", "crates/mongodb_view", "crates/remote_file_editor"] resolver = "2" [workspace.package] @@ -157,7 +156,7 @@ interprocess = { version = "2.4.0", features = ["tokio"] } url = "2.5.4" percent-encoding = "2.3.1" global-hotkey = "0.7.0" -ferrum-flow = { path = "crates/ferrum-flow" } +ferrum-flow = { git = "https://github.com/tu6ge/ferrum-flow.git", rev = "507cab7505b8dfa6a1e4a121e3c503e3f2f882d6" } [patch.crates-io] gpui = { git = "https://github.com/zed-industries/zed", rev = "8b5328ca" } diff --git a/crates/db_view/src/er_diagram/mod.rs b/crates/db_view/src/er_diagram/mod.rs index bd0d597b90..b7b81f5ff3 100644 --- a/crates/db_view/src/er_diagram/mod.rs +++ b/crates/db_view/src/er_diagram/mod.rs @@ -4,15 +4,13 @@ mod scroll_pan_plugin; use db::GlobalDbState; use ferrum_flow::{ - BackgroundPlugin, EdgePlugin, FitAllGraphPlugin, FlowCanvas, FlowTheme, Graph, MinimapPlugin, + BackgroundPlugin, EdgePlugin, FitAllGraphPlugin, FlowCanvas, Graph, MinimapPlugin, NodeInteractionPlugin, NodePlugin, ViewportPlugin, ZoomControlsPlugin, }; use crate::er_diagram::pan_mode_plugin::ErDiagramPanModePlugin; use crate::er_diagram::scroll_pan_plugin::ErDiagramScrollPanPlugin; -use er_flow::{ - ErCardTheme, er_flow_theme_from_ui, er_node_renderers_from_theme, graph_from_diagram, -}; +use er_flow::{er_flow_theme_from_ui, er_node_renderers_from_theme, graph_from_diagram}; use gpui::{ App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement as _, IntoElement, ParentElement as _, Render, SharedString, Styled as _, @@ -42,16 +40,9 @@ pub(crate) struct ErDiagramTab { canvas: Option>, loading: bool, error: Option, - theme_snapshot: Option, focus_handle: FocusHandle, } -#[derive(Clone, PartialEq)] -struct ErDiagramThemeSnapshot { - flow_theme: FlowTheme, - card_theme: ErCardTheme, -} - impl ErDiagramTab { pub(crate) fn new( config: ErDiagramConfig, @@ -63,7 +54,6 @@ impl ErDiagramTab { canvas: None, loading: true, error: None, - theme_snapshot: None, focus_handle: cx.focus_handle(), }; tab.reload(window, cx); @@ -108,27 +98,9 @@ impl ErDiagramTab { self.error = Some(err.to_string()); } } - self.theme_snapshot = Some(current_theme_snapshot(cx)); cx.notify(); } - fn sync_canvas_theme(&mut self, cx: &mut Context) { - let Some(canvas) = self.canvas.as_ref() else { - return; - }; - let next_snapshot = current_theme_snapshot(cx); - if self.theme_snapshot.as_ref() == Some(&next_snapshot) { - return; - } - let theme = cx.theme(); - let renderers = er_node_renderers_from_theme(theme); - canvas.update(cx, |canvas, cx| { - canvas.set_theme(next_snapshot.flow_theme.clone(), cx); - canvas.replace_node_renderers(renderers, cx); - }); - self.theme_snapshot = Some(next_snapshot); - } - fn render_loading(&self, cx: &mut Context) -> impl IntoElement { v_flex() .size_full() @@ -248,8 +220,6 @@ fn build_canvas( impl Render for ErDiagramTab { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - self.sync_canvas_theme(cx); - div() .track_focus(&self.focus_handle) .size_full() @@ -266,14 +236,6 @@ impl Render for ErDiagramTab { } } -fn current_theme_snapshot(cx: &mut App) -> ErDiagramThemeSnapshot { - let theme = cx.theme(); - ErDiagramThemeSnapshot { - flow_theme: er_flow_theme_from_ui(theme), - card_theme: ErCardTheme::from_ui_theme(theme), - } -} - impl Focusable for ErDiagramTab { fn focus_handle(&self, _cx: &App) -> FocusHandle { self.focus_handle.clone() diff --git a/crates/ferrum-flow/Cargo.toml b/crates/ferrum-flow/Cargo.toml deleted file mode 100644 index e17528df32..0000000000 --- a/crates/ferrum-flow/Cargo.toml +++ /dev/null @@ -1,25 +0,0 @@ -[package] -name = "ferrum-flow" -version = "0.2.1" -edition = "2024" -license = "Apache-2.0" -authors = ["tu6ge"] -repository = "https://github.com/tu6ge/ferrum-flow" -description = "A high-performance node-based editor framework built with Rust and GPUI." - -[features] -default = [] -## Public `command_interop` test helpers; run `cargo test -p ferrum-flow --features testing`. -testing = [] - -[dependencies] -anyhow = { workspace = true } -gpui = { workspace = true } -# used by background plugin -image = { workspace = true } -# used by background plugin -smallvec = { workspace = true } -serde = { workspace = true, features = ["derive"] } -serde_json = { workspace = true } -futures = { workspace = true, features = ["std"] } -uuid = { workspace = true } diff --git a/crates/ferrum-flow/README.md b/crates/ferrum-flow/README.md deleted file mode 100644 index 3f9523a29b..0000000000 --- a/crates/ferrum-flow/README.md +++ /dev/null @@ -1,240 +0,0 @@ -# FerrumFlow - -A high-performance, extensible node-based editor built with Rust and gpui. -Designed for building visual programming tools, workflow editors, and graph-based UIs. - -**This project is in early stage (alpha), API may change** - -## Features - -- Plugin-based architecture -- Interaction system (drag, pan, select, etc.) -- Undo / Redo (Command pattern) -- Viewport control (zoom & pan) -- Box selection & multi-select -- Node / Port / Edge model -- Custom node rendering system -- Built with performance in mind -- Multi-user collaboration support (by [plugin](https://github.com/tu6ge/ferrum-flow/tree/master/crates/sync_plugin)) - -[![Watch the video](https://img.youtube.com/vi/mimeKsIldog/0.jpg)](https://www.youtube.com/watch?v=mimeKsIldog) - -[GitHub](https://github.com/tu6ge/ferrum-flow) - -## Usage - -```bash -cargo add ferrum-flow -``` - -This is a hello world example: - -```rust -use ferrum_flow::{FlowCanvas, Graph}; -use gpui::{AppContext as _, Application, WindowOptions}; -use serde_json::json; - -fn main() { - Application::new().run(|cx| { - let mut graph = Graph::new(); - - graph - .create_node("default") - .position(100.0, 100.0) - .data(json!({ "label": "Hello World" })) - .build(); - - cx.open_window(WindowOptions::default(), |window, cx| { - cx.new(|ctx| { - FlowCanvas::builder(graph, ctx, window) - .default_plugins() // Includes built-in rendering for nodes, edges, selection, and more. Replace with custom plugins as needed. - .build() - }) - }) - .unwrap(); - }); -} -``` - -For more examples, see the [examples directory](./examples/). - -## Architecture Overview - -The system is designed with clear separation of concerns: - -### Core Concepts - -- Graph - Stores persistent data (nodes, edges, ports) - -- Viewport - Handles zooming and panning - -- Plugin System - Extends behavior (rendering, input handling, etc.) -- Interaction System - Manages ongoing user interactions (dragging, selecting, etc.) - -- Command System - Enables undo/redo support - -### Plugin System - -Plugins are the primary extension mechanism: - -```rust -pub trait Plugin { - fn name(&self) -> &'static str; - - fn setup(&mut self, ctx: &mut InitPluginContext); - - fn on_event(&mut self, event: &FlowEvent, ctx: &mut PluginContext) -> EventResult; - - fn render(&mut self, ctx: &mut RenderContext) -> Option; - - fn priority(&self) -> i32 { - 0 - } - - fn render_layer(&self) -> RenderLayer { - RenderLayer::Overlay - } -} -``` - -**Responsibilities** - -A plugin can: - -- Handle input events -- Start interactions -- Render UI layers -- Modify graph state - -### Interaction System - -Interactions represent ongoing user actions, such as: - -- Node dragging -- Box selection -- Viewport panning - -```rust -pub trait Interaction { - fn on_mouse_move(&mut self, event: &MouseMoveEvent, ctx: &mut PluginContext) -> InteractionResult; - - fn on_mouse_up(&mut self, event: &MouseUpEvent, ctx: &mut PluginContext) -> InteractionResult; - - fn render(&self, ctx: &mut RenderContext) -> Option; -} -``` - -Interaction Lifecycle - -``` -Start → Update → End / Replace -``` - -```rust -pub enum InteractionResult { - Continue, - End, - Replace(Box), -} -``` - -### Command System (Undo / Redo) - -Implements the Command Pattern: - -```rust -pub trait Command { - fn execute(&mut self, ctx: &mut CommandContext); - fn undo(&mut self, ctx: &mut CommandContext); -} -``` - -Built-in Features - -- Undo / Redo stacks -- Composite commands -- Easy integration via PluginContext - -```rust -ctx.execute_command(MyCommand { ... }); -``` - -### Node Rendering - -Rendering is fully customizable via a registry: - -```rust -pub trait NodeRenderer { - fn render(&self, node: &Node, ctx: &mut RenderContext) -> AnyElement; - - // custom render port UI - fn port_render(&self, node: &Node, port: &Port, ctx: &mut RenderContext) -> Option { - // ... default implement - } - - // computing the position of port relative to node - fn port_offset(&self, node: &Node, port: &Port, graph: &Graph) -> Point { - // ... default implement - } -} -``` - -Render example: - -```rust -// Absolute-positioned node card shell: screen origin, zoom-scaled size. -ctx.node_card_shell(node, false, NodeCardVariant::Custom) - .rounded(px(6.0)) - .border(px(1.5)) -``` - -### Graph Model - -```rust -pub struct Node { - id: NodeId, - node_type: String, - x: Pixels, - y: Pixels, - size: Size, - inputs: Vec, - outputs: Vec, - data: serde_json::Value, -} -``` - -🏗️ Creating Nodes (Builder API) - -```rust -graph.create_node("math.add") - .position(100.0, 100.0) - .input() - .output() - .build(); -``` - -### Performance - -Designed to scale to large graphs: - -- Viewport-based rendering (virtualization) -- Layered rendering system -- Interaction-aware rendering (degraded mode during drag) -- Ready for spatial indexing - -### Design Principles - -- Separation of data and interaction -- Plugins over hardcoded behavior -- Explicit state transitions -- Performance-first rendering -- Composable architecture - -## License - -Apache2.0 diff --git a/crates/ferrum-flow/examples/basic.rs b/crates/ferrum-flow/examples/basic.rs deleted file mode 100644 index fd188ea481..0000000000 --- a/crates/ferrum-flow/examples/basic.rs +++ /dev/null @@ -1,24 +0,0 @@ -use ferrum_flow::{FlowCanvas, Graph}; -use gpui::{AppContext as _, Application, WindowOptions}; -use serde_json::json; - -fn main() { - Application::new().run(|cx| { - let mut graph = Graph::new(); - - graph - .create_node("default") - .position(100.0, 100.0) - .data(json!({ "label": "Hello World" })) - .build(); - - cx.open_window(WindowOptions::default(), |window, cx| { - cx.new(|ctx| { - FlowCanvas::builder(graph, ctx, window) - .default_plugins() - .build() - }) - }) - .unwrap(); - }); -} diff --git a/crates/ferrum-flow/examples/basic2.rs b/crates/ferrum-flow/examples/basic2.rs deleted file mode 100644 index 95e5095421..0000000000 --- a/crates/ferrum-flow/examples/basic2.rs +++ /dev/null @@ -1,58 +0,0 @@ -use ferrum_flow::*; -use gpui::{AppContext as _, Application, Size, WindowOptions, px}; -use serde_json::json; - -fn main() { - Application::new().run(|cx| { - let mut graph = Graph::new(); - - graph - .create_node("") - .position(100.0, 100.0) - .output() - .output() - .output_with(PortPosition::Bottom, Size::new(px(20.0), px(20.0))) - .output_at(PortPosition::Bottom) - .data(json!({ "label": "Node 1" })) - .build(); - - graph - .create_node("") - .position(300.0, 400.0) - .input() - .input_at(PortPosition::Top) - .input_at(PortPosition::Top) - .output() - .output_at(PortPosition::Bottom) - .output_at(PortPosition::Bottom) - .data(json!({ "label": "Node 2" })) - .build(); - - graph - .create_node("") - .position(500.0, 500.0) - .input() - .output() - .data(json!({ "label": "Node 3" })) - .build(); - - cx.open_window(WindowOptions::default(), |window, cx| { - cx.new(|ctx| { - FlowCanvas::builder(graph, ctx, window) - .default_plugins() - .plugin(MinimapPlugin::new()) - .plugin(ZoomControlsPlugin::new()) - .plugin(ClipboardPlugin::new()) - .plugin(ContextMenuPlugin::new()) - .plugin(SelectAllViewportPlugin::new()) - .plugin(AlignPlugin::new()) - .plugin(FocusSelectionPlugin::new()) - .plugin(FitAllGraphPlugin::new()) - .plugin(SnapGuidesPlugin::new()) - .plugin(ToastPlugin::new()) - .build() - }) - }) - .unwrap(); - }); -} diff --git a/crates/ferrum-flow/examples/bench.rs b/crates/ferrum-flow/examples/bench.rs deleted file mode 100644 index 8bd367f290..0000000000 --- a/crates/ferrum-flow/examples/bench.rs +++ /dev/null @@ -1,71 +0,0 @@ -use ferrum_flow::*; -use gpui::{AppContext as _, Application, WindowOptions}; -use serde_json::json; - -fn main() { - Application::new().run(|cx| { - let mut graph = Graph::new(); - - for j in 0..100 { - for i in 0..100 { - graph - .create_node("") - .position(200.0 * i as f32, 200.0 * j as f32) - .input() - .output() - .data(json!({ "label": format!("Node {}", i * 100 + j) })) - .build(); - } - } - - let node_ids = graph.nodes().keys().copied().collect::>(); - - generate_chain_edges(&mut graph, node_ids); - - cx.open_window(WindowOptions::default(), |window, cx| { - cx.new(|ctx| { - FlowCanvas::builder(graph, ctx, window) - .plugin(BackgroundPlugin::new()) - .plugin(SelectionPlugin::new()) - .plugin(NodeInteractionPlugin::new()) - .plugin(ViewportPlugin::new()) - .plugin(NodePlugin::new()) - .plugin(PortInteractionPlugin::new()) - .plugin(EdgePlugin::new()) - .plugin(DeletePlugin::new()) - .plugin(HistoryPlugin::new()) - .plugin(MinimapPlugin::new()) - .plugin(ClipboardPlugin::new()) - .plugin(ContextMenuPlugin::new()) - .plugin(SelectAllViewportPlugin::new()) - .plugin(AlignPlugin::new()) - .plugin(FocusSelectionPlugin::new()) - .plugin(ZoomControlsPlugin::new()) - .plugin(SnapGuidesPlugin::new()) - .plugin(ToastPlugin::new()) - //.plugin(FitAllGraphPlugin::new()) - .build() - }) - }) - .unwrap(); - }); -} - -pub fn generate_chain_edges(graph: &mut Graph, node_ids: Vec) { - for window in node_ids.windows(2) { - let from = window[0]; - let to = window[1]; - - let from_node = graph.get_node(&from).unwrap(); - let to_node = graph.get_node(&to).unwrap(); - - let source_port = from_node.outputs()[0]; - let target_port = to_node.inputs()[0]; - - graph - .create_edge() - .source(source_port) - .target(target_port) - .build(); - } -} diff --git a/crates/ferrum-flow/examples/extension.rs b/crates/ferrum-flow/examples/extension.rs deleted file mode 100644 index 95a64678ef..0000000000 --- a/crates/ferrum-flow/examples/extension.rs +++ /dev/null @@ -1,77 +0,0 @@ -use ferrum_flow::*; -use gpui::{ - AnyElement, AppContext as _, Application, Element as _, ParentElement as _, Styled, - WindowOptions, div, rgb, white, -}; -use serde_json::json; - -fn main() { - Application::new().run(|cx| { - let mut graph = Graph::new(); - - graph - .create_node("number") - .position(100.0, 100.0) - .size(300.0, 150.0) - .output() - .data(json!({ "label": "Number Node" })) - .build(); - - graph.create_node("").position(300.0, 400.0).input().build(); - - graph - .create_node("undefined") - .position(500.0, 500.0) - .input() - .output() - .build(); - - cx.open_window(WindowOptions::default(), |window, cx| { - cx.new(|ctx| { - FlowCanvas::builder(graph, ctx, window) - .default_plugins() - .plugin(ZoomControlsPlugin::new()) - .plugin(FocusSelectionPlugin::new()) - .plugin(FitAllGraphPlugin::new()) - .plugin(ClipboardPlugin::new()) - .plugin(ContextMenuPlugin::new()) - .node_renderer("number", NumberNode {}) - .build() - }) - }) - .unwrap(); - }); -} - -pub struct NumberNode; - -impl NodeRenderer for NumberNode { - fn render(&self, node: &Node, ctx: &mut RenderContext) -> AnyElement { - let screen = ctx.world_to_screen(node.point()); - let node_x = screen.x; - let node_y = screen.y; - - div() - .absolute() - .left(node_x) - .top(node_y) - .w(ctx.world_length_to_screen(node.size_ref().width)) - .h(ctx.world_length_to_screen(node.size_ref().height)) - .bg(rgb(0x505078)) - .child(div().child("Number Node").text_color(white())) - .into_any() - } - - fn port_render(&self, node: &Node, port: &Port, ctx: &mut RenderContext) -> Option { - let frame = ctx.port_screen_frame(node, port)?; - Some( - frame - .anchor_div() - .rounded_full() - .border_1() - .border_color(rgb(0x1A192B)) - .bg(white()) - .into_any(), - ) - } -} diff --git a/crates/ferrum-flow/examples/plugin.rs b/crates/ferrum-flow/examples/plugin.rs deleted file mode 100644 index 9f53565608..0000000000 --- a/crates/ferrum-flow/examples/plugin.rs +++ /dev/null @@ -1,142 +0,0 @@ -use ferrum_flow::*; -use gpui::{ - AnyElement, AppContext as _, Application, Element as _, ParentElement as _, Styled, - WindowOptions, div, px, rgb, white, -}; -use serde_json::json; - -/// A beginner-friendly custom plugin example. -/// -/// Run with: -/// `cargo run -p ferrum-flow --example plugin` -fn main() { - Application::new().run(|cx| { - let mut graph = Graph::new(); - - graph - .create_node("default") - .position(120.0, 120.0) - .input() - .output() - .data(json!({ "label": "Base Node" })) - .build(); - - cx.open_window(WindowOptions::default(), |window, cx| { - cx.new(|ctx| { - FlowCanvas::builder(graph, ctx, window) - .default_plugins() - .plugin(StarterPlugin::new()) - .plugin(ToastPlugin::new()) - .build() - }) - }) - .unwrap(); - }); -} - -/// A tiny plugin that demonstrates: -/// 1) plugin state -/// 2) input handling -/// 3) custom overlay rendering -/// 4) mutating graph data through `PluginContext` -struct StarterPlugin { - next_index: usize, - clicks: usize, - show_hud: bool, -} - -impl StarterPlugin { - fn new() -> Self { - Self { - next_index: 1, - clicks: 0, - show_hud: true, - } - } - - fn add_demo_node(&mut self, ctx: &mut PluginContext) { - let i = self.next_index; - self.next_index += 1; - - let x = 120.0 + ((i % 6) as f32) * 180.0; - let y = 280.0 + ((i / 6) as f32) * 140.0; - - ctx.create_node("default") - .position(x, y) - .input() - .output() - .data(json!({ "label": format!("Plugin Node {i}") })) - .build(); - - ctx.emit(FlowEvent::custom(ToastMessage::success(format!( - "Created node #{i} from StarterPlugin" - )))); - } -} - -impl Plugin for StarterPlugin { - fn name(&self) -> &'static str { - "starter_plugin" - } - - fn setup(&mut self, _ctx: &mut InitPluginContext) { - // Put one initial node index behind the first generated node label. - self.next_index = 1; - } - - fn on_event(&mut self, event: &FlowEvent, ctx: &mut PluginContext) -> EventResult { - if let FlowEvent::Input(InputEvent::KeyDown(ev)) = event { - if ev.keystroke.key == "n" { - self.add_demo_node(ctx); - return EventResult::Stop; - } - if ev.keystroke.key == "h" { - self.show_hud = !self.show_hud; - ctx.notify(); - return EventResult::Stop; - } - } - - if let FlowEvent::Input(InputEvent::MouseDown(_)) = event { - self.clicks += 1; - ctx.notify(); - } - - EventResult::Continue - } - - fn render(&mut self, _ctx: &mut RenderContext) -> Option { - if !self.show_hud { - return None; - } - - Some( - div() - .absolute() - .left(px(12.0)) - .top(px(12.0)) - .px_3() - .py_2() - .rounded(px(8.0)) - .bg(rgb(0x001F2937)) - .text_color(white()) - .child(div().text_sm().child("StarterPlugin (custom example)")) - .child( - div() - .text_sm() - .child(format!("Mouse clicks: {}", self.clicks)), - ) - .child(div().text_sm().child("Press N: create node")) - .child(div().text_sm().child("Press H: hide/show this panel")) - .into_any(), - ) - } - - fn priority(&self) -> i32 { - 120 - } - - fn render_layer(&self) -> RenderLayer { - RenderLayer::Overlay - } -} diff --git a/crates/ferrum-flow/examples/theme.rs b/crates/ferrum-flow/examples/theme.rs deleted file mode 100644 index d2ce87221e..0000000000 --- a/crates/ferrum-flow/examples/theme.rs +++ /dev/null @@ -1,85 +0,0 @@ -//! Custom canvas chrome via [`InitPluginContext::theme`] in [`Plugin::setup`]. -use ferrum_flow::*; -use gpui::{AppContext as _, Application, WindowOptions}; -use serde_json::json; - -struct DarkGridThemePlugin; - -impl Plugin for DarkGridThemePlugin { - fn name(&self) -> &'static str { - "dark_grid_theme" - } - - fn setup(&mut self, ctx: &mut InitPluginContext) { - ctx.theme.background = 0x001a1d2a; - ctx.theme.background_grid_dot = 0x003d4559; - ctx.theme.node_card_background = 0x0024283a; - ctx.theme.node_card_border = 0x004a5568; - ctx.theme.node_card_border_selected = 0x00f5a524; - ctx.theme.node_caption_text = 0x00e8eaef; - ctx.theme.default_port_fill = 0x004a5568; - ctx.theme.undefined_node_background = 0x00303845; - ctx.theme.undefined_node_border = 0x00f5a524; - ctx.theme.undefined_node_caption_text = 0x00b8bcc8; - ctx.theme.edge_stroke = 0x0050586b; - ctx.theme.edge_stroke_selected = 0x00f5a524; - ctx.theme.selection_rect_border = 0x006b8cff; - ctx.theme.selection_rect_fill_rgba = 0x6b8cff33; - ctx.theme.port_preview_line = 0x0050586b; - ctx.theme.port_preview_dot = 0x0060809e; - ctx.theme.minimap_background = 0x0018202e; - ctx.theme.minimap_border = 0x004a5568; - ctx.theme.minimap_edge = 0x0050586b; - ctx.theme.minimap_node_fill = 0x0024283a; - ctx.theme.minimap_node_stroke = 0x00607080; - ctx.theme.minimap_viewport_stroke = 0x006b8cff; - ctx.theme.zoom_controls_background = 0x0024283a; - ctx.theme.zoom_controls_border = 0x004a5568; - ctx.theme.zoom_controls_text = 0x00e8eaef; - ctx.theme.context_menu_background = 0x0024283a; - ctx.theme.context_menu_border = 0x004a5568; - ctx.theme.context_menu_text = 0x00e8eaef; - ctx.theme.context_menu_shortcut_text = 0x009098a8; - ctx.theme.context_menu_separator = 0x003d4559; - } -} - -fn main() { - Application::new().run(|cx| { - let mut graph = Graph::new(); - - graph - .create_node("") - .position(100.0, 100.0) - .output() - .output() - .data(json!({ "label": "Themed" })) - .build(); - - cx.open_window(WindowOptions::default(), |window, cx| { - cx.new(|ctx| { - FlowCanvas::builder(graph, ctx, window) - .plugin(DarkGridThemePlugin) - .plugin(MinimapPlugin::new()) - .plugin(SelectionPlugin::new()) - .plugin(NodeInteractionPlugin::new()) - .plugin(ViewportPlugin::new()) - .plugin(ZoomControlsPlugin::new()) - .plugin(BackgroundPlugin::new()) - .plugin(NodePlugin::new()) - .plugin(PortInteractionPlugin::new()) - .plugin(EdgePlugin::new()) - .plugin(ClipboardPlugin::new()) - .plugin(ContextMenuPlugin::new()) - .plugin(SelectAllViewportPlugin::new()) - .plugin(AlignPlugin::new()) - .plugin(FocusSelectionPlugin::new()) - .plugin(FitAllGraphPlugin::new()) - .plugin(DeletePlugin::new()) - .plugin(HistoryPlugin::new()) - .build() - }) - }) - .unwrap(); - }); -} diff --git a/crates/ferrum-flow/src/canvas.rs b/crates/ferrum-flow/src/canvas.rs deleted file mode 100644 index 5717d41523..0000000000 --- a/crates/ferrum-flow/src/canvas.rs +++ /dev/null @@ -1,942 +0,0 @@ -use futures::{StreamExt, channel::mpsc}; -use gpui::*; -use std::cell::Cell; -use std::collections::BTreeMap; -use std::rc::Rc; -use std::time::Duration; - -use crate::{ - BackgroundPlugin, DeletePlugin, EdgePlugin, FlowTheme, GraphChange, HistoryPlugin, - NodeInteractionPlugin, NodePlugin, PortInteractionPlugin, SelectionPlugin, SharedState, - SyncPlugin, SyncPluginContext, ViewportPlugin, - graph::Graph, - plugin::{ - EventResult, FlowEvent, InitPluginContext, InputEvent, Plugin, PluginContext, - PluginRegistry, RenderContext, RenderLayer, invalidate_port_layout_cache_for_graph_change, - }, - viewport::Viewport, -}; - -mod node_renderer; -mod port_cache; -mod types; -mod undo; - -pub use port_cache::PortLayoutCache; - -pub use undo::{Command, CommandContext, CompositeCommand, HistoryProvider, LocalHistory}; - -pub use types::{Interaction, InteractionResult, InteractionState}; - -#[allow(deprecated)] -pub use node_renderer::port_screen_position; -pub use node_renderer::{NodeRenderer, RendererRegistry, default_node_caption}; - -/// Host-side callback for **outbound** [`FlowEvent`]s: invoked synchronously whenever a plugin calls -/// [`PluginContext::emit`](crate::plugin::PluginContext::emit) with the same event that is then -/// enqueued for the internal plugin pipeline ([`FlowCanvas::event_queue`]). -/// -/// # Parent / shell integration -/// -/// This is **not** a GPUI `subscribe` / `observe` stream: you install **one** `FnMut` on the canvas -/// ([`FlowCanvasBuilder::outbound`] or [`FlowCanvas::set_outbound`]). The closure runs on the **UI -/// thread**, **before** the event is pushed onto [`FlowCanvas::event_queue`], and receives a -/// **read-only** reference for inspection ([`FlowEvent::as_custom`]). -/// -/// **Typical patterns** -/// -/// - **Shared counters or queues** — [`std::sync::Arc`] + [`std::sync::atomic::AtomicUsize`] / -/// [`std::sync::Mutex`] / `mpsc` sender; the host view reads them in [`gpui::Render`] (see the -/// `outbound_host` example in this crate). -/// - **Refresh a parent `Entity`** — capture `gpui::Entity` (or a weak handle) in the -/// closure and call [`gpui::Entity::update`] + [`gpui::Context::notify`] after filtering with -/// `as_custom::()`. -/// - **Graph edits without `emit`** — outbound does **not** run for plain [`FlowCanvas::dispatch_command`] -/// unless a plugin later `emit`s; also use [`gpui::Context::observe`] on `Entity` for -/// those cases. -/// -/// ```ignore -/// use ferrum_flow::{FlowCanvas, FlowEvent}; -/// use gpui::{Context, Entity}; -/// -/// fn wire(canvas: &Entity, shell: &Entity, cx: &mut Context) { -/// let shell = shell.clone(); -/// canvas.update(cx, |canvas, cx| { -/// canvas.set_outbound(Some(Box::new(move |ev: &FlowEvent| { -/// if ev.as_custom::().is_some() { -/// let _ = shell.update(cx, |_, cx| cx.notify()); -/// } -/// }))); -/// }); -/// } -/// ``` -pub type FlowCanvasOutbound = Box; - -fn enqueue_plugin_emit( - outbound: &mut Option, - queue: &mut Vec, - e: FlowEvent, -) { - if let Some(h) = outbound.as_mut() { - h(&e); - } - queue.push(e); -} - -pub struct FlowCanvas { - graph: Graph, - - pub(crate) viewport: Viewport, - - pub(crate) plugins_registry: PluginRegistry, - - pub(crate) sync_plugin: Option>, - - renderers: RendererRegistry, - - pub(crate) focus_handle: FocusHandle, - - pub(crate) interaction: InteractionState, - - pub history: Box, - - event_queue: Vec, - port_offset_cache: PortLayoutCache, - - /// Visual tokens for canvas chrome; plugins adjust via [`InitPluginContext::theme`](crate::plugin::InitPluginContext::theme). - theme: FlowTheme, - - /// Type-erased map for cross-plugin data on this canvas instance. - shared_state: SharedState, - canvas_bounds: Rc>>>, - delayed_notify_tx: mpsc::UnboundedSender<()>, - - /// Optional host hook for every plugin [`PluginContext::emit`](crate::plugin::PluginContext::emit). - outbound: Option, -} - -// // TODO -// impl Clone for FlowCanvas { -// fn clone(&self) -> Self { -// Self { -// graph: self.graph.clone(), -// viewport: self.viewport.clone(), -// plugins_registry: PluginRegistry::new(), -// focus_handle: self.focus_handle.clone(), -// interaction: InteractionState::new(), -// event_queue: vec![], -// } -// } -// } - -impl FlowCanvas { - fn init_delayed_notify_channel(&mut self, cx: &mut Context) { - let (tx, mut rx) = mpsc::unbounded::<()>(); - self.delayed_notify_tx = tx; - cx.spawn(async move |this, ctx| { - while rx.next().await.is_some() { - let _ = this.update(ctx, |_, cx| { - cx.notify(); - }); - } - }) - .detach(); - } - - #[deprecated(note = "use builder instead")] - pub fn new(graph: Graph, cx: &mut Context) -> Self { - let focus_handle = cx.focus_handle(); - let (delayed_notify_tx, _rx) = mpsc::unbounded::<()>(); - let mut canvas = Self { - graph, - viewport: Viewport::new(), - plugins_registry: PluginRegistry::new(), - sync_plugin: None, - renderers: RendererRegistry::new(), - focus_handle, - interaction: InteractionState::new(), - history: Box::new(LocalHistory::new()), - event_queue: vec![], - port_offset_cache: PortLayoutCache::new(), - theme: FlowTheme::default(), - shared_state: SharedState::new(), - canvas_bounds: Rc::new(Cell::new(None)), - delayed_notify_tx, - outbound: None, - }; - canvas.init_delayed_notify_channel(cx); - canvas - } - - pub fn builder<'a, 'b>( - graph: Graph, - ctx: &'a mut Context<'b, Self>, - window: &'a Window, - ) -> FlowCanvasBuilder<'a, 'b> { - FlowCanvasBuilder { - graph, - ctx, - window, - plugins: PluginRegistry::new(), - sync_plugin: None, - renderers: RendererRegistry::new(), - theme: FlowTheme::default(), - outbound: None, - } - } - - /// If there is an active [`Interaction`], deliver `MouseMove` / `MouseUp` only to it and return - /// `true` so the plugin chain is skipped for this dispatch (avoids duplicate handling and keeps - /// drag ownership consistent, including for [`Self::process_event_queue`]). - fn dispatch_interaction_pointer(&mut self, event: &FlowEvent, cx: &mut Context) -> bool { - let mut notify = || cx.notify(); - let delayed_notify_tx = self.delayed_notify_tx.clone(); - let mut schedule_after = move |delay: Duration| { - let tx = delayed_notify_tx.clone(); - std::thread::spawn(move || { - std::thread::sleep(delay); - let _ = tx.unbounded_send(()); - }); - }; - match event { - FlowEvent::Input(InputEvent::MouseMove(ev)) => { - let Some(mut handler) = self.interaction.handler.take() else { - return false; - }; - let outbound = &mut self.outbound; - let event_queue = &mut self.event_queue; - let mut emit = |e| enqueue_plugin_emit(outbound, event_queue, e); - let mut ctx = PluginContext::new( - &mut self.graph, - &mut self.port_offset_cache, - &mut self.viewport, - &mut self.interaction, - &mut self.renderers, - &mut self.sync_plugin, - self.history.as_mut(), - &mut self.theme, - &mut self.shared_state, - &mut emit, - &mut notify, - &mut schedule_after, - ); - let result = handler.on_mouse_move(ev, &mut ctx); - match result { - InteractionResult::Continue => self.interaction.handler = Some(handler), - InteractionResult::End => self.interaction.handler = None, - InteractionResult::Replace(h) => self.interaction.handler = Some(h), - } - true - } - FlowEvent::Input(InputEvent::MouseUp(ev)) => { - let Some(mut handler) = self.interaction.handler.take() else { - return false; - }; - let outbound = &mut self.outbound; - let event_queue = &mut self.event_queue; - let mut emit = |e| enqueue_plugin_emit(outbound, event_queue, e); - let mut ctx = PluginContext::new( - &mut self.graph, - &mut self.port_offset_cache, - &mut self.viewport, - &mut self.interaction, - &mut self.renderers, - &mut self.sync_plugin, - self.history.as_mut(), - &mut self.theme, - &mut self.shared_state, - &mut emit, - &mut notify, - &mut schedule_after, - ); - let result = handler.on_mouse_up(ev, &mut ctx); - match result { - InteractionResult::Continue => self.interaction.handler = Some(handler), - InteractionResult::End => self.interaction.handler = None, - InteractionResult::Replace(h) => self.interaction.handler = Some(h), - } - true - } - _ => false, - } - } - - fn handle_event(&mut self, event: FlowEvent, cx: &mut Context) { - if let Some(sync_plugin) = &mut self.sync_plugin { - let mut ctx = SyncPluginContext::new(&self.viewport); - sync_plugin.on_event(&event, &mut ctx); - } - - // Pointer stream is owned by the active [`Interaction`]; do not also give Move/Up to plugins. - if self.dispatch_interaction_pointer(&event, cx) { - return; - } - - let outbound = &mut self.outbound; - let event_queue = &mut self.event_queue; - let mut emit = |e| enqueue_plugin_emit(outbound, event_queue, e); - let mut notify = || cx.notify(); - let delayed_notify_tx = self.delayed_notify_tx.clone(); - let mut schedule_after = move |delay: Duration| { - let tx = delayed_notify_tx.clone(); - std::thread::spawn(move || { - std::thread::sleep(delay); - let _ = tx.unbounded_send(()); - }); - }; - - let mut ctx = PluginContext::new( - &mut self.graph, - &mut self.port_offset_cache, - &mut self.viewport, - &mut self.interaction, - &mut self.renderers, - &mut self.sync_plugin, - self.history.as_mut(), - &mut self.theme, - &mut self.shared_state, - &mut emit, - &mut notify, - &mut schedule_after, - ); - - for plugin in self.plugins_registry.iter_mut() { - let result = plugin.on_event(&event, &mut ctx); - match result { - EventResult::Continue => {} - EventResult::Stop => break, - } - } - } - - /// Same [`PluginContext`] wiring as input dispatch, for **inbound** control from other GPUI - /// entities (toolbar, palette, automation) without touching `graph` directly. - /// - /// Use from `Entity::update`: - /// - /// ```ignore - /// canvas_entity.update(cx, |canvas, cx| { - /// canvas.dispatch_command(CreateNode::new(node), cx); - /// }); - /// ``` - fn with_plugin_context_for_dispatch( - &mut self, - cx: &mut Context, - f: impl FnOnce(&mut PluginContext<'_>), - ) { - let outbound = &mut self.outbound; - let event_queue = &mut self.event_queue; - let mut emit = |e| enqueue_plugin_emit(outbound, event_queue, e); - let mut notify = || cx.notify(); - let delayed_notify_tx = self.delayed_notify_tx.clone(); - let mut schedule_after = move |delay: Duration| { - let tx = delayed_notify_tx.clone(); - std::thread::spawn(move || { - std::thread::sleep(delay); - let _ = tx.unbounded_send(()); - }); - }; - - let mut ctx = PluginContext::new( - &mut self.graph, - &mut self.port_offset_cache, - &mut self.viewport, - &mut self.interaction, - &mut self.renderers, - &mut self.sync_plugin, - self.history.as_mut(), - &mut self.theme, - &mut self.shared_state, - &mut emit, - &mut notify, - &mut schedule_after, - ); - f(&mut ctx); - } - - /// Run a [`Command`] through the same path as plugins: local [`HistoryProvider`] or - /// [`SyncPlugin::process_intent`], then redraw. - /// - /// Prefer this for graph edits so undo/redo and sync stay consistent. - pub fn dispatch_command(&mut self, command: impl Command + 'static, cx: &mut Context) { - self.with_plugin_context_for_dispatch(cx, |ctx| { - ctx.execute_command(command); - }); - } - - /// Undo the last command (same as plugin [`PluginContext::undo`]). - pub fn dispatch_undo(&mut self, cx: &mut Context) { - self.with_plugin_context_for_dispatch(cx, |ctx| { - ctx.undo(); - }); - } - - /// Redo (same as plugin [`PluginContext::redo`]). - pub fn dispatch_redo(&mut self, cx: &mut Context) { - self.with_plugin_context_for_dispatch(cx, |ctx| { - ctx.redo(); - }); - } - - /// Replace or clear the outbound hook ([`FlowCanvasOutbound`]). Prefer calling from - /// `Entity::update` once the canvas exists; see [`FlowCanvasOutbound`] for parent - /// wiring and the `outbound_host` example in this crate. - /// - /// The hook runs on the same thread as input dispatch, **before** the event is pushed onto - /// [`Self::event_queue`]. Graph changes that do not go through - /// [`PluginContext::emit`](crate::plugin::PluginContext::emit) (for example plain - /// [`Self::dispatch_command`] with no follow-up emit) are **not** reported here; use - /// [`gpui::Context::observe`] on the canvas entity if you need those as well. - pub fn set_outbound(&mut self, hook: Option) { - self.outbound = hook; - } - - /// Read-only view of the document graph (nodes, edges, selection). - pub fn graph(&self) -> &Graph { - &self.graph - } - - /// Clone the graph for use outside the current `update` closure (e.g. async snapshots). - pub fn graph_snapshot(&self) -> Graph { - self.graph.clone() - } - - /// Replace the active canvas visual tokens and request a redraw when they changed. - pub fn set_theme(&mut self, theme: FlowTheme, cx: &mut Context) { - if self.theme == theme { - return; - } - self.theme = theme; - cx.notify(); - } - - /// Replace node renderers and clear cached port layout derived from renderer geometry. - pub fn replace_node_renderers>( - &mut self, - items: impl IntoIterator)>, - cx: &mut Context, - ) { - let mut renderers = RendererRegistry::new(); - for (name, renderer) in items { - renderers.register_boxed(name, renderer); - } - self.renderers = renderers; - self.port_offset_cache.clear_all(); - cx.notify(); - } - - fn process_event_queue(&mut self, cx: &mut Context) { - while let Some(event) = self.event_queue.pop() { - if let Some(sync_plugin) = &mut self.sync_plugin { - let mut ctx = SyncPluginContext::new(&self.viewport); - sync_plugin.on_event(&event, &mut ctx); - } - - if self.dispatch_interaction_pointer(&event, cx) { - continue; - } - - let outbound = &mut self.outbound; - let event_queue = &mut self.event_queue; - let mut emit = |e| enqueue_plugin_emit(outbound, event_queue, e); - let mut notify = || cx.notify(); - let delayed_notify_tx = self.delayed_notify_tx.clone(); - let mut schedule_after = |delay: Duration| { - let tx = delayed_notify_tx.clone(); - std::thread::spawn(move || { - std::thread::sleep(delay); - let _ = tx.unbounded_send(()); - }); - }; - - let mut ctx = PluginContext::new( - &mut self.graph, - &mut self.port_offset_cache, - &mut self.viewport, - &mut self.interaction, - &mut self.renderers, - &mut self.sync_plugin, - self.history.as_mut(), - &mut self.theme, - &mut self.shared_state, - &mut emit, - &mut notify, - &mut schedule_after, - ); - - for plugin in self.plugins_registry.iter_mut() { - let result = plugin.on_event(&event, &mut ctx); - match result { - EventResult::Continue => {} - EventResult::Stop => break, - } - } - } - } - - fn on_key_down(&mut self, ev: &KeyDownEvent, _: &mut Window, cx: &mut Context) { - self.handle_event(FlowEvent::Input(InputEvent::KeyDown(ev.clone())), cx); - self.process_event_queue(cx); - } - - fn on_key_up(&mut self, ev: &KeyUpEvent, _: &mut Window, cx: &mut Context) { - self.handle_event(FlowEvent::Input(InputEvent::KeyUp(ev.clone())), cx); - self.process_event_queue(cx); - } - - fn on_mouse_down(&mut self, ev: &MouseDownEvent, _: &mut Window, cx: &mut Context) { - self.sync_viewport_to_canvas_bounds(); - let ev = self.mouse_down_event_in_canvas(ev); - self.handle_event(FlowEvent::Input(InputEvent::MouseDown(ev)), cx); - self.process_event_queue(cx); - } - - fn on_mouse_move(&mut self, ev: &MouseMoveEvent, _: &mut Window, cx: &mut Context) { - self.sync_viewport_to_canvas_bounds(); - let ev = self.mouse_move_event_in_canvas(ev); - self.handle_event(FlowEvent::Input(InputEvent::MouseMove(ev)), cx); - self.process_event_queue(cx); - } - - fn on_mouse_up(&mut self, ev: &MouseUpEvent, _: &mut Window, cx: &mut Context) { - self.sync_viewport_to_canvas_bounds(); - let ev = self.mouse_up_event_in_canvas(ev); - self.handle_event(FlowEvent::Input(InputEvent::MouseUp(ev)), cx); - self.process_event_queue(cx); - } - - fn on_scroll_wheel(&mut self, ev: &ScrollWheelEvent, _: &mut Window, cx: &mut Context) { - self.sync_viewport_to_canvas_bounds(); - let ev = self.scroll_wheel_event_in_canvas(ev); - self.handle_event(FlowEvent::Input(InputEvent::Wheel(ev)), cx); - self.process_event_queue(cx); - } - - fn on_canvas_hover(&mut self, hovered: &bool, _: &mut Window, cx: &mut Context) { - self.handle_event(FlowEvent::Input(InputEvent::Hover(*hovered)), cx); - self.process_event_queue(cx); - } - - fn canvas_origin(&self) -> Point { - self.canvas_bounds - .get() - .map(|bounds| bounds.origin) - .unwrap_or(Point::new(px(0.0), px(0.0))) - } - - fn sync_viewport_to_canvas_bounds(&mut self) { - if let Some(bounds) = self.canvas_bounds.get() { - self.viewport.sync_canvas_bounds(bounds); - } - } - - fn mouse_down_event_in_canvas(&self, ev: &MouseDownEvent) -> MouseDownEvent { - let mut ev = ev.clone(); - ev.position = window_point_to_canvas_point(ev.position, self.canvas_origin()); - ev - } - - fn mouse_move_event_in_canvas(&self, ev: &MouseMoveEvent) -> MouseMoveEvent { - let mut ev = ev.clone(); - ev.position = window_point_to_canvas_point(ev.position, self.canvas_origin()); - ev - } - - fn mouse_up_event_in_canvas(&self, ev: &MouseUpEvent) -> MouseUpEvent { - let mut ev = ev.clone(); - ev.position = window_point_to_canvas_point(ev.position, self.canvas_origin()); - ev - } - - fn scroll_wheel_event_in_canvas(&self, ev: &ScrollWheelEvent) -> ScrollWheelEvent { - let mut ev = ev.clone(); - ev.position = window_point_to_canvas_point(ev.position, self.canvas_origin()); - ev - } -} - -impl Render for FlowCanvas { - fn render(&mut self, window: &mut Window, this_cx: &mut Context) -> impl IntoElement { - if let Some(bounds) = self.canvas_bounds.get() { - self.viewport.sync_canvas_bounds(bounds); - } else { - self.viewport.sync_drawable_bounds(window); - } - - let entity = this_cx.entity(); - - let graph = &mut self.graph; - let viewport = &self.viewport; - let renderers = &self.renderers; - let port_offset_cache = &mut self.port_offset_cache; - let theme = &self.theme; - let shared_state = &self.shared_state; - - let mut layers: Vec> = - (0..RenderLayer::ALL.len()).map(|_| Vec::new()).collect(); - - for plugin in self.plugins_registry.iter_mut() { - let mut ctx = RenderContext::new( - graph, - port_offset_cache, - viewport, - renderers, - window, - theme, - shared_state, - ); - - if let Some(el) = plugin.render(&mut ctx) { - layers[plugin.render_layer().index()].push(el); - } - } - - if let Some(i) = self.interaction.handler.as_ref() { - let mut ctx = RenderContext::new( - graph, - port_offset_cache, - viewport, - renderers, - window, - theme, - shared_state, - ); - - if let Some(el) = i.render(&mut ctx) { - layers[RenderLayer::Interaction.index()].push(el); - } - } - - if let Some(sync_plugin) = &mut self.sync_plugin { - let mut ctx = RenderContext::new( - graph, - port_offset_cache, - viewport, - renderers, - window, - theme, - shared_state, - ); - let els = sync_plugin.render(&mut ctx); - for el in els { - layers[RenderLayer::Overlay.index()].push(el); - } - } - - let root = div() - .id("ferrum_flow_canvas") - .size_full() - .track_focus(&self.focus_handle) - .on_key_down(window.listener_for(&entity, Self::on_key_down)) - .on_key_up(window.listener_for(&entity, Self::on_key_up)) - .on_mouse_down( - MouseButton::Left, - window.listener_for(&entity, Self::on_mouse_down), - ) - .on_mouse_down( - MouseButton::Right, - window.listener_for(&entity, Self::on_mouse_down), - ) - .on_mouse_move(window.listener_for(&entity, Self::on_mouse_move)) - .on_hover(window.listener_for(&entity, Self::on_canvas_hover)) - .on_mouse_up( - MouseButton::Left, - window.listener_for(&entity, Self::on_mouse_up), - ) - .on_scroll_wheel(window.listener_for(&entity, Self::on_scroll_wheel)) - .children(RenderLayer::ALL.iter().map(|layer| { - div() - .id(ElementId::Integer(layer.index() as u64)) - .absolute() - .size_full() - .children(layers[layer.index()].drain(..)) - })); - - CanvasRootElement::new(root, Rc::clone(&self.canvas_bounds)) - } -} - -fn window_point_to_canvas_point( - window_point: Point, - canvas_origin: Point, -) -> Point { - window_point - canvas_origin -} - -struct CanvasRootElement { - element: E, - canvas_bounds: Rc>>>, -} - -impl CanvasRootElement { - fn new(element: E, canvas_bounds: Rc>>>) -> Self { - Self { - element, - canvas_bounds, - } - } -} - -impl IntoElement for CanvasRootElement -where - E: Element, -{ - type Element = Self; - - fn into_element(self) -> Self::Element { - self - } -} - -impl Element for CanvasRootElement -where - E: Element, -{ - type RequestLayoutState = E::RequestLayoutState; - type PrepaintState = E::PrepaintState; - - fn id(&self) -> Option { - self.element.id() - } - - fn source_location(&self) -> Option<&'static core::panic::Location<'static>> { - self.element.source_location() - } - - fn request_layout( - &mut self, - id: Option<&GlobalElementId>, - inspector_id: Option<&InspectorElementId>, - window: &mut Window, - cx: &mut App, - ) -> (LayoutId, Self::RequestLayoutState) { - self.element.request_layout(id, inspector_id, window, cx) - } - - fn prepaint( - &mut self, - id: Option<&GlobalElementId>, - inspector_id: Option<&InspectorElementId>, - bounds: Bounds, - request_layout: &mut Self::RequestLayoutState, - window: &mut Window, - cx: &mut App, - ) -> Self::PrepaintState { - self.canvas_bounds.set(Some(bounds)); - self.element - .prepaint(id, inspector_id, bounds, request_layout, window, cx) - } - - fn paint( - &mut self, - id: Option<&GlobalElementId>, - inspector_id: Option<&InspectorElementId>, - bounds: Bounds, - request_layout: &mut Self::RequestLayoutState, - prepaint: &mut Self::PrepaintState, - window: &mut Window, - cx: &mut App, - ) { - self.element.paint( - id, - inspector_id, - bounds, - request_layout, - prepaint, - window, - cx, - ); - } -} - -#[cfg(test)] -mod tests { - use gpui::{Point, px}; - - use super::window_point_to_canvas_point; - - #[test] - fn window_point_to_canvas_point_subtracts_canvas_origin() { - let window_point = Point::new(px(360.0), px(140.0)); - let canvas_origin = Point::new(px(320.0), px(96.0)); - - let canvas_point = window_point_to_canvas_point(window_point, canvas_origin); - - assert_eq!(canvas_point, Point::new(px(40.0), px(44.0))); - } -} - -pub struct FlowCanvasBuilder<'a, 'b> { - graph: Graph, - ctx: &'a mut Context<'b, FlowCanvas>, - window: &'a Window, - - plugins: PluginRegistry, - renderers: RendererRegistry, - sync_plugin: Option>, - theme: FlowTheme, - outbound: Option, -} - -impl<'a, 'b> FlowCanvasBuilder<'a, 'b> { - /// register plugin - pub fn plugin(mut self, plugin: impl Plugin + 'static) -> Self { - self.plugins = self.plugins.add(plugin); - self - } - - /// Registers several plugins in one call (each item is a `Box`). - /// - /// Order is only relevant before [`Self::build`], which sorts by [`Plugin::priority`]. Prefer - /// [`.plugin`](Self::plugin) for single plugins so the compiler boxes them for you. - /// - /// When building a list of heterogeneous plugin types, use an explicitly typed - /// `Vec>` so each `Box::new(concrete)` coerces to the trait object. - pub fn plugins(mut self, plugins: impl IntoIterator>) -> Self { - self.plugins.extend_boxed(plugins); - self - } - - /// Registers the **core** plugin set for editing a node graph on the canvas: background, - /// selection, node drag, pan/zoom, node/edge rendering, port wiring, delete, and undo/redo - /// ([`BackgroundPlugin`], [`SelectionPlugin`], [`NodeInteractionPlugin`], [`ViewportPlugin`], - /// [`NodePlugin`], [`PortInteractionPlugin`], [`EdgePlugin`], [`DeletePlugin`], [`HistoryPlugin`]). - /// - /// Event order is determined by each plugin’s [`Plugin::priority`] when [`FlowCanvas::build`] - /// runs (not by the order of calls to [`.plugin`](Self::plugin)). Add minimap, clipboard, - /// context menu, etc. with [`.plugin`](Self::plugin) before or after this call. - pub fn default_plugins(mut self) -> Self { - self.plugins = self - .plugins - .add(BackgroundPlugin::new()) - .add(SelectionPlugin::new()) - .add(NodeInteractionPlugin::new()) - .add(ViewportPlugin::new()) - .add(NodePlugin::new()) - .add(PortInteractionPlugin::new()) - .add(EdgePlugin::new()) - .add(DeletePlugin::new()) - .add(HistoryPlugin::new()); - self - } - - pub fn sync_plugin(mut self, plugin: impl SyncPlugin + 'static) -> Self { - self.sync_plugin = Some(Box::new(plugin)); - self - } - - /// register node renderer - pub fn node_renderer(mut self, name: impl Into, renderer: R) -> Self - where - R: node_renderer::NodeRenderer + 'static, - { - self.renderers.register(name, renderer); - self - } - - /// Registers several [`NodeRenderer`](node_renderer::NodeRenderer) entries (each `Box`), same idea as [`Self::plugins`]. - pub fn node_renderers>( - mut self, - items: impl IntoIterator)>, - ) -> Self { - for (name, renderer) in items { - self.renderers.register_boxed(name, renderer); - } - self - } - - /// Replace the default [`FlowTheme`] before plugins run [`Plugin::setup`](crate::plugin::Plugin::setup). - pub fn theme(mut self, theme: FlowTheme) -> Self { - self.theme = theme; - self - } - - /// Register an outbound hook: invoked for every [`PluginContext::emit`](crate::plugin::PluginContext::emit) - /// on this canvas (same as [`FlowCanvas::set_outbound`]). See [`FlowCanvasOutbound`] and the - /// `outbound_host` example for how a parent view can react (e.g. `Arc` or - /// `Entity::update` on a shell). - pub fn outbound(mut self, hook: impl FnMut(&FlowEvent) + Send + 'static) -> Self { - self.outbound = Some(Box::new(hook)); - self - } - - pub fn build(self) -> FlowCanvas { - let mut duplicate_plugins: BTreeMap<&'static str, usize> = BTreeMap::new(); - for plugin in self.plugins.iter() { - *duplicate_plugins.entry(plugin.name()).or_insert(0) += 1; - } - for (name, count) in duplicate_plugins - .into_iter() - .filter(|(_, count)| *count > 1) - { - eprintln!( - "warning: plugin '{name}' is registered {count} times; this can cause duplicated event handling" - ); - } - - let focus_handle = self.ctx.focus_handle(); - let drawable_size = self.window.viewport_size(); - let (delayed_notify_tx, _rx) = mpsc::unbounded::<()>(); - - let mut canvas = FlowCanvas { - graph: self.graph, - viewport: Viewport::new(), - plugins_registry: self.plugins, - sync_plugin: self.sync_plugin, - renderers: self.renderers, - focus_handle, - interaction: InteractionState::new(), - history: Box::new(LocalHistory::new()), - event_queue: vec![], - port_offset_cache: PortLayoutCache::new(), - theme: self.theme, - shared_state: SharedState::new(), - canvas_bounds: Rc::new(Cell::new(None)), - delayed_notify_tx, - outbound: self.outbound, - }; - canvas.init_delayed_notify_channel(self.ctx); - - if let Some(sync_plugin) = &mut canvas.sync_plugin { - let (change_sender, mut change_receiver) = mpsc::unbounded::(); - - self.ctx - .spawn(async move |this, ctx| { - while let Some(change) = change_receiver.next().await { - let _ = this.update(ctx, |this, cx| { - invalidate_port_layout_cache_for_graph_change( - &mut this.port_offset_cache, - &this.graph, - &change.kind, - ); - this.graph.apply(change.kind); - cx.notify(); - }); - } - }) - .detach(); - sync_plugin.setup(change_sender); - } - - canvas.plugins_registry.sort_by_priority_desc(); - - { - let mut ctx = InitPluginContext::new( - &mut canvas.graph, - &mut canvas.port_offset_cache, - &mut canvas.viewport, - &mut canvas.renderers, - self.ctx, - drawable_size, - &mut canvas.theme, - &mut canvas.shared_state, - ); - - for plugin in canvas.plugins_registry.iter_mut() { - plugin.setup(&mut ctx); - } - } - - canvas - } -} diff --git a/crates/ferrum-flow/src/canvas/node_renderer.rs b/crates/ferrum-flow/src/canvas/node_renderer.rs deleted file mode 100644 index 6527d66bd9..0000000000 --- a/crates/ferrum-flow/src/canvas/node_renderer.rs +++ /dev/null @@ -1,182 +0,0 @@ -use gpui::*; -use std::collections::HashMap; - -use crate::node::Node; -use crate::plugin::{NodeCardVariant, RenderContext}; -use crate::{Graph, Port, PortId, PortPosition}; - -pub trait NodeRenderer: Send + Sync { - /// render node inner UI - fn render(&self, node: &Node, ctx: &mut RenderContext) -> AnyElement; - - // custom render port UI - fn port_render(&self, node: &Node, port: &Port, ctx: &mut RenderContext) -> Option { - let frame = ctx.port_screen_frame(node, port)?; - Some( - frame - .anchor_div() - .rounded_full() - .bg(rgb(ctx.theme.default_port_fill)) - .into_any(), - ) - } - - /// computing the position of port relative to node - /// built-in Node Plugin is cached this. - fn port_offset(&self, node: &Node, port: &Port, graph: &Graph) -> Point { - let total = graph - .ports_values() - .filter(|p| { - p.node_id() == node.id() - && p.kind() == port.kind() - && p.position() == port.position() - }) - .count() as f32; - let index = port.index() as f32; - let size = *node.size_ref(); - - match port.position() { - PortPosition::Left => { - let spacing = size.height / (total + 1.0); - Point::new(px(0.0), spacing * (index + 1.0)) - } - PortPosition::Right => { - let spacing = size.height / (total + 1.0); - Point::new(size.width, spacing * (index + 1.0)) - } - PortPosition::Top => { - let spacing = size.width / (total + 1.0); - Point::new(spacing * (index + 1.0), px(0.0)) - } - PortPosition::Bottom => { - let spacing = size.width / (total + 1.0); - Point::new(spacing * (index + 1.0), size.height) - } - } - } -} - -pub struct RendererRegistry { - map: HashMap>, - default: Box, - undefined: Box, -} - -impl RendererRegistry { - pub(crate) fn new() -> Self { - Self { - map: HashMap::new(), - default: Box::new(DefaultNodeRenderer {}), - undefined: Box::new(UndefinedNodeRenderer {}), - } - } - - pub fn register(&mut self, name: impl Into, renderer: R) - where - R: NodeRenderer + 'static, - { - self.map.insert(name.into(), Box::new(renderer)); - } - - pub fn register_boxed(&mut self, name: impl Into, renderer: Box) { - self.map.insert(name.into(), renderer); - } - - pub fn get(&self, name: &str) -> &dyn NodeRenderer { - if name.is_empty() || name == "default" { - return self.default.as_ref(); - } - - self.map - .get(name) - .map(|r| r.as_ref()) - .unwrap_or(self.undefined.as_ref()) - } -} - -struct DefaultNodeRenderer; - -impl NodeRenderer for DefaultNodeRenderer { - fn render(&self, node: &Node, ctx: &mut RenderContext) -> AnyElement { - let node_id = node.id(); - let selected = ctx.graph.selected_node().iter().any(|id| *id == node_id); - - ctx.node_card_shell(node, selected, NodeCardVariant::Default) - .rounded(px(6.0)) - .border(px(1.5)) - .child( - div() - .id(ElementId::Uuid(*node_id.as_uuid())) - .size_full() - .flex() - .items_center() - .justify_center() - .text_center() - .px_2() - .child(default_node_caption(node)) - .text_color(rgb(ctx.theme.node_caption_text)), - ) - .into_any() - } -} - -struct UndefinedNodeRenderer; - -impl NodeRenderer for UndefinedNodeRenderer { - fn render(&self, node: &Node, ctx: &mut RenderContext) -> AnyElement { - ctx.node_card_shell(node, false, NodeCardVariant::UndefinedType) - .rounded(px(6.0)) - .border(px(1.5)) - .child( - div() - .id(ElementId::Uuid(*node.id().as_uuid())) - .size_full() - .flex() - .items_center() - .justify_center() - .text_center() - .px_2() - .child(undefined_node_caption(node)) - .text_color(rgb(ctx.theme.undefined_node_caption_text)), - ) - .into_any() - } -} - -#[deprecated(note = "use `ctx.port_screen_center(node, port_id)`")] -pub fn port_screen_position( - node: &Node, - port_id: PortId, - ctx: &RenderContext, -) -> Option> { - ctx.port_screen_center(node, port_id) -} - -fn data_title(data: &serde_json::Value) -> Option { - if let Some(s) = data.get("label").and_then(|v| v.as_str()) { - let t = s.trim(); - if !t.is_empty() { - return Some(t.to_string()); - } - } - None -} - -/// Label for [`DefaultNodeRenderer`]: user-facing title from `data`, else `node_type`, else a generic word. -/// UUID stays off-canvas; use debug/inspector/tooltip if operators need the id. -pub fn default_node_caption(node: &Node) -> String { - if let Some(s) = data_title(node.data_ref()) { - return s; - } - if !node.renderer_key().is_empty() { - return node.renderer_key().to_string(); - } - "Node".to_string() -} - -fn undefined_node_caption(node: &Node) -> String { - if !node.renderer_key().is_empty() { - return format!("Unknown type: {}", node.renderer_key()); - } - "Unknown node type".to_string() -} diff --git a/crates/ferrum-flow/src/canvas/port_cache.rs b/crates/ferrum-flow/src/canvas/port_cache.rs deleted file mode 100644 index 6e5fab8f06..0000000000 --- a/crates/ferrum-flow/src/canvas/port_cache.rs +++ /dev/null @@ -1,117 +0,0 @@ -use std::collections::HashMap; - -use gpui::{Pixels, Point}; - -use crate::{EdgeId, Graph, NodeId, PortId, RendererRegistry}; - -#[derive(Debug, Clone)] -pub struct PortLayoutCache { - map: HashMap>>, -} - -impl PortLayoutCache { - pub(crate) fn new() -> Self { - Self { - map: HashMap::new(), - } - } - - pub fn get_offset(&self, node_id: &NodeId, port_id: &PortId) -> Option> { - self.map.get(node_id)?.get(port_id).copied() - } - - pub fn is_node_cached(&self, node_id: &NodeId) -> bool { - self.map.contains_key(node_id) - } - - /// Port ids whose offsets are cached for `node_id` (after [`Self::ensure_node_ports`]). - /// - /// Order follows the inner [`HashMap`] and is not guaranteed stable across runs. - pub fn cached_port_ids_for_node(&self, node_id: &NodeId) -> impl Iterator + '_ { - self.map - .get(node_id) - .into_iter() - .flat_map(|ports| ports.keys().copied()) - } - - pub fn replace_node_offsets( - &mut self, - node_id: NodeId, - offsets: HashMap>, - ) { - self.map.insert(node_id, offsets); - } - - pub fn clear_node(&mut self, node_id: &NodeId) { - self.map.remove(node_id); - } - - pub fn clear_all(&mut self) { - self.map.clear(); - } - - /// Fill port layout for `node_id` if not already cached. - pub fn ensure_node_ports( - &mut self, - graph: &Graph, - renderers: &RendererRegistry, - node_id: &NodeId, - ) { - if self.is_node_cached(node_id) { - return; - } - - let Some(node) = graph.get_node(node_id) else { - return; - }; - - let renderer = renderers.get(node.renderer_key()); - - let mut result = HashMap::new(); - - for port in graph.ports_values().filter(|p| p.node_id() == node.id()) { - let pos = renderer.port_offset(node, port, graph); - result.insert(port.id(), pos); - } - - self.replace_node_offsets(node.id(), result); - } - - /// Fill port layout for every node if not already cached. - pub fn ensure_all_nodes_ports(&mut self, graph: &Graph, renderers: &RendererRegistry) { - let node_ids = graph.nodes().keys().copied(); - - for node_id in node_ids { - self.ensure_node_ports(graph, renderers, &node_id); - } - } - - /// Ensure both endpoint nodes of the edge have port layout cached. - pub fn ensure_edge_ports( - &mut self, - graph: &Graph, - renderers: &RendererRegistry, - edge_id: &EdgeId, - ) { - let Some(edge) = graph.get_edge(edge_id) else { - return; - }; - - self.ensure_node_ports_for_port(graph, renderers, &edge.source_port); - self.ensure_node_ports_for_port(graph, renderers, &edge.target_port); - } - - /// Ensure the node that owns `port_id` has port layout cached. - pub fn ensure_node_ports_for_port( - &mut self, - graph: &Graph, - renderers: &RendererRegistry, - port_id: &PortId, - ) { - let Some(port) = graph.get_port(port_id) else { - return; - }; - - self.ensure_node_ports(graph, renderers, &port.node_id()); - } -} diff --git a/crates/ferrum-flow/src/canvas/types.rs b/crates/ferrum-flow/src/canvas/types.rs deleted file mode 100644 index 6bb68c1ed6..0000000000 --- a/crates/ferrum-flow/src/canvas/types.rs +++ /dev/null @@ -1,51 +0,0 @@ -use gpui::{AnyElement, MouseMoveEvent, MouseUpEvent}; - -use crate::plugin::{PluginContext, RenderContext}; - -pub struct InteractionState { - pub(crate) handler: Option>, -} - -impl InteractionState { - pub(crate) fn new() -> Self { - Self { handler: None } - } - - pub fn add(&mut self, handler: impl Interaction + 'static) { - self.handler = Some(Box::new(handler)); - } - - pub fn clear(&mut self) { - self.handler = None; - } - - pub fn is_some(&self) -> bool { - self.handler.is_some() - } -} - -pub trait Interaction { - fn on_mouse_move( - &mut self, - event: &MouseMoveEvent, - ctx: &mut PluginContext, - ) -> InteractionResult; - - fn on_mouse_up(&mut self, event: &MouseUpEvent, ctx: &mut PluginContext) -> InteractionResult; - - fn render(&self, _ctx: &mut RenderContext) -> Option { - None - } -} - -pub enum InteractionResult { - Continue, - End, - Replace(Box), -} - -impl InteractionResult { - pub fn replace(new_handler: impl Interaction + 'static) -> Self { - Self::Replace(Box::new(new_handler)) - } -} diff --git a/crates/ferrum-flow/src/canvas/undo.rs b/crates/ferrum-flow/src/canvas/undo.rs deleted file mode 100644 index 172583add5..0000000000 --- a/crates/ferrum-flow/src/canvas/undo.rs +++ /dev/null @@ -1,341 +0,0 @@ -use std::collections::HashMap; - -use gpui::{Bounds, Pixels, Point}; - -use crate::{ - Edge, EdgeBuilder, EdgeId, Graph, GraphOp, Node, NodeBuilder, NodeId, Port, PortId, - RendererRegistry, SharedState, Viewport, - canvas::PortLayoutCache, - plugin::{is_edge_visible, is_node_visible}, -}; - -pub trait Command { - fn name(&self) -> &'static str; - - /// execute command detail, e.g: move node - fn execute(&mut self, ctx: &mut CommandContext); - - // undo command , when open sync plugin, this is diabeled. - fn undo(&mut self, ctx: &mut CommandContext); - - /// used by sync plugin - /// when open sync plugin, execute method is diasbeld, and using to_ops send graph intent - fn to_ops(&self, _ctx: &mut CommandContext) -> Vec { - vec![] - } -} - -pub trait HistoryProvider { - fn undo(&mut self, ctx: &mut CommandContext); - fn redo(&mut self, ctx: &mut CommandContext); - fn push(&mut self, command: Box, ctx: &mut CommandContext); - fn clear(&mut self); -} - -pub struct CommandContext<'a> { - pub graph: &'a mut Graph, - pub port_offset_cache: &'a mut PortLayoutCache, - viewport: &'a mut Viewport, - pub renderers: &'a mut RendererRegistry, - /// Shared plugin state on the [`FlowCanvas`](crate::canvas::FlowCanvas). - pub shared_state: &'a mut SharedState, - pub(crate) notify: &'a mut dyn FnMut(), -} -const MAX_HISTORY: usize = 100; -pub struct LocalHistory { - undo_stack: Vec>, - redo_stack: Vec>, -} - -impl LocalHistory { - pub(crate) fn new() -> Self { - Self { - undo_stack: vec![], - redo_stack: vec![], - } - } -} - -impl HistoryProvider for LocalHistory { - fn push(&mut self, mut command: Box, ctx: &mut CommandContext) { - command.execute(ctx); - - self.undo_stack.push(command); - - self.redo_stack.clear(); - - if self.undo_stack.len() > MAX_HISTORY { - self.undo_stack.remove(0); - } - } - fn undo(&mut self, ctx: &mut CommandContext) { - if let Some(mut cmd) = self.undo_stack.pop() { - cmd.undo(ctx); - self.redo_stack.push(cmd); - } - } - - fn redo(&mut self, ctx: &mut CommandContext) { - if let Some(mut cmd) = self.redo_stack.pop() { - cmd.execute(ctx); - self.undo_stack.push(cmd); - } - } - - fn clear(&mut self) { - self.undo_stack.clear(); - self.redo_stack.clear(); - } -} - -pub struct CompositeCommand { - commands: Vec>, -} - -impl Default for CompositeCommand { - fn default() -> Self { - Self::new() - } -} - -impl CompositeCommand { - pub fn new() -> Self { - Self { - commands: Vec::new(), - } - } - pub fn push(&mut self, command: impl Command + 'static) { - self.commands.push(Box::new(command)); - } -} - -impl Command for CompositeCommand { - fn name(&self) -> &'static str { - "composite" - } - fn execute(&mut self, state: &mut CommandContext) { - for cmd in &mut self.commands { - cmd.execute(state); - } - } - - fn undo(&mut self, state: &mut CommandContext) { - for cmd in self.commands.iter_mut().rev() { - cmd.undo(state); - } - } - fn to_ops(&self, ctx: &mut CommandContext) -> Vec { - let mut list = vec![]; - for cmd in &self.commands { - list.extend(cmd.to_ops(ctx)); - } - - vec![GraphOp::Batch(list)] - } -} - -impl<'a> CommandContext<'a> { - pub(crate) fn new( - graph: &'a mut Graph, - port_offset_cache: &'a mut PortLayoutCache, - viewport: &'a mut Viewport, - renderers: &'a mut RendererRegistry, - shared_state: &'a mut SharedState, - notify: &'a mut dyn FnMut(), - ) -> Self { - Self { - graph, - port_offset_cache, - viewport, - renderers, - shared_state, - notify, - } - } - pub fn create_node(&mut self, node_type: &str) -> NodeBuilder<'_> { - self.graph.create_node(node_type) - } - - pub fn create_edge(&mut self) -> EdgeBuilder<'_> { - self.graph.create_edge() - } - - pub fn next_node_id(&self) -> NodeId { - self.graph.next_node_id() - } - - pub fn next_port_id(&self) -> PortId { - self.graph.next_port_id() - } - - pub fn next_edge_id(&self) -> EdgeId { - self.graph.next_edge_id() - } - pub fn add_node(&mut self, node: Node) { - self.graph.add_node(node); - } - - pub fn add_port(&mut self, port: Port) { - self.graph.add_port(port); - } - - pub fn remove_port(&mut self, id: &PortId) { - self.graph.remove_port(id); - } - - pub fn get_node(&self, id: &NodeId) -> Option<&Node> { - self.graph.get_node(id) - } - - pub fn get_node_mut(&mut self, id: &NodeId) -> Option<&mut Node> { - self.graph.get_node_mut(id) - } - pub fn remove_node(&mut self, id: &NodeId) { - self.graph.remove_node(id); - self.port_offset_cache.clear_node(id); - } - pub fn nodes(&self) -> &HashMap { - self.graph.nodes() - } - pub fn node_order(&self) -> &Vec { - self.graph.node_order() - } - - pub fn new_edge(&self) -> Edge { - self.graph.new_edge() - } - - pub fn add_edge(&mut self, edge: Edge) { - self.graph.add_edge(edge); - } - - pub fn remove_edge(&mut self, edge_id: &EdgeId) { - self.graph.remove_edge(edge_id); - } - - pub fn add_selected_node(&mut self, id: NodeId, shift: bool) { - self.graph.add_selected_node(id, shift); - } - pub fn clear_selected_node(&mut self) { - self.graph.clear_selected_node(); - } - pub fn remove_selected_node(&mut self) -> bool { - self.graph.remove_selected_node() - } - - pub fn add_selected_edge(&mut self, id: EdgeId, shift: bool) { - self.graph.add_selected_edge(id, shift); - } - pub fn clear_selected_edge(&mut self) { - self.graph.clear_selected_edge(); - } - pub fn remove_selected_edge(&mut self) -> bool { - self.graph.remove_selected_edge() - } - - pub fn selection_bounds(&self) -> Option> { - self.graph.selection_bounds() - } - - pub fn selected_nodes_with_positions(&self) -> HashMap> { - self.graph.selected_nodes_with_positions() - } - - pub fn hit_node(&self, mouse: Point) -> Option { - self.graph.hit_node(mouse, self.viewport) - } - - pub fn bring_node_to_front(&mut self, node_id: NodeId) { - self.graph.bring_node_to_front(node_id); - } - - // ---- Viewport shortcuts ---- - pub fn zoom(&self) -> f32 { - self.viewport.zoom() - } - - pub fn set_zoom(&mut self, zoom: f32) { - self.viewport.set_zoom(zoom); - } - - pub fn zoom_scaled_by(&self, factor: f32) -> f32 { - self.viewport.zoom_scaled_by(factor) - } - - pub fn offset(&self) -> Point { - self.viewport.offset() - } - - pub fn set_offset(&mut self, offset: Point) { - self.viewport.set_offset(offset); - } - - pub fn set_offset_xy(&mut self, x: Pixels, y: Pixels) { - self.viewport.set_offset_xy(x, y); - } - - pub fn translate_offset(&mut self, dx: Pixels, dy: Pixels) { - self.viewport.translate_offset(dx, dy); - } - - pub fn window_bounds(&self) -> Option> { - self.viewport.window_bounds() - } - - pub fn set_window_bounds(&mut self, bounds: Option>) { - self.viewport.set_window_bounds(bounds); - } - - pub fn world_scalar_to_screen(&self, value: f32) -> f32 { - self.viewport.world_scalar_to_screen(value) - } - - pub fn screen_scalar_to_world(&self, value: f32) -> f32 { - self.viewport.screen_scalar_to_world(value) - } - - pub fn world_length_to_screen(&self, value: Pixels) -> Pixels { - self.viewport.world_length_to_screen(value) - } - - pub fn screen_length_to_world(&self, value: Pixels) -> Pixels { - self.viewport.screen_length_to_world(value) - } - - pub fn world_to_screen(&self, p: Point) -> Point { - self.viewport.world_to_screen(p) - } - - pub fn screen_to_world(&self, p: Point) -> Point { - self.viewport.screen_to_world(p) - } - - pub fn is_node_visible(&self, node_id: &NodeId) -> bool { - is_node_visible(self.graph, self.viewport, node_id) - } - pub fn is_node_visible_node(&self, node: &Node) -> bool { - self.viewport.is_node_visible(node) - } - - pub fn is_edge_visible(&self, edge: &Edge) -> bool { - is_edge_visible(self.graph, self.viewport, edge) - } - - pub fn port_offset_cached(&self, node_id: &NodeId, port_id: &PortId) -> Option> { - self.port_offset_cache.get_offset(node_id, port_id) - } - - pub fn cache_all_node_port_offset(&mut self) { - self.port_offset_cache - .ensure_all_nodes_ports(self.graph, self.renderers); - } - - pub fn cache_node_port_offset(&mut self, node_id: &NodeId) { - self.port_offset_cache - .ensure_node_ports(self.graph, self.renderers, node_id); - } - - pub fn notify(&mut self) { - (self.notify)(); - } -} diff --git a/crates/ferrum-flow/src/command_interop.rs b/crates/ferrum-flow/src/command_interop.rs deleted file mode 100644 index 1af7c3a2a9..0000000000 --- a/crates/ferrum-flow/src/command_interop.rs +++ /dev/null @@ -1,208 +0,0 @@ -//! Helpers for verifying that [`Command`](crate::Command) implementations agree between -//! [`Command::execute`](crate::Command::execute), [`Command::undo`](crate::Command::undo), and -//! [`Command::to_ops`](crate::Command::to_ops). -//! -//! Enable the **`testing`** Cargo feature on `ferrum-flow` to use this module: -//! -//! ```toml -//! ferrum-flow = { version = "…", features = ["testing"] } -//! ``` -//! -//! Run this crate’s built-in interop tests with: -//! -//! ```text -//! cargo test -p ferrum-flow --features testing -//! ``` -//! -//! The public entry points are [`graph_snapshot`] and [`assert_command_interop`]. Example tests that -//! use them live next to each [`Command`](crate::Command) implementation under `plugins/` (and -//! `plugins/port/command.rs` for create commands). - -use serde_json::{Value, json}; - -use crate::{ - Command, CommandContext, Graph, GraphOp, RendererRegistry, SharedState, Viewport, - canvas::PortLayoutCache, -}; - -fn with_command_ctx(graph: &mut Graph, f: impl FnOnce(&mut CommandContext) -> R) -> R { - let mut port_offset_cache = PortLayoutCache::new(); - let mut viewport = Viewport::new(); - let mut renderers = RendererRegistry::new(); - let mut shared_state = SharedState::new(); - let mut notify = || {}; - let mut ctx = CommandContext::new( - graph, - &mut port_offset_cache, - &mut viewport, - &mut renderers, - &mut shared_state, - &mut notify, - ); - f(&mut ctx) -} - -fn apply_graph_op(graph: &mut Graph, op: GraphOp) { - match op { - GraphOp::AddNode(node) => { - graph.add_node_without_order(node); - } - GraphOp::RemoveNode { id } => graph.remove_node(&id), - GraphOp::MoveNode { id, x, y } => { - if let Some(node) = graph.get_node_mut(&id) { - node.set_position(x.into(), y.into()); - } - } - GraphOp::ResizeNode { id, size } => { - if let Some(node) = graph.get_node_mut(&id) { - node.set_size_mut(size); - } - } - GraphOp::UpdateNodeData { id, data } => { - if let Some(node) = graph.get_node_mut(&id) { - node.set_data(data); - } - } - GraphOp::NodeOrderInsert { id } => graph.node_order_mut().push(id), - GraphOp::NodeOrderRemove { index } => { - if index < graph.node_order().len() { - graph.node_order_mut().remove(index); - } - } - GraphOp::AddPort(port) => graph.add_port(port), - GraphOp::RemovePort(id) => graph.remove_port(&id), - GraphOp::AddEdge(edge) => graph.add_edge(edge), - GraphOp::RemoveEdge(id) => graph.remove_edge(&id), - GraphOp::Batch(ops) => { - for op in ops { - apply_graph_op(graph, op); - } - } - } -} - -/// Canonical JSON snapshot of a [`Graph`] for stable equality checks (sorted maps / sets). -pub fn graph_snapshot(graph: &Graph) -> Value { - let mut nodes: Vec<_> = graph - .nodes() - .iter() - .map(|(id, n)| { - ( - id.to_string(), - json!({ - "x": f32::from(n.position().0), - "y": f32::from(n.position().1), - "w": f32::from(n.size_ref().width), - "h": f32::from(n.size_ref().height), - "inputs": n.inputs().iter().map(ToString::to_string).collect::>(), - "outputs": n.outputs().iter().map(ToString::to_string).collect::>(), - }), - ) - }) - .collect(); - nodes.sort_by(|a, b| a.0.cmp(&b.0)); - - let mut ports: Vec<_> = graph - .ports() - .iter() - .map(|(id, p)| { - ( - id.to_string(), - json!({ - "node_id": p.node_id().to_string(), - "kind": p.kind().to_string(), - "position": p.position().to_string(), - "index": p.index(), - "w": f32::from(p.size_ref().width), - "h": f32::from(p.size_ref().height), - }), - ) - }) - .collect(); - ports.sort_by(|a, b| a.0.cmp(&b.0)); - - let mut edges: Vec<_> = graph - .edges() - .iter() - .map(|(id, e)| { - ( - id.to_string(), - json!({ - "source": e.source_port.to_string(), - "target": e.target_port.to_string(), - }), - ) - }) - .collect(); - edges.sort_by(|a, b| a.0.cmp(&b.0)); - - let mut selected_node: Vec<_> = graph - .selected_node() - .iter() - .map(ToString::to_string) - .collect(); - selected_node.sort(); - let mut selected_edge: Vec<_> = graph - .selected_edge() - .iter() - .map(ToString::to_string) - .collect(); - selected_edge.sort(); - - json!({ - "nodes": nodes, - "ports": ports, - "edges": edges, - "node_order": graph.node_order().iter().map(ToString::to_string).collect::>(), - "selected_node": selected_node, - "selected_edge": selected_edge - }) -} - -/// Asserts that `execute` + `undo` restores `base`, and that replaying `to_ops` matches `execute`. -pub fn assert_command_interop( - base: &Graph, - mut make: impl FnMut() -> Box, - case_name: &str, -) { - let expected_after_execute = { - let mut g = base.clone(); - with_command_ctx(&mut g, |ctx| { - let mut cmd = make(); - cmd.execute(ctx); - }); - g - }; - - let execute_then_undo = { - let mut g = base.clone(); - with_command_ctx(&mut g, |ctx| { - let mut cmd = make(); - cmd.execute(ctx); - cmd.undo(ctx); - }); - g - }; - assert_eq!( - graph_snapshot(&execute_then_undo), - graph_snapshot(base), - "execute+undo must restore original graph for {case_name}" - ); - - let via_ops = { - let mut g = base.clone(); - with_command_ctx(&mut g, |ctx| { - let cmd = make(); - let ops = cmd.to_ops(ctx); - for op in ops { - apply_graph_op(ctx.graph, op); - } - }); - g - }; - assert_eq!( - graph_snapshot(&via_ops), - graph_snapshot(&expected_after_execute), - "to_ops replay must match execute result for {case_name}" - ); -} diff --git a/crates/ferrum-flow/src/edge.rs b/crates/ferrum-flow/src/edge.rs deleted file mode 100644 index bf5f47d0cb..0000000000 --- a/crates/ferrum-flow/src/edge.rs +++ /dev/null @@ -1,123 +0,0 @@ -use std::{fmt::Display, str::FromStr as _}; - -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -use crate::{Graph, PortId}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct EdgeId(Uuid); - -impl Display for EdgeId { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.0) - } -} - -impl Default for EdgeId { - fn default() -> Self { - Self::new() - } -} - -impl EdgeId { - pub fn new() -> Self { - Self(Uuid::new_v4()) - } - pub fn from_string(s: impl Into) -> Option { - let string = s.into(); - Uuid::from_str(&string).ok().map(Self) - } - pub fn from_uuid(uuid: Uuid) -> Self { - Self(uuid) - } - - pub fn as_uuid(&self) -> &Uuid { - &self.0 - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Edge { - pub id: EdgeId, - pub source_port: PortId, - - pub target_port: PortId, -} - -impl Default for Edge { - fn default() -> Self { - Self::new() - } -} - -impl Edge { - pub fn new() -> Self { - Self { - id: EdgeId::new(), - source_port: PortId::new(), - target_port: PortId::new(), - } - } - pub fn source(mut self, port: PortId) -> Self { - self.source_port = port; - self - } - pub fn target(mut self, port: PortId) -> Self { - self.target_port = port; - self - } -} - -pub struct EdgeBuilder<'a> { - graph: Option<&'a mut Graph>, - source: Option, - target: Option, -} - -impl<'a> Default for EdgeBuilder<'a> { - fn default() -> Self { - Self::new() - } -} - -impl<'a> EdgeBuilder<'a> { - pub fn new() -> Self { - Self { - graph: None, - source: None, - target: None, - } - } - - pub fn graph(mut self, graph: &'a mut Graph) -> Self { - self.graph = Some(graph); - self - } - - pub fn source(mut self, port: PortId) -> Self { - self.source = Some(port); - self - } - - pub fn target(mut self, port: PortId) -> Self { - self.target = Some(port); - self - } - - pub fn build(self) -> Option { - let graph = self.graph?; - let source = self.source?; - let target = self.target?; - - let edge_id = graph.next_edge_id(); - - graph.add_edge(Edge { - id: edge_id, - source_port: source, - target_port: target, - }); - - Some(edge_id) - } -} diff --git a/crates/ferrum-flow/src/graph.rs b/crates/ferrum-flow/src/graph.rs deleted file mode 100644 index e75c52fd3e..0000000000 --- a/crates/ferrum-flow/src/graph.rs +++ /dev/null @@ -1,378 +0,0 @@ -use std::collections::hash_map::Values as HashMapValues; -use std::collections::hash_set::Iter as HashSetIter; -use std::collections::{HashMap, HashSet}; - -use gpui::{Bounds, Pixels, Point, Size, px}; -use serde::{Deserialize, Serialize}; - -use crate::edge::{Edge, EdgeId}; -use crate::node::{Node, NodeId, Port, PortId}; -use crate::{EdgeBuilder, NodeBuilder, PortKind, PortPosition, Viewport}; - -mod store; - -pub use store::{ChangeSource, GraphChange, GraphChangeKind, GraphOp}; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Graph { - nodes: HashMap, - node_order: Vec, - ports: HashMap, - - edges: HashMap, - - selected_edge: HashSet, - selected_node: HashSet, -} - -impl Default for Graph { - fn default() -> Self { - Self::new() - } -} - -impl Graph { - pub fn new() -> Self { - Self { - nodes: HashMap::new(), - node_order: vec![], - ports: HashMap::new(), - edges: HashMap::new(), - selected_edge: HashSet::new(), - selected_node: HashSet::new(), - } - } - - pub fn from_json(json: &str) -> serde_json::Result { - serde_json::from_str(json) - } - - pub fn to_json(&self) -> serde_json::Result { - serde_json::to_string(self) - } - - pub fn is_empty(&self) -> bool { - self.nodes.is_empty() - && self.ports.is_empty() - && self.edges.is_empty() - && self.node_order.is_empty() - } - - pub fn apply(&mut self, op: GraphChangeKind) { - match op { - GraphChangeKind::NodeAdded(node) => self.add_node(node), - GraphChangeKind::NodeRemoved { id } => self.remove_node(&id), - GraphChangeKind::NodeMoved { id, x, y } => { - if let Some(node) = self.nodes.get_mut(&id) { - node.set_position(px(x), px(y)); - } - } - GraphChangeKind::NodeSetWidthed { id, width } => { - if let Some(node) = self.nodes.get_mut(&id) { - node.set_size_width(px(width)); - } - } - GraphChangeKind::NodeSetHeighted { id, height } => { - if let Some(node) = self.nodes.get_mut(&id) { - node.set_size_height(px(height)); - } - } - GraphChangeKind::NodeDataUpdated { id, data } => { - if let Some(node) = self.nodes.get_mut(&id) { - node.set_data(data); - } - } - GraphChangeKind::NodeOrderUpdate(vec) => { - self.node_order = vec; - } - GraphChangeKind::PortAdded(port) => self.add_port(port), - GraphChangeKind::PortRemoved { id } => { - self.remove_port(&id); - } - GraphChangeKind::EdgeAdded(edge) => self.add_edge(edge), - GraphChangeKind::EdgeRemoved { id } => self.remove_edge(&id), - GraphChangeKind::RedrawRequested => {} - GraphChangeKind::Batch(graph_change_kinds) => { - for change in graph_change_kinds { - self.apply(change); - } - } - } - } - - pub fn create_node(&mut self, renderer_key: &str) -> NodeBuilder<'_> { - NodeBuilder::new(renderer_key).graph(self) - } - - pub fn create_edge(&mut self) -> EdgeBuilder<'_> { - EdgeBuilder::new().graph(self) - } - - #[deprecated(note = "use `Graph::create_edge`")] - pub fn create_dege(&mut self) -> EdgeBuilder<'_> { - EdgeBuilder::new().graph(self) - } - - pub fn next_node_id(&self) -> NodeId { - NodeId::new() - } - - pub fn next_port_id(&self) -> PortId { - PortId::new() - } - - pub fn next_edge_id(&self) -> EdgeId { - EdgeId::new() - } - - pub fn add_node(&mut self, node: Node) { - let node_id = node.id(); - self.nodes.insert(node.id(), node); - self.node_order.push(node_id); - } - #[cfg(any(test, feature = "testing"))] - pub(crate) fn add_node_without_order(&mut self, node: Node) { - self.nodes.insert(node.id(), node); - } - - pub fn add_port(&mut self, port: Port) { - let map = &mut self.ports; - map.insert(port.id(), port); - } - - pub fn remove_port(&mut self, id: &PortId) { - self.ports.remove(id); - } - - pub fn nodes(&self) -> &HashMap { - &self.nodes - } - - pub fn node_order(&self) -> &Vec { - &self.node_order - } - pub fn node_order_mut(&mut self) -> &mut Vec { - &mut self.node_order - } - pub fn ports(&self) -> &HashMap { - &self.ports - } - pub fn get_port(&self, id: &PortId) -> Option<&Port> { - self.ports.get(id) - } - pub fn ports_values(&self) -> HashMapValues<'_, PortId, Port> { - self.ports.values() - } - pub fn edges(&self) -> &HashMap { - &self.edges - } - pub fn get_edge(&self, id: &EdgeId) -> Option<&Edge> { - self.edges.get(id) - } - pub fn edges_values(&self) -> HashMapValues<'_, EdgeId, Edge> { - self.edges.values() - } - pub fn selected_node(&self) -> &HashSet { - &self.selected_node - } - pub fn selected_node_is_empty(&self) -> bool { - self.selected_node.is_empty() - } - pub fn selected_node_iter(&self) -> HashSetIter<'_, NodeId> { - self.selected_node.iter() - } - pub fn selected_edge(&self) -> &HashSet { - &self.selected_edge - } - pub fn selected_edge_iter(&self) -> HashSetIter<'_, EdgeId> { - self.selected_edge.iter() - } - pub fn set_selected_node(&mut self, selected: HashSet) { - self.selected_node = selected; - } - pub fn set_selected_edge(&mut self, selected: HashSet) { - self.selected_edge = selected; - } - - pub fn new_edge(&self) -> Edge { - Edge::new() - } - - pub fn add_edge(&mut self, edge: Edge) { - self.edges.insert(edge.id, edge); - } - - pub fn remove_edge(&mut self, edge_id: &EdgeId) { - self.edges.remove(edge_id); - self.selected_edge.remove(edge_id); - } - - pub fn get_node(&self, id: &NodeId) -> Option<&Node> { - self.nodes.get(id) - } - - pub fn get_node_mut(&mut self, id: &NodeId) -> Option<&mut Node> { - self.nodes.get_mut(id) - } - - pub fn remove_node(&mut self, id: &NodeId) { - let Some(node) = &self.nodes.get(id) else { - return; - }; - - let mut edge_ids_to_remove = HashSet::new(); - for port_id in node.inputs().iter().chain(node.outputs().iter()).copied() { - edge_ids_to_remove.extend( - self.edges - .iter() - .filter(|(_, edge)| edge.source_port == port_id || edge.target_port == port_id) - .map(|(id, _)| *id), - ); - self.ports.remove(&port_id); - } - for edge_id in edge_ids_to_remove { - self.remove_edge(&edge_id); - } - - self.nodes.remove(id); - self.selected_node.remove(id); - let index = self.node_order.iter().position(|v| *v == *id); - if let Some(index) = index { - self.node_order.remove(index); - } - } - - pub fn add_selected_node(&mut self, id: NodeId, shift: bool) { - if shift { - if self.selected_node.contains(&id) { - self.selected_node.remove(&id); - } else { - self.selected_node.insert(id); - } - } else { - self.selected_node.clear(); - self.selected_node.insert(id); - } - } - pub fn clear_selected_node(&mut self) { - self.selected_node.clear(); - } - - pub fn remove_selected_node(&mut self) -> bool { - if self.selected_node.is_empty() { - return false; - } - - let mut ids = vec![]; - for id in self.selected_node.iter() { - ids.push(*id); - } - for id in ids.iter() { - self.remove_node(id); - } - self.selected_node.clear(); - true - } - - pub fn add_selected_edge(&mut self, id: EdgeId, shift: bool) { - if shift { - if self.selected_edge.contains(&id) { - self.selected_edge.remove(&id); - } else { - self.selected_edge.insert(id); - } - } else { - self.selected_edge.clear(); - self.selected_edge.insert(id); - } - } - pub fn clear_selected_edge(&mut self) { - self.selected_edge.clear(); - } - - pub fn remove_selected_edge(&mut self) -> bool { - if self.selected_edge.is_empty() { - return false; - } - - let mut ids = vec![]; - for id in self.selected_edge.iter() { - ids.push(*id); - } - for id in ids.iter() { - self.edges.remove(id); - } - self.selected_edge.clear(); - true - } - - pub fn selection_bounds(&self) -> Option> { - let mut min_x = f32::MAX; - let mut min_y = f32::MAX; - let mut max_x = f32::MIN; - let mut max_y = f32::MIN; - - let mut found = false; - - for id in &self.selected_node { - let node = &self.nodes.get(id)?; - let (x, y) = node.position(); - let size = *node.size_ref(); - - min_x = min_x.min(x.into()); - min_y = min_y.min(y.into()); - - max_x = max_x.max((x + size.width).into()); - max_y = max_y.max((y + size.height).into()); - - found = true; - } - - if !found { - return None; - } - - Some(Bounds::new( - Point::new(px(min_x), px(min_y)), - Size::new(px(max_x - min_x), px(max_y - min_y)), - )) - } - - pub fn selected_nodes_with_positions(&self) -> HashMap> { - self.selected_node - .iter() - .filter_map(|id| { - let n = &self.nodes.get(id)?; - Some((*id, n.point())) - }) - .collect() - } - - pub fn hit_node(&self, mouse: Point, viewport: &Viewport) -> Option { - self.nodes - .iter() - .filter(|(_, node)| viewport.is_node_visible(node)) - .find(|(_, n)| n.bounds().contains(&mouse)) - .map(|(id, _)| *id) - } - - pub fn bring_node_to_front(&mut self, node_id: NodeId) { - if let Some(index) = self.node_order_mut().iter().position(|id| *id == node_id) { - self.node_order_mut().remove(index); - } - - self.node_order_mut().push(node_id); - } - - pub fn ports_on_node_side( - &self, - node_id: NodeId, - kind: PortKind, - position: PortPosition, - ) -> Vec<&Port> { - self.ports - .values() - .filter(|p| p.node_id() == node_id && p.kind() == kind && p.position() == position) - .collect() - } -} diff --git a/crates/ferrum-flow/src/graph/store.rs b/crates/ferrum-flow/src/graph/store.rs deleted file mode 100644 index c0408cdf6a..0000000000 --- a/crates/ferrum-flow/src/graph/store.rs +++ /dev/null @@ -1,102 +0,0 @@ -use gpui::{Pixels, Size}; -use serde::{Deserialize, Serialize}; - -use crate::{Edge, EdgeId, Node, NodeId, Port, PortId}; - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[non_exhaustive] -pub enum GraphOp { - // --- Node --- - AddNode(Node), - - RemoveNode { id: NodeId }, - - MoveNode { id: NodeId, x: f32, y: f32 }, - - ResizeNode { id: NodeId, size: Size }, - - UpdateNodeData { id: NodeId, data: serde_json::Value }, - - // --- node_order --- - NodeOrderInsert { id: NodeId }, - NodeOrderRemove { index: usize }, - - // --- Port --- - AddPort(Port), - - RemovePort(PortId), - - // --- Edge --- - AddEdge(Edge), - - RemoveEdge(EdgeId), - - Batch(Vec), -} - -#[derive(Debug, Clone)] -pub struct GraphChange { - pub kind: GraphChangeKind, - pub source: ChangeSource, -} - -impl GraphChange { - pub fn is_local(&self) -> bool { - matches!(self.source, ChangeSource::Local) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ChangeSource { - Local, - Remote, - Undo, - Redo, -} - -#[derive(Debug, Clone)] -#[non_exhaustive] -pub enum GraphChangeKind { - // --- Node --- - NodeAdded(Node), - NodeRemoved { - id: NodeId, - }, - NodeMoved { - id: NodeId, - x: f32, - y: f32, - }, - NodeSetWidthed { - id: NodeId, - width: f32, - }, - NodeSetHeighted { - id: NodeId, - height: f32, - }, - NodeDataUpdated { - id: NodeId, - data: serde_json::Value, - }, - - // --- node_order --- - NodeOrderUpdate(Vec), - - // --- Port --- - PortAdded(Port), - PortRemoved { - id: PortId, - }, - - // --- Edge --- - EdgeAdded(Edge), - EdgeRemoved { - id: EdgeId, - }, - - /// No graph mutation; used to request a frame repaint (e.g. after remote awareness updates). - RedrawRequested, - - Batch(Vec), -} diff --git a/crates/ferrum-flow/src/lib.rs b/crates/ferrum-flow/src/lib.rs deleted file mode 100644 index 425b1f7d6b..0000000000 --- a/crates/ferrum-flow/src/lib.rs +++ /dev/null @@ -1,35 +0,0 @@ -mod canvas; -#[cfg(any(feature = "testing", test))] -pub mod command_interop; -mod edge; -mod graph; -mod node; -mod plugin; -#[cfg(any(feature = "testing", test))] -pub mod plugin_testing; -mod plugins; -mod port_screen; -mod shared_state; -mod theme; -mod viewport; - -/// Prefer [`RenderContext::port_screen_frame`](crate::plugin::RenderContext::port_screen_frame). -#[allow(deprecated)] -pub use canvas::port_screen_position; -pub use canvas::{ - Command, CommandContext, CompositeCommand, FlowCanvas, FlowCanvasOutbound, HistoryProvider, - Interaction, InteractionResult, InteractionState, LocalHistory, NodeRenderer, RendererRegistry, - default_node_caption, -}; -pub use edge::*; -pub use graph::*; -pub use node::*; -pub use plugin::{ - EventResult, FlowEvent, InitPluginContext, InputEvent, NodeCardVariant, Plugin, PluginContext, - RenderContext, RenderLayer, SyncPlugin, SyncPluginContext, primary_platform_modifier, -}; -pub use plugins::*; -pub use port_screen::PortScreenFrame; -pub use shared_state::SharedState; -pub use theme::FlowTheme; -pub use viewport::Viewport; diff --git a/crates/ferrum-flow/src/node.rs b/crates/ferrum-flow/src/node.rs deleted file mode 100644 index 674f862cd6..0000000000 --- a/crates/ferrum-flow/src/node.rs +++ /dev/null @@ -1,760 +0,0 @@ -use std::{collections::HashMap, fmt::Display, str::FromStr}; - -use gpui::{Bounds, Pixels, Point, Size, px}; -use serde::{Deserialize, Serialize}; -use serde_json::json; -use uuid::Uuid; - -use crate::Graph; - -pub const DEFAULT_NODE_WIDTH: Pixels = px(120.0); -pub const DEFAULT_NODE_HEIGHT: Pixels = px(60.0); - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct NodeId(Uuid); - -impl Display for NodeId { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.0) - } -} - -impl Default for NodeId { - fn default() -> Self { - Self::new() - } -} - -impl NodeId { - pub fn new() -> Self { - Self(Uuid::new_v4()) - } - pub fn from_string(s: impl Into) -> Option { - let string = s.into(); - Uuid::from_str(&string).ok().map(Self) - } - pub fn from_uuid(uuid: Uuid) -> Self { - Self(uuid) - } - - pub fn as_uuid(&self) -> &Uuid { - &self.0 - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Node { - // Transitional API: these fields stay public for compatibility in this release. - // Prefer using methods on `Node`; fields will become private in a future release. - #[deprecated(note = "Use `Node::id()` instead; fields will be private in next release.")] - pub id: NodeId, - #[deprecated( - note = "Use `Node::renderer_key()` / `Node::set_renderer_key()` instead; fields will be private in next release." - )] - pub node_type: String, - #[deprecated( - note = "Use `Node::execute_type_ref()` / `Node::set_execute_type()` instead; fields will be private in next release." - )] - pub execute_type: String, - #[deprecated( - note = "Use `Node::position()` / `Node::set_position()` instead; fields will be private in next release." - )] - pub x: Pixels, - #[deprecated( - note = "Use `Node::position()` / `Node::set_position()` instead; fields will be private in next release." - )] - pub y: Pixels, - #[deprecated( - note = "Use `Node::size_ref()` / `Node::set_size_mut()` instead; fields will be private in next release." - )] - pub size: Size, - - #[deprecated( - note = "Use `Node::inputs()` / `Node::push_input()` instead; fields will be private in next release." - )] - pub inputs: Vec, - #[deprecated( - note = "Use `Node::outputs()` / `Node::push_output()` instead; fields will be private in next release." - )] - pub outputs: Vec, - #[deprecated( - note = "Use `Node::data_ref()` / `Node::data_mut()` / `Node::set_data()` instead; fields will be private in next release." - )] - pub data: serde_json::Value, -} - -impl Node { - // Transitional period: `Node` fields are deprecated for external callers, - // but internal constructors/methods still need to read/write those fields. - #[allow(deprecated)] - pub fn new(x: f32, y: f32) -> Self { - Self { - id: NodeId::new(), - node_type: String::new(), - execute_type: String::new(), - x: x.into(), - y: y.into(), - size: Size { - width: DEFAULT_NODE_WIDTH, - height: DEFAULT_NODE_HEIGHT, - }, - inputs: vec![], - outputs: vec![], - data: json!({}), - } - } - - #[allow(deprecated)] - pub fn id(&self) -> NodeId { - self.id - } - - #[allow(deprecated)] - pub(crate) fn set_id(&mut self, id: NodeId) { - self.id = id; - } - - #[allow(deprecated)] - pub fn renderer_key(&self) -> &str { - &self.node_type - } - - #[allow(deprecated)] - pub fn execute_type_ref(&self) -> &str { - &self.execute_type - } - - #[allow(deprecated)] - pub fn set_renderer_key(&mut self, node_type: impl Into) { - self.node_type = node_type.into(); - } - - #[allow(deprecated)] - pub fn set_execute_type(&mut self, execute_type: impl Into) { - self.execute_type = execute_type.into(); - } - - #[allow(deprecated)] - pub fn position(&self) -> (Pixels, Pixels) { - (self.x, self.y) - } - - #[allow(deprecated)] - pub fn position_point(&self) -> Point { - Point::new(self.x, self.y) - } - - #[allow(deprecated)] - pub fn size_ref(&self) -> &Size { - &self.size - } - - #[allow(deprecated)] - pub fn inputs(&self) -> &[PortId] { - &self.inputs - } - - #[allow(deprecated)] - pub fn outputs(&self) -> &[PortId] { - &self.outputs - } - - #[allow(deprecated)] - pub fn data_ref(&self) -> &serde_json::Value { - &self.data - } - - #[allow(deprecated)] - pub fn data_mut(&mut self) -> &mut serde_json::Value { - &mut self.data - } - - #[allow(deprecated)] - pub fn set_position(&mut self, x: Pixels, y: Pixels) { - self.x = x; - self.y = y; - } - - #[allow(deprecated)] - pub fn set_position_with_point(&mut self, point: Point) { - self.x = point.x; - self.y = point.y; - } - - #[allow(deprecated)] - pub fn set_size_mut(&mut self, size: Size) { - self.size = size; - } - - #[allow(deprecated)] - pub fn set_size_width(&mut self, width: Pixels) { - self.size.width = width; - } - - #[allow(deprecated)] - pub fn set_size_height(&mut self, height: Pixels) { - self.size.height = height; - } - - #[allow(deprecated)] - pub fn set_data(&mut self, data: serde_json::Value) { - self.data = data; - } - - #[allow(deprecated)] - pub fn push_input(&mut self, id: PortId) { - self.inputs.push(id); - } - - #[allow(deprecated)] - pub fn push_output(&mut self, id: PortId) { - self.outputs.push(id); - } - - #[allow(deprecated)] - pub fn point(&self) -> Point { - Point::new(self.x, self.y) - } - - #[allow(deprecated)] - pub fn bounds(&self) -> Bounds { - Bounds::new(self.point(), self.size) - } - - #[allow(deprecated)] - pub fn set_size(mut self, size: Size) -> Self { - self.size = size; - self - } - - #[allow(deprecated)] - pub fn output(mut self, id: PortId) -> Self { - self.outputs.push(id); - self - } - - #[allow(deprecated)] - pub fn input(mut self, id: PortId) -> Self { - self.inputs.push(id); - self - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct PortId(Uuid); - -impl Display for PortId { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.0) - } -} - -impl Default for PortId { - fn default() -> Self { - Self::new() - } -} - -impl PortId { - pub fn new() -> Self { - Self(Uuid::new_v4()) - } - pub fn from_string(s: impl Into) -> Option { - let string = s.into(); - Uuid::from_str(&string).ok().map(Self) - } - pub fn from_uuid(uuid: Uuid) -> Self { - Self(uuid) - } - - pub fn as_uuid(&self) -> &Uuid { - &self.0 - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum PortKind { - Input, - Output, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)] -pub enum PortPosition { - Left, - Right, - Top, - Bottom, -} - -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub enum PortType { - Any, - Bool, - Int, - Float, - String, - List(Box), - Map(Box, Box), - Custom(String), - Union(Vec), -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Port { - // Transitional API: these fields stay public for compatibility in this release. - // Prefer using methods on `Port`; fields will become private in a future release. - #[deprecated(note = "Use `Port::id()` instead; fields will be private in next release.")] - pub id: PortId, - #[deprecated(note = "Use `Port::kind()` instead; fields will be private in next release.")] - pub kind: PortKind, - #[deprecated( - note = "Use `Port::index()` / `Port::set_index()` instead; fields will be private in next release." - )] - pub index: usize, - #[deprecated(note = "Use `Port::node_id()` instead; fields will be private in next release.")] - pub node_id: NodeId, - #[deprecated( - note = "Use `Port::position()` / `Port::set_position()` instead; fields will be private in next release." - )] - pub position: PortPosition, - #[deprecated( - note = "Use `Port::size_ref()` / `Port::set_size()` instead; fields will be private in next release." - )] - pub size: Size, - #[deprecated( - note = "Use `Port::port_type_ref()` / `Port::port_type_mut()` instead; fields will be private in next release." - )] - pub port_type: PortType, -} - -impl Port { - #[allow(clippy::too_many_arguments)] - #[allow(deprecated)] - pub fn new( - id: PortId, - kind: PortKind, - index: usize, - node_id: NodeId, - position: PortPosition, - size: Size, - port_type: PortType, - ) -> Self { - Self { - id, - kind, - index, - node_id, - position, - size, - port_type, - } - } - - #[allow(deprecated)] - pub fn id(&self) -> PortId { - self.id - } - - #[allow(deprecated)] - pub fn kind(&self) -> PortKind { - self.kind - } - - #[allow(deprecated)] - pub fn index(&self) -> usize { - self.index - } - - #[allow(deprecated)] - pub fn node_id(&self) -> NodeId { - self.node_id - } - - #[allow(deprecated)] - pub fn position(&self) -> PortPosition { - self.position - } - - #[allow(deprecated)] - pub fn size_ref(&self) -> &Size { - &self.size - } - - #[allow(deprecated)] - pub fn port_type_ref(&self) -> &PortType { - &self.port_type - } - - #[allow(deprecated)] - pub fn port_type_mut(&mut self) -> &mut PortType { - &mut self.port_type - } - - #[allow(deprecated)] - pub fn set_size(&mut self, size: Size) { - self.size = size; - } - - #[allow(deprecated)] - pub fn set_index(&mut self, index: usize) { - self.index = index; - } - - #[allow(deprecated)] - pub fn set_position(&mut self, position: PortPosition) { - self.position = position; - } -} - -impl Display for PortKind { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - PortKind::Input => write!(f, "input"), - PortKind::Output => write!(f, "output"), - } - } -} - -impl Display for PortPosition { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - PortPosition::Left => write!(f, "left"), - PortPosition::Right => write!(f, "right"), - PortPosition::Top => write!(f, "top"), - PortPosition::Bottom => write!(f, "bottom"), - } - } -} - -impl FromStr for PortPosition { - type Err = anyhow::Error; - - fn from_str(s: &str) -> Result { - match s { - "right" => Ok(Self::Right), - "top" => Ok(Self::Top), - "bottom" => Ok(Self::Bottom), - "left" => Ok(Self::Left), - _ => Err(anyhow::anyhow!("Invalid port position: {}", s)), - } - } -} - -pub struct NodeBuilder<'a> { - graph: Option<&'a mut Graph>, - node_type: String, - execute_type: String, - x: Pixels, - y: Pixels, - size: Size, - inputs: Vec, - outputs: Vec, - data: serde_json::Value, -} - -#[derive(Clone)] -pub struct PortSpec { - position: PortPosition, - size: Size, - port_type: PortType, -} - -impl PortSpec { - pub fn input(position: PortPosition) -> Self { - Self { - position, - size: DEFAULT_PORT_SIZE, - port_type: PortType::Any, - } - } - - pub fn output(position: PortPosition) -> Self { - Self { - position, - size: DEFAULT_PORT_SIZE, - port_type: PortType::Any, - } - } - - pub fn with_size(mut self, size: Size) -> Self { - self.size = size; - self - } - - pub fn with_type(mut self, port_type: PortType) -> Self { - self.port_type = port_type; - self - } -} - -const DEFAULT_PORT_SIZE: Size = Size { - width: px(12.0), - height: px(12.0), -}; - -pub struct PortBuilder { - id: PortId, - kind: PortKind, - index: usize, - node_id: NodeId, - position: PortPosition, - size: Size, - port_type: PortType, -} - -impl PortBuilder { - pub fn new(id: PortId) -> Self { - Self { - id, - kind: PortKind::Input, - index: 0, - node_id: NodeId::from_uuid(Uuid::nil()), - position: PortPosition::Left, - size: DEFAULT_PORT_SIZE, - port_type: PortType::Any, - } - } - - pub fn kind(mut self, kind: PortKind) -> Self { - self.kind = kind; - self - } - - pub fn node_id(mut self, node_id: NodeId) -> Self { - self.node_id = node_id; - self - } - - pub fn index(mut self, index: usize) -> Self { - self.index = index; - self - } - - pub fn position(mut self, position: PortPosition) -> Self { - self.position = position; - self - } - - pub fn size(mut self, width: f32, height: f32) -> Self { - self.size = Size::new(px(width), px(height)); - self - } - - pub fn port_type(mut self, port_type: PortType) -> Self { - self.port_type = port_type; - self - } - - pub fn build(self) -> Port { - Port::new( - self.id, - self.kind, - self.index, - self.node_id, - self.position, - self.size, - self.port_type, - ) - } -} - -impl<'a> NodeBuilder<'a> { - pub fn new(renderer_key: impl Into) -> NodeBuilder<'static> { - NodeBuilder { - graph: None, - node_type: renderer_key.into(), - execute_type: String::new(), - x: px(0.0), - y: px(0.0), - size: Size { - width: DEFAULT_NODE_WIDTH, - height: DEFAULT_NODE_HEIGHT, - }, - inputs: vec![], - outputs: vec![], - data: json!({}), - } - } - - pub fn graph(mut self, graph: &'a mut Graph) -> NodeBuilder<'a> { - self.graph = Some(graph); - self - } - - pub fn execute_type(mut self, execute_type: impl Into) -> Self { - self.execute_type = execute_type.into(); - self - } - - pub fn position(mut self, x: f32, y: f32) -> Self { - self.x = x.into(); - self.y = y.into(); - self - } - - pub fn size(mut self, w: f32, h: f32) -> Self { - self.size = Size { - width: w.into(), - height: h.into(), - }; - self - } - - fn push_input_spec(&mut self, spec: PortSpec) { - self.inputs.push(spec); - } - - fn push_output_spec(&mut self, spec: PortSpec) { - self.outputs.push(spec); - } - - pub fn input(mut self) -> Self { - self.push_input_spec(PortSpec::input(PortPosition::Left)); - self - } - - pub fn output(mut self) -> Self { - self.push_output_spec(PortSpec::output(PortPosition::Right)); - self - } - - pub fn input_at(mut self, pos: PortPosition) -> Self { - self.push_input_spec(PortSpec::input(pos)); - self - } - - pub fn output_at(mut self, pos: PortPosition) -> Self { - self.push_output_spec(PortSpec::output(pos)); - self - } - - pub fn input_with(mut self, pos: PortPosition, size: Size) -> Self { - self.push_input_spec(PortSpec::input(pos).with_size(size)); - self - } - - pub fn output_with(mut self, pos: PortPosition, size: Size) -> Self { - self.push_output_spec(PortSpec::output(pos).with_size(size)); - self - } - - pub fn input_port(mut self, spec: PortSpec) -> Self { - self.push_input_spec(spec); - self - } - - pub fn output_port(mut self, spec: PortSpec) -> Self { - self.push_output_spec(spec); - self - } - - pub fn data(mut self, data: serde_json::Value) -> Self { - self.data = data; - self - } - - /// Like [`Self::build_raw`], but uses the given node id and input/output port id lists. - /// Returns an empty port vector: port records are expected to be loaded separately - /// (e.g. from persistence). Any [`PortSpec`]s on this builder are ignored. - #[allow(deprecated)] - pub fn build_raw_with_port_ids( - self, - node_id: NodeId, - input_ids: Vec, - output_ids: Vec, - ) -> Node { - Node { - id: node_id, - node_type: self.node_type, - execute_type: self.execute_type, - x: self.x, - y: self.y, - size: self.size, - inputs: input_ids, - outputs: output_ids, - data: self.data, - } - } - - #[allow(deprecated)] - pub fn build_raw(self) -> (Node, Vec, Option<&'a mut Graph>) { - let node_id = NodeId::new(); - - let mut inputs = Vec::new(); - let mut outputs = Vec::new(); - - let mut input_counters: HashMap = HashMap::new(); - - // Create input ports - let mut ports = vec![]; - for spec in self.inputs { - let port_id = PortId::new(); - - let index = input_counters.entry(spec.position).or_insert(0); - let current_index = *index; - *index += 1; - - ports.push(Port { - id: port_id, - kind: PortKind::Input, - index: current_index, - node_id, - position: spec.position, - size: spec.size, - port_type: spec.port_type, - }); - - inputs.push(port_id); - } - - let mut output_counters: HashMap = HashMap::new(); - - // Create output ports - for spec in self.outputs { - let port_id = PortId::new(); - - let index = output_counters.entry(spec.position).or_insert(0); - let current_index = *index; - *index += 1; - - ports.push(Port { - id: port_id, - kind: PortKind::Output, - index: current_index, - node_id, - position: spec.position, - size: spec.size, - port_type: spec.port_type, - }); - - outputs.push(port_id); - } - - ( - Node { - id: node_id, - node_type: self.node_type, - execute_type: self.execute_type, - x: self.x, - y: self.y, - size: self.size, - inputs, - outputs, - data: self.data, - }, - ports, - self.graph, - ) - } - - pub fn build(self) -> Option { - let (node, ports, graph) = self.build_raw(); - let id = node.id(); - let graph = graph?; - graph.add_node(node); - for port in ports { - graph.add_port(port); - } - Some(id) - } -} diff --git a/crates/ferrum-flow/src/plugin.rs b/crates/ferrum-flow/src/plugin.rs deleted file mode 100644 index af4252083c..0000000000 --- a/crates/ferrum-flow/src/plugin.rs +++ /dev/null @@ -1,1089 +0,0 @@ -use std::{any::Any, collections::HashMap, time::Duration}; - -use gpui::{ - AnyElement, Bounds, Context, Div, ElementId, InteractiveElement as _, KeyDownEvent, KeyUpEvent, - MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Point, ScrollWheelEvent, Size, Stateful, - Styled, Window, div, rgb, -}; - -use crate::{ - Edge, EdgeBuilder, EdgeId, FlowCanvas, FlowTheme, Graph, GraphOp, Node, NodeBuilder, NodeId, - NodeRenderer, Port, PortId, PortPosition, RendererRegistry, SharedState, Viewport, - canvas::{ - Command, CommandContext, HistoryProvider, Interaction, InteractionState, PortLayoutCache, - }, - port_screen::PortScreenFrame, -}; - -mod sync; -mod utils; - -pub use sync::{SyncPlugin, SyncPluginContext}; - -pub use utils::{ - invalidate_port_layout_cache_for_graph_change, is_edge_visible, is_node_visible, - primary_platform_modifier, -}; - -/// Chrome for [`RenderContext::node_card_shell`]. [`NodeCardVariant::Default`] and -/// [`NodeCardVariant::UndefinedType`] read colors from [`RenderContext::theme`]; plugins may change -/// them via [`InitPluginContext::theme`] / [`PluginContext::theme`]. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum NodeCardVariant { - /// Card from [`FlowTheme::node_card_background`] and border from [`FlowTheme::node_card_border`] - /// / [`FlowTheme::node_card_border_selected`] when `selected`. - Default, - /// Card from [`FlowTheme::undefined_node_background`] and [`FlowTheme::undefined_node_border`] - /// (no selection styling). - UndefinedType, - - /// Geometry and border width only; set `.bg` / `.border_color` yourself. - Custom, -} - -pub trait Plugin { - fn name(&self) -> &'static str; - - fn setup(&mut self, _ctx: &mut InitPluginContext) {} - - fn on_event(&mut self, _event: &FlowEvent, _ctx: &mut PluginContext) -> EventResult { - EventResult::Continue - } - - fn render(&mut self, _ctx: &mut RenderContext) -> Option { - None - } - - fn priority(&self) -> i32 { - 0 - } - - fn render_layer(&self) -> RenderLayer { - RenderLayer::Overlay - } -} - -pub struct InitPluginContext<'a, 'b> { - graph: &'a mut Graph, - port_offset_cache: &'a mut PortLayoutCache, - viewport: &'a mut Viewport, - renderers: &'a mut RendererRegistry, - pub gpui_ctx: &'a Context<'b, FlowCanvas>, - /// Drawable size from the `window` passed to [`FlowCanvas::builder`] (`Window::viewport_size` when `build()` runs). - pub drawable_size: Size, - /// Canvas colors and strokes; mutate in [`Plugin::setup`](Plugin::setup) to customize chrome. - pub theme: &'a mut FlowTheme, - /// Plugin-local shared state on the [`FlowCanvas`](FlowCanvas). - pub shared_state: &'a mut SharedState, - // pub notify: &'a mut dyn FnMut(), -} - -impl<'a, 'b> InitPluginContext<'a, 'b> { - #[allow(clippy::too_many_arguments)] - pub(crate) fn new( - graph: &'a mut Graph, - port_offset_cache: &'a mut PortLayoutCache, - viewport: &'a mut Viewport, - renderers: &'a mut RendererRegistry, - gpui_ctx: &'a Context<'b, FlowCanvas>, - drawable_size: Size, - theme: &'a mut FlowTheme, - shared_state: &'a mut SharedState, - ) -> Self { - Self { - graph, - port_offset_cache, - viewport, - renderers, - gpui_ctx, - drawable_size, - theme, - shared_state, - } - } - pub fn create_node(&mut self, node_type: &str) -> NodeBuilder<'_> { - self.graph.create_node(node_type) - } - - pub fn create_edge(&mut self) -> EdgeBuilder<'_> { - self.graph.create_edge() - } - - pub fn next_node_id(&self) -> NodeId { - self.graph.next_node_id() - } - - pub fn next_port_id(&self) -> PortId { - self.graph.next_port_id() - } - - pub fn next_edge_id(&self) -> EdgeId { - self.graph.next_edge_id() - } - - pub fn add_node(&mut self, node: Node) { - self.graph.add_node(node); - } - - pub fn add_port(&mut self, port: Port) { - self.graph.add_port(port); - } - - pub fn get_node(&self, id: &NodeId) -> Option<&Node> { - self.graph.get_node(id) - } - - pub fn get_node_mut(&mut self, id: &NodeId) -> Option<&mut Node> { - self.graph.get_node_mut(id) - } - pub fn remove_node(&mut self, id: &NodeId) { - self.graph.remove_node(id); - } - pub fn nodes(&self) -> &HashMap { - self.graph.nodes() - } - pub fn node_order(&self) -> &Vec { - self.graph.node_order() - } - - pub fn new_edge(&self) -> Edge { - self.graph.new_edge() - } - - pub fn add_edge(&mut self, edge: Edge) { - self.graph.add_edge(edge); - } - - pub fn remove_edge(&mut self, edge_id: &EdgeId) { - self.graph.remove_edge(edge_id); - } - - pub fn add_selected_node(&mut self, id: NodeId, shift: bool) { - self.graph.add_selected_node(id, shift); - } - pub fn clear_selected_node(&mut self) { - self.graph.clear_selected_node(); - } - pub fn remove_selected_node(&mut self) -> bool { - self.graph.remove_selected_node() - } - - pub fn add_selected_edge(&mut self, id: EdgeId, shift: bool) { - self.graph.add_selected_edge(id, shift); - } - pub fn clear_selected_edge(&mut self) { - self.graph.clear_selected_edge(); - } - pub fn remove_selected_edge(&mut self) -> bool { - self.graph.remove_selected_edge() - } - - pub fn selection_bounds(&self) -> Option> { - self.graph.selection_bounds() - } - - pub fn selected_nodes_with_positions(&self) -> HashMap> { - self.graph.selected_nodes_with_positions() - } - - pub fn hit_node(&self, mouse: Point) -> Option { - self.graph.hit_node(mouse, self.viewport) - } - - pub fn bring_node_to_front(&mut self, node_id: NodeId) { - self.graph.bring_node_to_front(node_id); - } - - // ---- Viewport shortcuts ---- - pub fn zoom(&self) -> f32 { - self.viewport.zoom() - } - - pub fn set_zoom(&mut self, zoom: f32) { - self.viewport.set_zoom(zoom); - } - - pub fn zoom_scaled_by(&self, factor: f32) -> f32 { - self.viewport.zoom_scaled_by(factor) - } - - pub fn offset(&self) -> Point { - self.viewport.offset() - } - - pub fn set_offset(&mut self, offset: Point) { - self.viewport.set_offset(offset); - } - - pub fn set_offset_xy(&mut self, x: Pixels, y: Pixels) { - self.viewport.set_offset_xy(x, y); - } - - pub fn translate_offset(&mut self, dx: Pixels, dy: Pixels) { - self.viewport.translate_offset(dx, dy); - } - - pub fn window_bounds(&self) -> Option> { - self.viewport.window_bounds() - } - - pub fn set_window_bounds(&mut self, bounds: Option>) { - self.viewport.set_window_bounds(bounds); - } - - pub fn world_scalar_to_screen(&self, value: f32) -> f32 { - self.viewport.world_scalar_to_screen(value) - } - - pub fn screen_scalar_to_world(&self, value: f32) -> f32 { - self.viewport.screen_scalar_to_world(value) - } - - pub fn world_length_to_screen(&self, value: Pixels) -> Pixels { - self.viewport.world_length_to_screen(value) - } - - pub fn screen_length_to_world(&self, value: Pixels) -> Pixels { - self.viewport.screen_length_to_world(value) - } - - pub fn world_to_screen(&self, p: Point) -> Point { - self.viewport.world_to_screen(p) - } - - pub fn screen_to_world(&self, p: Point) -> Point { - self.viewport.screen_to_world(p) - } - - pub fn edge_control_point( - &self, - source: Point, - position: PortPosition, - ) -> Point { - self.viewport.edge_control_point(source, position) - } - - pub fn is_node_visible(&self, node_id: &NodeId) -> bool { - is_node_visible(self.graph, self.viewport, node_id) - } - pub fn is_node_visible_node(&self, node: &Node) -> bool { - self.viewport.is_node_visible(node) - } - - pub fn is_edge_visible(&self, edge: &Edge) -> bool { - is_edge_visible(self.graph, self.viewport, edge) - } - - pub fn port_offset_cached(&self, node_id: &NodeId, port_id: &PortId) -> Option> { - self.port_offset_cache.get_offset(node_id, port_id) - } - - /// Port center in screen pixels when you already have the owning [`Node`]. - /// *warning*: this is using port offset cache, so it will not be accurate if the port offset is not cached. - pub fn port_screen_center(&self, node: &Node, port_id: PortId) -> Option> { - let node_pos = node.point(); - let offset = self.port_offset_cached(&node.id(), &port_id)?; - Some(self.viewport.world_to_screen(node_pos + offset)) - } - - /// Like [`Self::port_screen_center`], resolving the port from [`Graph::ports`]. - /// *warning*: this is using port offset cache, so it will not be accurate if the port offset is not cached. - pub fn port_screen_center_by_port_id(&self, port_id: PortId) -> Option> { - let port = self.graph.get_port(&port_id)?; - let node = self.get_node(&port.node_id())?; - self.port_screen_center(node, port_id) - } - - /// Full port layout for custom [`NodeRenderer::port_render`]. - /// *warning*: this is using port offset cache, so it will not be accurate if the port offset is not cached. - pub fn port_screen_frame(&self, node: &Node, port: &Port) -> Option { - Some(PortScreenFrame { - center: self.port_screen_center(node, port.id())?, - size: *port.size_ref(), - zoom: self.viewport.zoom(), - port_id: port.id(), - }) - } - - pub fn cache_port_offset_with_node(&mut self, node_ids: &Vec) { - for node_id in node_ids { - self.cache_node_port_offset(node_id); - } - } - - pub fn cache_port_offset_with_edge(&mut self, edge_id: &EdgeId) { - self.port_offset_cache - .ensure_edge_ports(self.graph, self.renderers, edge_id); - } - - pub fn cache_port_offset_with_port(&mut self, port_id: &PortId) { - self.port_offset_cache - .ensure_node_ports_for_port(self.graph, self.renderers, port_id); - } - - fn cache_node_port_offset(&mut self, node_id: &NodeId) { - self.port_offset_cache - .ensure_node_ports(self.graph, self.renderers, node_id); - } -} - -pub struct PluginContext<'a> { - pub graph: &'a mut Graph, - port_offset_cache: &'a mut PortLayoutCache, - viewport: &'a mut Viewport, - pub(crate) interaction: &'a mut InteractionState, - renderers: &'a mut RendererRegistry, - - sync_plugin: &'a mut Option>, - - history: &'a mut dyn HistoryProvider, - /// Canvas theme; change during event handling and call [`PluginContext::notify`] to redraw. - pub theme: &'a mut FlowTheme, - /// Plugin-local shared state on the [`FlowCanvas`](FlowCanvas). - pub shared_state: &'a mut SharedState, - emit: &'a mut dyn FnMut(FlowEvent), - notify: &'a mut dyn FnMut(), - schedule_after: &'a mut dyn FnMut(Duration), -} - -pub enum EventResult { - Continue, - Stop, -} - -impl<'a> PluginContext<'a> { - #[allow(clippy::too_many_arguments)] - pub(crate) fn new( - graph: &'a mut Graph, - port_offset_cache: &'a mut PortLayoutCache, - viewport: &'a mut Viewport, - interaction: &'a mut InteractionState, - renderers: &'a mut RendererRegistry, - sync_plugin: &'a mut Option>, - history: &'a mut dyn HistoryProvider, - theme: &'a mut FlowTheme, - shared_state: &'a mut SharedState, - emit: &'a mut dyn FnMut(FlowEvent), - notify: &'a mut dyn FnMut(), - schedule_after: &'a mut dyn FnMut(Duration), - ) -> Self { - Self { - graph, - port_offset_cache, - viewport, - interaction, - renderers, - sync_plugin, - history, - theme, - shared_state, - emit, - notify, - schedule_after, - } - } - - pub fn start_interaction(&mut self, handler: impl Interaction + 'static) { - self.interaction.handler = Some(Box::new(handler)); - } - - pub fn cancel_interaction(&mut self) { - self.interaction.handler = None; - } - - pub fn has_interaction(&self) -> bool { - self.interaction.handler.is_some() - } - - /// Tell GPUI that this entity has changed and observers of it should be notified. - pub fn notify(&mut self) { - (self.notify)(); - } - - /// Schedule a future canvas refresh after a delay. - pub fn schedule_after(&mut self, delay: Duration) { - (self.schedule_after)(delay); - } - - /// Enqueue a follow-up event for plugins and notify the canvas. If the host registered - /// [`FlowCanvas::set_outbound`](crate::canvas::FlowCanvas::set_outbound) or - /// [`FlowCanvasBuilder::outbound`](crate::canvas::FlowCanvasBuilder::outbound), the same - /// `event` is passed there first (synchronous, read-only). - pub fn emit(&mut self, event: FlowEvent) { - (self.emit)(event); - self.notify(); - } - - pub fn has_sync_plugin(&self) -> bool { - self.sync_plugin.is_some() - } - - pub fn execute_command(&mut self, command: impl Command + 'static) { - let mut ctx = CommandContext::new( - self.graph, - self.port_offset_cache, - self.viewport, - self.renderers, - self.shared_state, - self.notify, - ); - if let Some(sync) = &mut self.sync_plugin { - sync.process_intent(GraphOp::Batch(command.to_ops(&mut ctx))); - - self.notify(); - } else { - self.history.push(Box::new(command), &mut ctx); - - self.notify(); - } - } - - pub fn undo(&mut self) { - if let Some(sync) = &mut self.sync_plugin { - sync.undo(); - } else { - let mut ctx = CommandContext::new( - self.graph, - self.port_offset_cache, - self.viewport, - self.renderers, - self.shared_state, - self.notify, - ); - - self.history.undo(&mut ctx); - - self.notify(); - } - } - - pub fn redo(&mut self) { - if let Some(sync) = &mut self.sync_plugin { - sync.redo(); - } else { - let mut ctx = CommandContext::new( - self.graph, - self.port_offset_cache, - self.viewport, - self.renderers, - self.shared_state, - self.notify, - ); - - self.history.redo(&mut ctx); - - self.notify(); - } - } - - pub fn history_clear(&mut self) { - self.history.clear(); - } - - pub fn create_node(&mut self, node_type: &str) -> NodeBuilder<'_> { - self.graph.create_node(node_type) - } - - pub fn create_edge(&mut self) -> EdgeBuilder<'_> { - self.graph.create_edge() - } - - pub fn next_node_id(&self) -> NodeId { - self.graph.next_node_id() - } - - pub fn next_port_id(&self) -> PortId { - self.graph.next_port_id() - } - - pub fn next_edge_id(&self) -> EdgeId { - self.graph.next_edge_id() - } - - pub fn add_node(&mut self, node: Node) { - self.graph.add_node(node); - } - - pub fn add_port(&mut self, port: Port) { - self.graph.add_port(port); - } - - pub fn get_node(&self, id: &NodeId) -> Option<&Node> { - self.graph.get_node(id) - } - - pub fn get_node_render(&self, id: &NodeId) -> Option<&dyn NodeRenderer> { - let node = self.get_node(id)?; - - Some(self.renderers.get(node.renderer_key())) - } - - /// World-space offset from the node's top-left ([`Node::point`]) to the port anchor used for - /// edge wiring (same as [`NodeRenderer::port_offset`]). - /// - /// `graph` must contain `node` and that node's ports (a scratch graph is fine) so multi-port - /// spacing matches runtime layout. - pub(crate) fn port_world_offset_relative( - &self, - graph: &Graph, - node: &Node, - port: &Port, - ) -> Point { - self.renderers - .get(node.renderer_key()) - .port_offset(node, port, graph) - } - - pub fn get_node_mut(&mut self, id: &NodeId) -> Option<&mut Node> { - self.graph.get_node_mut(id) - } - pub fn remove_node(&mut self, id: &NodeId) { - self.graph.remove_node(id); - self.port_offset_cache.clear_node(id); - } - pub fn nodes(&self) -> &HashMap { - self.graph.nodes() - } - pub fn node_order(&self) -> &Vec { - self.graph.node_order() - } - - pub fn new_edge(&self) -> Edge { - self.graph.new_edge() - } - - pub fn add_edge(&mut self, edge: Edge) { - self.graph.add_edge(edge); - } - - pub fn remove_edge(&mut self, edge_id: &EdgeId) { - self.graph.remove_edge(edge_id); - } - - pub fn add_selected_node(&mut self, id: NodeId, shift: bool) { - self.graph.add_selected_node(id, shift); - } - pub fn clear_selected_node(&mut self) { - self.graph.clear_selected_node(); - } - pub fn remove_selected_node(&mut self) -> bool { - self.graph.remove_selected_node() - } - - pub fn add_selected_edge(&mut self, id: EdgeId, shift: bool) { - self.graph.add_selected_edge(id, shift); - } - pub fn clear_selected_edge(&mut self) { - self.graph.clear_selected_edge(); - } - pub fn remove_selected_edge(&mut self) -> bool { - self.graph.remove_selected_edge() - } - - pub fn selection_bounds(&self) -> Option> { - self.graph.selection_bounds() - } - - pub fn selected_nodes_with_positions(&self) -> HashMap> { - self.graph.selected_nodes_with_positions() - } - - pub fn hit_node(&self, mouse: Point) -> Option { - self.graph.hit_node(mouse, self.viewport) - } - - pub fn bring_node_to_front(&mut self, node_id: NodeId) { - self.graph.bring_node_to_front(node_id); - } - - // ---- Viewport shortcuts ---- - pub fn zoom(&self) -> f32 { - self.viewport.zoom() - } - - pub fn set_zoom(&mut self, zoom: f32) { - self.viewport.set_zoom(zoom); - } - - pub fn zoom_scaled_by(&self, factor: f32) -> f32 { - self.viewport.zoom_scaled_by(factor) - } - - pub fn offset(&self) -> Point { - self.viewport.offset() - } - - pub fn set_offset(&mut self, offset: Point) { - self.viewport.set_offset(offset); - } - - pub fn set_offset_xy(&mut self, x: Pixels, y: Pixels) { - self.viewport.set_offset_xy(x, y); - } - - pub fn translate_offset(&mut self, dx: Pixels, dy: Pixels) { - self.viewport.translate_offset(dx, dy); - } - - pub fn window_bounds(&self) -> Option> { - self.viewport.window_bounds() - } - - pub fn set_window_bounds(&mut self, bounds: Option>) { - self.viewport.set_window_bounds(bounds); - } - - pub fn world_scalar_to_screen(&self, value: f32) -> f32 { - self.viewport.world_scalar_to_screen(value) - } - - pub fn screen_scalar_to_world(&self, value: f32) -> f32 { - self.viewport.screen_scalar_to_world(value) - } - - pub fn world_length_to_screen(&self, value: Pixels) -> Pixels { - self.viewport.world_length_to_screen(value) - } - - pub fn screen_length_to_world(&self, value: Pixels) -> Pixels { - self.viewport.screen_length_to_world(value) - } - - pub fn world_to_screen(&self, p: Point) -> Point { - self.viewport.world_to_screen(p) - } - - pub fn screen_to_world(&self, p: Point) -> Point { - self.viewport.screen_to_world(p) - } - - pub fn edge_control_point( - &self, - source: Point, - position: PortPosition, - ) -> Point { - self.viewport.edge_control_point(source, position) - } - - pub fn is_node_visible(&self, node_id: &NodeId) -> bool { - is_node_visible(self.graph, self.viewport, node_id) - } - pub fn is_node_visible_node(&self, node: &Node) -> bool { - self.viewport.is_node_visible(node) - } - - pub fn is_edge_visible(&self, edge: &Edge) -> bool { - is_edge_visible(self.graph, self.viewport, edge) - } - - pub fn port_offset_cached(&self, node_id: &NodeId, port_id: &PortId) -> Option> { - self.port_offset_cache.get_offset(node_id, port_id) - } - - pub fn port_offset_cache_clear_all(&mut self) { - self.port_offset_cache.clear_all(); - } - - /// Port center in screen pixels when you already have the owning [`Node`]. - /// *warning*: this is using port offset cache, so it will not be accurate if the port offset is not cached. - pub fn port_screen_center(&self, node: &Node, port_id: PortId) -> Option> { - let node_pos = node.point(); - let offset = self.port_offset_cached(&node.id(), &port_id)?; - Some(self.viewport.world_to_screen(node_pos + offset)) - } - - /// Like [`Self::port_screen_center`], resolving the port from [`Graph::ports`]. - /// *warning*: this is using port offset cache, so it will not be accurate if the port offset is not cached. - pub fn port_screen_center_by_port_id(&self, port_id: PortId) -> Option> { - let port = self.graph.get_port(&port_id)?; - let node = self.get_node(&port.node_id())?; - self.port_screen_center(node, port_id) - } - - /// Full port layout for custom [`NodeRenderer::port_render`]. - /// *warning*: this is using port offset cache, so it will not be accurate if the port offset is not cached. - pub fn port_screen_frame(&self, node: &Node, port: &Port) -> Option { - Some(PortScreenFrame { - center: self.port_screen_center(node, port.id())?, - size: *port.size_ref(), - zoom: self.viewport.zoom(), - port_id: port.id(), - }) - } - - pub fn cache_all_node_port_offset(&mut self) { - self.port_offset_cache - .ensure_all_nodes_ports(self.graph, self.renderers); - } - - pub fn cache_port_offset_with_node(&mut self, node_ids: &Vec) { - for node_id in node_ids { - self.cache_node_port_offset(node_id); - } - } - - pub fn cache_port_offset_with_edge(&mut self, edge_id: &EdgeId) { - self.port_offset_cache - .ensure_edge_ports(self.graph, self.renderers, edge_id); - } - - pub fn cache_port_offset_with_port(&mut self, port_id: &PortId) { - self.port_offset_cache - .ensure_node_ports_for_port(self.graph, self.renderers, port_id); - } - - fn cache_node_port_offset(&mut self, node_id: &NodeId) { - self.port_offset_cache - .ensure_node_ports(self.graph, self.renderers, node_id); - } -} - -pub enum FlowEvent { - Input(InputEvent), - Custom(Box), -} - -impl FlowEvent { - pub fn custom(event: T) -> Self { - FlowEvent::Custom(Box::new(event)) - } - pub fn as_custom(&self) -> Option<&T> { - match self { - FlowEvent::Custom(e) => e.downcast_ref::(), - _ => None, - } - } -} - -pub enum InputEvent { - KeyDown(KeyDownEvent), - KeyUp(KeyUpEvent), - - MouseDown(MouseDownEvent), - MouseMove(MouseMoveEvent), - MouseUp(MouseUpEvent), - - Wheel(ScrollWheelEvent), - - Hover(bool), -} - -pub struct RenderContext<'a> { - pub graph: &'a Graph, - port_offset_cache: &'a mut PortLayoutCache, - viewport: &'a Viewport, - pub renderers: &'a RendererRegistry, - - pub window: &'a Window, - /// Active canvas theme (from [`FlowCanvas::theme`](crate::canvas::FlowCanvas::theme)). - pub theme: &'a FlowTheme, - /// Read-only shared plugin state on the [`FlowCanvas`](FlowCanvas). - shared_state: &'a SharedState, -} - -impl<'a> RenderContext<'a> { - pub(crate) fn new( - graph: &'a Graph, - port_offset_cache: &'a mut PortLayoutCache, - viewport: &'a Viewport, - renderers: &'a RendererRegistry, - window: &'a Window, - theme: &'a FlowTheme, - shared_state: &'a SharedState, - ) -> Self { - Self { - graph, - port_offset_cache, - viewport, - renderers, - window, - theme, - shared_state, - } - } - - /// Detached builder (no graph); use [`PluginContext::create_node`] or [`Graph::create_node`] to commit. - pub fn create_node(&self, renderer_key: &str) -> NodeBuilder<'_> { - NodeBuilder::new(renderer_key) - } - - pub fn next_node_id(&self) -> NodeId { - self.graph.next_node_id() - } - - pub fn next_port_id(&self) -> PortId { - self.graph.next_port_id() - } - - pub fn next_edge_id(&self) -> EdgeId { - self.graph.next_edge_id() - } - - pub fn get_node(&self, id: &NodeId) -> Option<&Node> { - self.graph.get_node(id) - } - - pub fn get_node_render(&self, id: &NodeId) -> Option<&dyn NodeRenderer> { - let node = self.get_node(id)?; - - Some(self.renderers.get(node.renderer_key())) - } - - pub fn nodes(&self) -> &HashMap { - self.graph.nodes() - } - pub fn node_order(&self) -> &Vec { - self.graph.node_order() - } - - pub fn new_edge(&self) -> Edge { - self.graph.new_edge() - } - - pub fn selection_bounds(&self) -> Option> { - self.graph.selection_bounds() - } - - pub fn selected_nodes_with_positions(&self) -> HashMap> { - self.graph.selected_nodes_with_positions() - } - - pub fn hit_node(&self, mouse: Point) -> Option { - self.graph.hit_node(mouse, self.viewport) - } - - // ---- Viewport shortcuts ---- - - pub fn viewport(&self) -> &Viewport { - self.viewport - } - - pub fn zoom(&self) -> f32 { - self.viewport.zoom() - } - - pub fn zoom_scaled_by(&self, factor: f32) -> f32 { - self.viewport.zoom_scaled_by(factor) - } - - pub fn offset(&self) -> Point { - self.viewport.offset() - } - - pub fn window_bounds(&self) -> Option> { - self.viewport.window_bounds() - } - - pub fn world_scalar_to_screen(&self, value: f32) -> f32 { - self.viewport.world_scalar_to_screen(value) - } - - pub fn screen_scalar_to_world(&self, value: f32) -> f32 { - self.viewport.screen_scalar_to_world(value) - } - - pub fn world_length_to_screen(&self, value: Pixels) -> Pixels { - self.viewport.world_length_to_screen(value) - } - - pub fn screen_length_to_world(&self, value: Pixels) -> Pixels { - self.viewport.screen_length_to_world(value) - } - - pub fn world_to_screen(&self, p: Point) -> Point { - self.viewport.world_to_screen(p) - } - - /// Absolute-positioned node card shell: screen origin, zoom-scaled size. - /// - /// Chain `.child(...)` for the inner body, then `.into_any()` (see [`gpui::Element`]). - pub fn node_card_shell( - &self, - node: &Node, - selected: bool, - variant: NodeCardVariant, - ) -> Stateful

{ - let screen = self.world_to_screen(node.point()); - let z = self.viewport.zoom(); - let base = div() - .id(ElementId::Uuid(*node.id().as_uuid())) - .absolute() - .left(screen.x) - .top(screen.y) - .w(node.size_ref().width * z) - .h(node.size_ref().height * z); - let t = self.theme; - match variant { - NodeCardVariant::Default => { - base.bg(rgb(t.node_card_background)) - .border_color(rgb(if selected { - t.node_card_border_selected - } else { - t.node_card_border - })) - } - NodeCardVariant::UndefinedType => base - .bg(rgb(t.undefined_node_background)) - .border_color(rgb(t.undefined_node_border)), - NodeCardVariant::Custom => base, - } - } - - pub fn screen_to_world(&self, p: Point) -> Point { - self.viewport.screen_to_world(p) - } - - pub fn edge_control_point( - &self, - source: Point, - position: PortPosition, - ) -> Point { - self.viewport.edge_control_point(source, position) - } - - pub fn is_node_visible(&self, node_id: &NodeId) -> bool { - is_node_visible(self.graph, self.viewport, node_id) - } - pub fn is_node_visible_node(&self, node: &Node) -> bool { - self.viewport.is_node_visible(node) - } - - pub fn is_edge_visible(&self, edge: &Edge) -> bool { - is_edge_visible(self.graph, self.viewport, edge) - } - - pub fn port_offset_cached(&self, node_id: &NodeId, port_id: &PortId) -> Option> { - self.port_offset_cache.get_offset(node_id, port_id) - } - - /// Port ids with layout cached for this node (see [`PortLayoutCache::cached_port_ids_for_node`]). - /// - /// Call [`Self::cache_port_offset_with_nodes`] (or other `cache_port_offset_*` helpers) first - /// so the list is complete for rendering. - pub fn cached_port_ids_for_node(&self, node_id: &NodeId) -> impl Iterator + '_ { - self.port_offset_cache.cached_port_ids_for_node(node_id) - } - - /// Port center in screen pixels when you already have the owning [`Node`]. - /// *warning*: this is using port offset cache, so it will not be accurate if the port offset is not cached. - pub fn port_screen_center(&self, node: &Node, port_id: PortId) -> Option> { - let node_pos = node.point(); - let offset = self.port_offset_cached(&node.id(), &port_id)?; - Some(self.viewport.world_to_screen(node_pos + offset)) - } - - /// Like [`Self::port_screen_center`], resolving the port from [`Graph::ports`]. - /// *warning*: this is using port offset cache, so it will not be accurate if the port offset is not cached. - pub fn port_screen_center_by_port_id(&self, port_id: PortId) -> Option> { - let port = self.graph.get_port(&port_id)?; - let node = self.get_node(&port.node_id())?; - self.port_screen_center(node, port_id) - } - - /// Full port layout for custom [`NodeRenderer::port_render`]. - /// *warning*: this is using port offset cache, so it will not be accurate if the port offset is not cached. - pub fn port_screen_frame(&self, node: &Node, port: &Port) -> Option { - Some(PortScreenFrame { - center: self.port_screen_center(node, port.id())?, - size: *port.size_ref(), - zoom: self.viewport.zoom(), - port_id: port.id(), - }) - } - - pub fn cache_all_node_port_offset(&mut self) { - self.port_offset_cache - .ensure_all_nodes_ports(self.graph, self.renderers); - } - - pub fn cache_port_offset_with_nodes(&mut self, node_ids: &[NodeId]) { - for node_id in node_ids { - self.cache_node_port_offset(node_id); - } - } - - pub fn cache_port_offset_with_edge(&mut self, edge_id: &EdgeId) { - self.port_offset_cache - .ensure_edge_ports(self.graph, self.renderers, edge_id); - } - - pub fn cache_port_offset_with_port(&mut self, port_id: &PortId) { - self.port_offset_cache - .ensure_node_ports_for_port(self.graph, self.renderers, port_id); - } - - fn cache_node_port_offset(&mut self, node_id: &NodeId) { - self.port_offset_cache - .ensure_node_ports(self.graph, self.renderers, node_id); - } - - pub fn get_shared_state(&self) -> Option<&T> { - self.shared_state.get::() - } - - pub fn contains_shared_state(&self) -> bool { - self.shared_state.contains::() - } -} - -#[derive(Debug, PartialEq, Eq, Clone, Copy)] -pub enum RenderLayer { - Background, - Edges, - Nodes, - Selection, - Interaction, - Overlay, -} - -impl RenderLayer { - pub const ALL: [RenderLayer; 6] = [ - RenderLayer::Background, - RenderLayer::Edges, - RenderLayer::Nodes, - RenderLayer::Selection, - RenderLayer::Interaction, - RenderLayer::Overlay, - ]; - pub fn index(self) -> usize { - match self { - RenderLayer::Background => 0, - RenderLayer::Edges => 1, - RenderLayer::Nodes => 2, - RenderLayer::Selection => 3, - RenderLayer::Interaction => 4, - RenderLayer::Overlay => 5, - } - } -} - -pub struct PluginRegistry { - plugins: Vec>, -} - -impl PluginRegistry { - pub(crate) fn new() -> Self { - Self { plugins: vec![] } - } - - pub fn add(mut self, plugin: impl Plugin + 'static) -> Self { - self.plugins.push(Box::new(plugin)); - self - } - - pub fn extend_boxed(&mut self, plugins: impl IntoIterator>) { - self.plugins.extend(plugins); - } - - pub fn sort_by_priority_desc(&mut self) { - self.plugins.sort_by_key(|p| -p.priority()); - } - - pub fn iter_mut(&mut self) -> std::slice::IterMut<'_, Box> { - self.plugins.iter_mut() - } - - pub fn iter(&self) -> std::slice::Iter<'_, Box> { - self.plugins.iter() - } -} diff --git a/crates/ferrum-flow/src/plugin/sync.rs b/crates/ferrum-flow/src/plugin/sync.rs deleted file mode 100644 index 4d455bbdd5..0000000000 --- a/crates/ferrum-flow/src/plugin/sync.rs +++ /dev/null @@ -1,88 +0,0 @@ -//! Collaboration / replication hooks for the canvas graph. -//! -//! A [`SyncPlugin`] sits **beside** the local [`crate::Graph`]: the canvas still applies -//! [`crate::GraphOp`] through commands and history, while the plugin mirrors those intents into a -//! shared model (CRDT, document store, network sync, etc.) and pushes updates back through -//! [`GraphChange`] so the UI stays consistent with peers and with undo/redo semantics. -//! -//! Implementations typically: -//! - In [`SyncPlugin::setup`], subscribe to the shared model and forward diffs on -//! [`UnboundedSender`], setting [`crate::ChangeSource`] (`Local`, `Remote`, `Undo`, -//! …) so the host can tell operator-driven edits from replay or remote merges. -//! - In [`SyncPlugin::process_intent`], apply each [`GraphOp`] produced locally (after a command -//! runs or history replays) into that model, using whatever metadata your stack needs to avoid -//! mis-classifying those writes when your subscription fires again. -//! - In [`SyncPlugin::undo`] / [`SyncPlugin::redo`], advance **your** backend undo manager if the -//! sync layer owns a stack separate from the canvas history. -//! -//! The concrete backend (Yjs, operational transform, file append, etc.) is up to the plugin; this -//! trait only defines the integration surface with the canvas. - -use futures::channel::mpsc::UnboundedSender; -use gpui::{AnyElement, Pixels, Point}; - -use crate::{FlowEvent, GraphChange, GraphOp, RenderContext, Viewport}; - -/// Bridges local graph edits to a replicated or external graph model, and streams model changes -/// back into the canvas. -/// -/// **Data flow (intended pattern)** -/// 1. User action → canvas runs a command → [`GraphOp`]s are applied to the local graph. -/// 2. The host forwards those ops to [`SyncPlugin::process_intent`] so the plugin updates its -/// shared state. -/// 3. Shared state emits updates (local echo, remote peer, or undo replay) → plugin sends -/// [`GraphChange`] on the channel passed to [`SyncPlugin::setup`]. -/// 4. Canvas applies those changes and refreshes; [`GraphChange::source`] distinguishes how each -/// change should be treated (e.g. skip re-broadcasting remote edits). -/// -/// Keep [`process_intent`](SyncPlugin::process_intent) idempotent with respect to your own -/// observers where possible: the same logical op may be reflected back through your subscription; -/// tagging “local intent” vs “remote” vs “undo” origins is the usual way to stay consistent. -pub trait SyncPlugin { - fn name(&self) -> &'static str; - - /// One-time wiring: subscribe to the shared model, retain subscriptions for the plugin - /// lifetime, and send [`GraphChange`] values on `change_sender` whenever the model moves. - /// - /// The host owns the receiver; do not block the UI thread on long-running I/O—spawn a task or - /// use non-blocking channels as appropriate. - fn setup(&mut self, change_sender: UnboundedSender); - - /// Apply a single local [`GraphOp`] (or a batch already decomposed by the host) into your - /// backend. This is invoked for operator-driven edits after they hit the local graph, not as - /// a replacement for the canvas command pipeline. - fn process_intent(&self, op: GraphOp); - - /// Step the sync-layer undo stack backward, if your backend maintains one in addition to (or - /// instead of) mirroring canvas history. - fn undo(&mut self); - /// Step the sync-layer undo stack forward. - fn redo(&mut self); - - /// Optional: handle canvas [`FlowEvent`]s for awareness, presence, or other non-[`GraphOp`] - /// signals. Use [`SyncPluginContext`] for coordinate transforms when needed. - fn on_event(&mut self, _event: &FlowEvent, _ctx: &mut SyncPluginContext); - - /// Optional overlay (e.g. remote pointers) drawn with normal canvas [`RenderContext`]. - fn render(&mut self, _ctx: &mut RenderContext) -> Vec { - vec![] - } -} - -pub struct SyncPluginContext<'a> { - viewport: &'a Viewport, -} - -impl<'a> SyncPluginContext<'a> { - pub(crate) fn new(viewport: &'a Viewport) -> Self { - Self { viewport } - } - - pub fn screen_to_world(&self, screen: Point) -> Point { - self.viewport.screen_to_world(screen) - } - - pub fn world_to_screen(&self, world: Point) -> Point { - self.viewport.world_to_screen(world) - } -} diff --git a/crates/ferrum-flow/src/plugin/utils.rs b/crates/ferrum-flow/src/plugin/utils.rs deleted file mode 100644 index 73116173b1..0000000000 --- a/crates/ferrum-flow/src/plugin/utils.rs +++ /dev/null @@ -1,85 +0,0 @@ -use gpui::KeyDownEvent; - -use crate::{Edge, Graph, GraphChangeKind, NodeId, Viewport, canvas::PortLayoutCache}; - -/// Clears [`PortLayoutCache`] entries affected by an incoming graph change. Call **before** -/// [`Graph::apply`](crate::graph::Graph::apply) so `PortRemoved` can still resolve `node_id`. -pub fn invalidate_port_layout_cache_for_graph_change( - cache: &mut PortLayoutCache, - graph: &Graph, - kind: &GraphChangeKind, -) { - match kind { - GraphChangeKind::NodeRemoved { id } => cache.clear_node(id), - GraphChangeKind::NodeAdded(node) => cache.clear_node(&node.id()), - GraphChangeKind::NodeSetWidthed { id, .. } - | GraphChangeKind::NodeSetHeighted { id, .. } - | GraphChangeKind::NodeDataUpdated { id, .. } => cache.clear_node(id), - GraphChangeKind::PortAdded(port) => cache.clear_node(&port.node_id()), - GraphChangeKind::PortRemoved { id } => { - if let Some(p) = graph.get_port(id) { - cache.clear_node(&p.node_id()); - } - } - GraphChangeKind::NodeMoved { .. } - | GraphChangeKind::NodeOrderUpdate(_) - | GraphChangeKind::EdgeAdded(_) - | GraphChangeKind::EdgeRemoved { .. } - | GraphChangeKind::RedrawRequested => {} - GraphChangeKind::Batch(changes) => { - for c in changes { - invalidate_port_layout_cache_for_graph_change(cache, graph, c); - } - } - } -} - -/// Primary shortcut modifier: ⌘ on macOS, Ctrl on other platforms. -pub fn primary_platform_modifier(ev: &KeyDownEvent) -> bool { - #[cfg(target_os = "macos")] - { - ev.keystroke.modifiers.platform - } - #[cfg(not(target_os = "macos"))] - { - ev.keystroke.modifiers.control - } -} - -pub fn is_node_visible(graph: &Graph, viewport: &Viewport, node_id: &NodeId) -> bool { - let Some(node) = graph.get_node(node_id) else { - return false; - }; - - viewport.is_node_visible(node) -} - -pub fn is_edge_visible(graph: &Graph, viewport: &Viewport, edge: &Edge) -> bool { - let Edge { - source_port, - target_port, - .. - } = edge; - - let Some(port) = graph.get_port(source_port) else { - return false; - }; - let n1 = port.node_id(); - - let Some(port) = graph.get_port(target_port) else { - return false; - }; - let n2 = port.node_id(); - - let node1_visible = graph - .get_node(&n1) - .map(|n| viewport.is_node_visible(n)) - .unwrap_or(false); - - let node2_visible = graph - .get_node(&n2) - .map(|n| viewport.is_node_visible(n)) - .unwrap_or(false); - - node1_visible || node2_visible -} diff --git a/crates/ferrum-flow/src/plugin_testing.rs b/crates/ferrum-flow/src/plugin_testing.rs deleted file mode 100644 index ae5161217f..0000000000 --- a/crates/ferrum-flow/src/plugin_testing.rs +++ /dev/null @@ -1,142 +0,0 @@ -//! Helpers for testing [`Plugin`](crate::Plugin) implementations. -//! -//! Enable the **`testing`** Cargo feature on `ferrum-flow` to use this module: -//! -//! ```toml -//! ferrum-flow = { version = "…", features = ["testing"] } -//! ``` -//! -//! This harness is intended for plugin unit/integration tests in downstream crates where -//! [`InitPluginContext`], [`PluginContext`] and [`RenderContext`] constructors are intentionally -//! not public. - -use gpui::{AnyElement, Context, Pixels, Size, Window, px}; -use std::time::Duration; - -use crate::{ - EventResult, FlowCanvas, FlowEvent, FlowTheme, Graph, LocalHistory, Plugin, PluginContext, - RenderContext, RendererRegistry, SharedState, SyncPlugin, Viewport, - canvas::{InteractionState, PortLayoutCache}, - plugin::InitPluginContext, -}; - -/// Test harness that can drive plugin `setup`, `on_event`, and `render` with realistic internal -/// contexts. -pub struct PluginTestHarness { - pub graph: Graph, - pub port_offset_cache: PortLayoutCache, - pub viewport: Viewport, - pub interaction: InteractionState, - pub renderers: RendererRegistry, - pub history: LocalHistory, - pub theme: FlowTheme, - pub shared_state: SharedState, - sync_plugin: Option>, - emitted_events: Vec, - notify_count: usize, -} - -impl PluginTestHarness { - pub fn new(graph: Graph) -> Self { - Self { - graph, - port_offset_cache: PortLayoutCache::new(), - viewport: Viewport::new(), - interaction: InteractionState::new(), - renderers: RendererRegistry::new(), - history: LocalHistory::new(), - theme: FlowTheme::default(), - shared_state: SharedState::new(), - sync_plugin: None, - emitted_events: Vec::new(), - notify_count: 0, - } - } - - /// Runs `Plugin::setup`. - /// - /// Call this only in tests that already have a GPUI context/window. - pub fn run_setup<'a, 'b>( - &mut self, - plugin: &mut dyn Plugin, - gpui_ctx: &'a Context<'b, FlowCanvas>, - drawable_size: Size, - ) { - let mut ctx = InitPluginContext::new( - &mut self.graph, - &mut self.port_offset_cache, - &mut self.viewport, - &mut self.renderers, - gpui_ctx, - drawable_size, - &mut self.theme, - &mut self.shared_state, - ); - plugin.setup(&mut ctx); - } - - /// Runs `Plugin::on_event` once and captures emitted events / notify calls. - pub fn run_event(&mut self, plugin: &mut dyn Plugin, event: FlowEvent) -> EventResult { - let emitted_events = &mut self.emitted_events; - let notify_count = &mut self.notify_count; - let mut emit = |e: FlowEvent| { - emitted_events.push(e); - }; - let mut notify = || { - *notify_count += 1; - }; - let mut schedule_after = |_delay: Duration| {}; - let mut ctx = PluginContext::new( - &mut self.graph, - &mut self.port_offset_cache, - &mut self.viewport, - &mut self.interaction, - &mut self.renderers, - &mut self.sync_plugin, - &mut self.history, - &mut self.theme, - &mut self.shared_state, - &mut emit, - &mut notify, - &mut schedule_after, - ); - plugin.on_event(&event, &mut ctx) - } - - /// Runs `Plugin::render` once. - /// - /// Call this only in tests that already have a GPUI window. - pub fn run_render(&mut self, plugin: &mut dyn Plugin, window: &Window) -> Option { - let mut ctx = RenderContext::new( - &mut self.graph, - &mut self.port_offset_cache, - &self.viewport, - &self.renderers, - window, - &self.theme, - &self.shared_state, - ); - plugin.render(&mut ctx) - } - - /// Returns number of times `ctx.notify()` was called during `run_event`. - pub fn notify_count(&self) -> usize { - self.notify_count - } - - /// Drains and returns custom/input events emitted via `ctx.emit(...)`. - pub fn drain_emitted_events(&mut self) -> Vec { - std::mem::take(&mut self.emitted_events) - } -} - -impl Default for PluginTestHarness { - fn default() -> Self { - let mut harness = Self::new(Graph::new()); - harness.viewport.set_window_bounds(Some(gpui::Bounds::new( - gpui::Point::new(px(0.0), px(0.0)), - gpui::Size::new(px(800.0), px(600.0)), - ))); - harness - } -} diff --git a/crates/ferrum-flow/src/plugins/align.rs b/crates/ferrum-flow/src/plugins/align.rs deleted file mode 100644 index 699ce0c38f..0000000000 --- a/crates/ferrum-flow/src/plugins/align.rs +++ /dev/null @@ -1,166 +0,0 @@ -use gpui::{Pixels, Point, px}; - -use crate::{ - NodeId, - plugin::{FlowEvent, Plugin, PluginContext, primary_platform_modifier}, - plugins::node::DragNodesCommand, -}; - -/// Align selected nodes to their shared bounding box (⌘⇧L/R/T/B/H/V or Ctrl⇧…). -pub struct AlignPlugin; - -#[derive(Clone, Copy)] -enum AlignKind { - Left, - Right, - Top, - Bottom, - CenterH, - CenterV, -} - -type NodePositions = Vec<(NodeId, Point)>; -type AlignFromTo = (NodePositions, NodePositions); - -impl AlignPlugin { - pub fn new() -> Self { - Self - } -} - -impl Default for AlignPlugin { - fn default() -> Self { - Self::new() - } -} - -fn align_shortcut(ev: &gpui::KeyDownEvent) -> bool { - primary_platform_modifier(ev) && ev.keystroke.modifiers.shift -} - -fn px_to_f32(p: Pixels) -> f32 { - p.into() -} - -fn f32_neq(a: f32, b: f32) -> bool { - (a - b).abs() > 0.01 -} - -fn selected_nodes_ordered(ctx: &PluginContext) -> Vec { - ctx.graph - .node_order() - .iter() - .filter(|id| ctx.graph.selected_node().contains(id)) - .copied() - .collect() -} - -fn build_aligned_positions(ctx: &PluginContext, kind: AlignKind) -> Option { - let ids = selected_nodes_ordered(ctx); - if ids.len() < 2 { - return None; - } - - let mut min_left = f32::INFINITY; - let mut max_right = f32::NEG_INFINITY; - let mut min_top = f32::INFINITY; - let mut max_bottom = f32::NEG_INFINITY; - - for id in &ids { - let n = ctx.get_node(id)?; - let (nx, ny) = n.position(); - let size = *n.size_ref(); - let x = px_to_f32(nx); - let y = px_to_f32(ny); - let w = px_to_f32(size.width); - let h = px_to_f32(size.height); - min_left = min_left.min(x); - max_right = max_right.max(x + w); - min_top = min_top.min(y); - max_bottom = max_bottom.max(y + h); - } - - let center_x = (min_left + max_right) / 2.0; - let center_y = (min_top + max_bottom) / 2.0; - - let mut from = Vec::with_capacity(ids.len()); - let mut to = Vec::with_capacity(ids.len()); - - for id in ids { - let n = ctx.get_node(&id)?; - let p = n.point(); - from.push((id, p)); - - let (nx, ny) = n.position(); - let size = *n.size_ref(); - let x = px_to_f32(nx); - let y = px_to_f32(ny); - let w = px_to_f32(size.width); - let h = px_to_f32(size.height); - - let (nx, ny) = match kind { - AlignKind::Left => (min_left, y), - AlignKind::Right => (max_right - w, y), - AlignKind::Top => (x, min_top), - AlignKind::Bottom => (x, max_bottom - h), - AlignKind::CenterH => (center_x - w / 2.0, y), - AlignKind::CenterV => (x, center_y - h / 2.0), - }; - to.push((id, Point::new(px(nx), px(ny)))); - } - - let changed = from.iter().zip(to.iter()).any(|((_, pf), (_, pt))| { - f32_neq(px_to_f32(pf.x), px_to_f32(pt.x)) || f32_neq(px_to_f32(pf.y), px_to_f32(pt.y)) - }); - if !changed { - return None; - } - - Some((from, to)) -} - -fn apply_align(ctx: &mut PluginContext, kind: AlignKind) { - let Some((from, to)) = build_aligned_positions(ctx, kind) else { - return; - }; - ctx.execute_command(DragNodesCommand::from_positions(from, to)); - ctx.cache_all_node_port_offset(); -} - -impl Plugin for AlignPlugin { - fn name(&self) -> &'static str { - "align" - } - - fn setup(&mut self, _ctx: &mut crate::plugin::InitPluginContext) {} - - fn priority(&self) -> i32 { - 91 - } - - fn on_event( - &mut self, - event: &FlowEvent, - ctx: &mut PluginContext, - ) -> crate::plugin::EventResult { - if let FlowEvent::Input(crate::plugin::InputEvent::KeyDown(ev)) = event { - if !align_shortcut(ev) { - return crate::plugin::EventResult::Continue; - } - let kind = match ev.keystroke.key.as_str() { - "l" => Some(AlignKind::Left), - "r" => Some(AlignKind::Right), - "t" => Some(AlignKind::Top), - "b" => Some(AlignKind::Bottom), - "h" => Some(AlignKind::CenterH), - "v" => Some(AlignKind::CenterV), - _ => None, - }; - if let Some(kind) = kind { - apply_align(ctx, kind); - return crate::plugin::EventResult::Stop; - } - } - crate::plugin::EventResult::Continue - } -} diff --git a/crates/ferrum-flow/src/plugins/background.rs b/crates/ferrum-flow/src/plugins/background.rs deleted file mode 100644 index 8ae2d002d3..0000000000 --- a/crates/ferrum-flow/src/plugins/background.rs +++ /dev/null @@ -1,206 +0,0 @@ -use crate::plugin::Plugin; -use gpui::{ - Bounds, Corners, Element as _, InteractiveElement as _, ParentElement, RenderImage, Size, - Styled, canvas, div, px, -}; -use image::{Frame, RgbaImage}; -use smallvec::smallvec; -use std::sync::Arc; - -const BASE_GRID: f32 = 40.0; - -#[derive(Clone, Copy, PartialEq)] -struct BitmapKey { - offset_x_mod: i32, // (offset_x % grid * 1000) as i32 - offset_y_mod: i32, - grid_i: i32, // (grid * 1000) as i32 - width: u32, - height: u32, - bg_color: u32, - dot_color: u32, -} - -fn generate_fullscreen_bitmap( - width: u32, - height: u32, - grid: f32, - start_x: f32, - start_y: f32, - bg_color: u32, - dot_color: u32, -) -> Arc { - let w = width as usize; - let h = height as usize; - - let bg = [ - ((bg_color >> 16) & 0xFF) as u8, - ((bg_color >> 8) & 0xFF) as u8, - (bg_color & 0xFF) as u8, - 255u8, - ]; - let dot = [ - ((dot_color >> 16) & 0xFF) as u8, - ((dot_color >> 8) & 0xFF) as u8, - (dot_color & 0xFF) as u8, - 255u8, - ]; - - let mut data = vec![0u8; w * h * 4]; - for i in 0..w * h { - let p = i * 4; - data[p] = bg[0]; - data[p + 1] = bg[1]; - data[p + 2] = bg[2]; - data[p + 3] = 255; - } - - let mut x = start_x; - while x < width as f32 { - let mut y = start_y; - while y < height as f32 { - for dy in 0..2i32 { - for dx in 0..2i32 { - let px = (x - 1.0 + dx as f32).floor() as isize; - let py = (y - 1.0 + dy as f32).floor() as isize; - if px >= 0 && py >= 0 && (px as usize) < w && (py as usize) < h { - let i = ((py as usize) * w + (px as usize)) * 4; - data[i] = dot[0]; - data[i + 1] = dot[1]; - data[i + 2] = dot[2]; - data[i + 3] = 255; - } - } - } - y += grid; - } - x += grid; - } - - for chunk in data.chunks_exact_mut(4) { - chunk.swap(0, 2); - } - - let img = RgbaImage::from_raw(width, height, data).unwrap(); - Arc::new(RenderImage::new(smallvec![Frame::new(img)])) -} - -pub struct BackgroundPlugin { - bitmap_key: Option, - bitmap: Option>, -} - -impl Default for BackgroundPlugin { - fn default() -> Self { - Self::new() - } -} - -impl BackgroundPlugin { - pub fn new() -> Self { - Self { - bitmap_key: None, - bitmap: None, - } - } - - fn sync_bitmap(&mut self, ctx: &crate::plugin::RenderContext) { - let zoom = ctx.zoom(); - let grid = BASE_GRID * zoom; - let offset = ctx.offset(); - let offset_x = f32::from(offset.x); - let offset_y = f32::from(offset.y); - let bounds = ctx.window.bounds(); - let width = f32::from(bounds.size.width) as u32; - let height = f32::from(bounds.size.height) as u32; - - if grid <= 0.0 || width == 0 || height == 0 { - return; - } - - let ox_mod = offset_x % grid; - let oy_mod = offset_y % grid; - - let key = BitmapKey { - offset_x_mod: (ox_mod * 1000.0) as i32, - offset_y_mod: (oy_mod * 1000.0) as i32, - grid_i: (grid * 1000.0) as i32, - width, - height, - bg_color: ctx.theme.background, - dot_color: ctx.theme.background_grid_dot, - }; - - if self.bitmap_key == Some(key) { - return; - } - - self.bitmap_key = Some(key); - self.bitmap = Some(generate_fullscreen_bitmap( - width, - height, - grid, - ox_mod, - oy_mod, - ctx.theme.background, - ctx.theme.background_grid_dot, - )); - } -} - -impl Plugin for BackgroundPlugin { - fn name(&self) -> &'static str { - "background" - } - fn priority(&self) -> i32 { - 0 - } - fn render_layer(&self) -> crate::plugin::RenderLayer { - crate::plugin::RenderLayer::Background - } - - fn render(&mut self, ctx: &mut crate::plugin::RenderContext) -> Option { - self.sync_bitmap(ctx); - - let Some(bitmap) = self.bitmap.as_ref().map(Arc::clone) else { - return Some( - div() - .id("background") - .absolute() - .size_full() - .bg(gpui::rgb(ctx.theme.background)) - .into_any(), - ); - }; - - let bounds = ctx.window.bounds(); - let width = f32::from(bounds.size.width); - let height = f32::from(bounds.size.height); - - let el = canvas( - move |_, _, _| bitmap, - move |bounds, bitmap, window, _cx| { - let _ = window.paint_image( - Bounds { - origin: bounds.origin, - size: Size::new(px(width), px(height)), - }, - Corners::default(), - Arc::clone(&bitmap), - 0, - false, - ); - }, - ) - .absolute() - .size_full(); - - Some( - div() - .id("background") - .absolute() - .size_full() - .child(el) - .into_any(), - ) - } -} diff --git a/crates/ferrum-flow/src/plugins/clipboard/clipboard_ops.rs b/crates/ferrum-flow/src/plugins/clipboard/clipboard_ops.rs deleted file mode 100644 index 6437aa857e..0000000000 --- a/crates/ferrum-flow/src/plugins/clipboard/clipboard_ops.rs +++ /dev/null @@ -1,172 +0,0 @@ -use std::collections::{HashMap, HashSet}; - -use gpui::{Pixels, Point, px}; - -use crate::{CompositeCommand, Edge, Graph, Node, Port, plugin::PluginContext}; - -use super::copied_subgraph::CopiedSubgraph; -use crate::plugins::{CreateEdge, CreateNode, CreatePort}; - -#[derive(Clone)] -pub(crate) struct ClipboardShared(pub CopiedSubgraph); - -pub(crate) fn set_clipboard_subgraph(ctx: &mut PluginContext, sub: CopiedSubgraph) { - ctx.shared_state.insert(ClipboardShared(sub)); -} - -pub(crate) fn get_clipboard_subgraph(ctx: &PluginContext) -> Option { - ctx.shared_state - .get::() - .map(|s| s.0.clone()) -} - -pub(crate) fn has_clipboard_subgraph(ctx: &PluginContext) -> bool { - ctx.shared_state.contains::() -} - -pub(crate) fn extract_subgraph(graph: &Graph) -> Option { - if graph.selected_node_is_empty() { - return None; - } - let node_ids = graph.selected_node(); - if node_ids.is_empty() { - return None; - } - - let mut port_ids = HashSet::new(); - let mut nodes = Vec::with_capacity(node_ids.len()); - let mut ports = Vec::new(); - for nid in node_ids { - let n = graph.get_node(nid)?; - for pid in n.inputs().iter().chain(n.outputs().iter()) { - port_ids.insert(*pid); - if let Some(p) = graph.get_port(pid) { - ports.push(p.clone()); - } - } - nodes.push(n.clone()); - } - - let edges = graph - .edges_values() - .filter(|e| port_ids.contains(&e.source_port) && port_ids.contains(&e.target_port)) - .cloned() - .collect(); - - Some(CopiedSubgraph { - nodes, - ports, - edges, - }) -} - -/// Top-left of the axis-aligned bounding box of copied node positions (world space). -fn subgraph_bounds_top_left(sub: &CopiedSubgraph) -> Point { - let (mut min_x, mut min_y) = (f32::INFINITY, f32::INFINITY); - for n in &sub.nodes { - let (x, y) = n.position(); - min_x = min_x.min(x.into()); - min_y = min_y.min(y.into()); - } - Point::new(px(min_x), px(min_y)) -} - -/// Paste with the subgraph's bounding-box top-left placed at `anchor_world`. -pub(crate) fn paste_subgraph_at_world( - ctx: &mut PluginContext, - sub: &CopiedSubgraph, - anchor_world: Point, -) { - paste_subgraph_with_anchor(ctx, sub, anchor_world); -} - -/// Paste offset from the copied layout (keyboard paste): bbox top-left moves by (40, 40) in world space. -pub(crate) fn paste_subgraph(ctx: &mut PluginContext, sub: &CopiedSubgraph) { - const NUDGE: f32 = 40.0; - let origin = subgraph_bounds_top_left(sub); - let anchor = Point::new(origin.x + px(NUDGE), origin.y + px(NUDGE)); - paste_subgraph_with_anchor(ctx, sub, anchor); -} - -fn paste_subgraph_with_anchor( - ctx: &mut PluginContext, - sub: &CopiedSubgraph, - anchor_world: Point, -) { - if sub.nodes.is_empty() { - return; - } - - let origin = subgraph_bounds_top_left(sub); - let ox: f32 = origin.x.into(); - let oy: f32 = origin.y.into(); - let ax: f32 = anchor_world.x.into(); - let ay: f32 = anchor_world.y.into(); - - let mut node_map = HashMap::new(); - for n in &sub.nodes { - node_map.insert(n.id(), ctx.graph.next_node_id()); - } - let mut port_map = HashMap::new(); - for p in &sub.ports { - port_map.insert(p.id(), ctx.graph.next_port_id()); - } - - let mut composite = CompositeCommand::new(); - - let mut new_node_ids = Vec::new(); - - for old in &sub.nodes { - let new_id = node_map[&old.id()]; - let (x, y) = old.position(); - let nx = ax + f32::from(x) - ox; - let ny = ay + f32::from(y) - oy; - let mut node = Node::new(nx, ny); - node.set_renderer_key(old.renderer_key()); - node.set_execute_type(old.execute_type_ref()); - node.set_size_mut(*old.size_ref()); - node.set_data(old.data_ref().clone()); - node.set_id(new_id); - - for pid in old.inputs() { - node.push_input(port_map[pid]); - } - for pid in old.outputs() { - node.push_output(port_map[pid]); - } - new_node_ids.push(new_id); - composite.push(CreateNode::new(node)); - } - - for old in &sub.ports { - let port = Port::new( - port_map[&old.id()], - old.kind(), - old.index(), - node_map[&old.node_id()], - old.position(), - *old.size_ref(), - old.port_type_ref().clone(), - ); - composite.push(CreatePort::new(port)); - } - - for old in &sub.edges { - let edge = Edge { - id: ctx.graph.next_edge_id(), - source_port: port_map[&old.source_port], - target_port: port_map[&old.target_port], - }; - composite.push(CreateEdge::new(edge)); - } - - let pasted_ids = sub.nodes.iter().map(|n| node_map[&n.id()]); - - ctx.execute_command(composite); - ctx.clear_selected_edge(); - ctx.clear_selected_node(); - for nid in pasted_ids { - ctx.add_selected_node(nid, true); - } - ctx.cache_port_offset_with_node(&new_node_ids); -} diff --git a/crates/ferrum-flow/src/plugins/clipboard/copied_subgraph.rs b/crates/ferrum-flow/src/plugins/clipboard/copied_subgraph.rs deleted file mode 100644 index f0deae69a2..0000000000 --- a/crates/ferrum-flow/src/plugins/clipboard/copied_subgraph.rs +++ /dev/null @@ -1,8 +0,0 @@ -use crate::{Edge, Node, Port}; - -#[derive(Clone)] -pub struct CopiedSubgraph { - pub(crate) nodes: Vec, - pub(crate) ports: Vec, - pub(crate) edges: Vec, -} diff --git a/crates/ferrum-flow/src/plugins/clipboard/mod.rs b/crates/ferrum-flow/src/plugins/clipboard/mod.rs deleted file mode 100644 index a0e9c75c2e..0000000000 --- a/crates/ferrum-flow/src/plugins/clipboard/mod.rs +++ /dev/null @@ -1,10 +0,0 @@ -mod clipboard_ops; -mod copied_subgraph; -mod plugin; - -pub use plugin::ClipboardPlugin; - -pub(crate) use clipboard_ops::{ - extract_subgraph, get_clipboard_subgraph, has_clipboard_subgraph, paste_subgraph_at_world, - set_clipboard_subgraph, -}; diff --git a/crates/ferrum-flow/src/plugins/clipboard/plugin.rs b/crates/ferrum-flow/src/plugins/clipboard/plugin.rs deleted file mode 100644 index 8deb1eb5f9..0000000000 --- a/crates/ferrum-flow/src/plugins/clipboard/plugin.rs +++ /dev/null @@ -1,58 +0,0 @@ -use crate::plugin::{FlowEvent, Plugin, PluginContext, primary_platform_modifier}; - -use super::clipboard_ops::{ - extract_subgraph, get_clipboard_subgraph, paste_subgraph, set_clipboard_subgraph, -}; - -/// Copy / paste selected nodes, their ports, and edges **between** those ports (one undo on paste). -pub struct ClipboardPlugin; - -impl ClipboardPlugin { - pub fn new() -> Self { - Self - } -} - -impl Default for ClipboardPlugin { - fn default() -> Self { - Self::new() - } -} - -impl Plugin for ClipboardPlugin { - fn name(&self) -> &'static str { - "clipboard" - } - - fn priority(&self) -> i32 { - 95 - } - - fn on_event( - &mut self, - event: &FlowEvent, - ctx: &mut PluginContext, - ) -> crate::plugin::EventResult { - if let FlowEvent::Input(crate::plugin::InputEvent::KeyDown(ev)) = event { - if !primary_platform_modifier(ev) { - return crate::plugin::EventResult::Continue; - } - match ev.keystroke.key.as_str() { - "c" => { - if let Some(sub) = extract_subgraph(ctx.graph) { - set_clipboard_subgraph(ctx, sub); - } - return crate::plugin::EventResult::Stop; - } - "v" => { - if let Some(sub) = get_clipboard_subgraph(ctx) { - paste_subgraph(ctx, &sub); - } - return crate::plugin::EventResult::Stop; - } - _ => {} - } - } - crate::plugin::EventResult::Continue - } -} diff --git a/crates/ferrum-flow/src/plugins/context_menu.rs b/crates/ferrum-flow/src/plugins/context_menu.rs deleted file mode 100644 index bc656632f8..0000000000 --- a/crates/ferrum-flow/src/plugins/context_menu.rs +++ /dev/null @@ -1,455 +0,0 @@ -use std::sync::Arc; - -use gpui::{ - IntoElement as _, MouseButton, ParentElement as _, Pixels, Point, SharedString, Styled as _, - div, px, rgb, -}; - -use crate::{ - NodeId, - plugin::{ - EventResult, FlowEvent, InputEvent, Plugin, PluginContext, RenderContext, RenderLayer, - }, -}; - -use super::{ - clipboard::{ - extract_subgraph, get_clipboard_subgraph, has_clipboard_subgraph, paste_subgraph_at_world, - set_clipboard_subgraph, - }, - delete::delete_selection, - fit_all::fit_entire_graph, - focus_selection::focus_viewport_on_selection, - select_all_viewport::select_all_in_viewport, -}; - -const MENU_W: f32 = 228.0; -const ROW_H: f32 = 26.0; -const SEP_H: f32 = 9.0; -const MENU_PAD: f32 = 4.0; - -/// Callback invoked when the user picks a custom canvas menu row (e.g. open an input dialog in the app). -/// -/// The second argument is the **world-space** point under the initial right-click that opened this menu -/// (same as [`PluginContext::screen_to_world`] applied to that click). -type ContextMenuActionFn = dyn for<'a> Fn(&mut PluginContext<'a>, Point) + Send + Sync; - -#[derive(Clone)] -pub struct ContextMenuCustomAction(Arc); - -impl ContextMenuCustomAction { - pub fn new( - f: impl for<'a> Fn(&mut PluginContext<'a>, Point) + Send + Sync + 'static, - ) -> Self { - Self(Arc::new(f)) - } - - fn call(&self, ctx: &mut PluginContext<'_>, menu_world: Point) { - (self.0)(ctx, menu_world); - } -} - -/// One extra row on the **canvas background** context menu (after built-in items). -#[derive(Clone)] -pub struct ContextMenuCanvasExtra { - pub label: SharedString, - pub shortcut: Option, - pub on_select: ContextMenuCustomAction, -} - -impl ContextMenuCanvasExtra { - pub fn new( - label: impl Into, - on_select: impl for<'a> Fn(&mut PluginContext<'a>, Point) + Send + Sync + 'static, - ) -> Self { - Self { - label: label.into(), - shortcut: None, - on_select: ContextMenuCustomAction::new(on_select), - } - } - - pub fn with_shortcut( - label: impl Into, - shortcut: impl Into, - on_select: impl for<'a> Fn(&mut PluginContext<'a>, Point) + Send + Sync + 'static, - ) -> Self { - Self { - label: label.into(), - shortcut: Some(shortcut.into()), - on_select: ContextMenuCustomAction::new(on_select), - } - } -} - -/// Right-click menu on the canvas (empty area) or on a node. Optional [`ContextMenuCanvasExtra`] rows -/// are appended after built-in canvas actions. -pub struct ContextMenuPlugin { - open: Option, - canvas_extras: Vec, -} - -#[derive(Clone, Copy)] -enum MenuBuiltin { - FitAllGraph, - Paste, - SelectAllViewport, - FocusSelection, - Copy, - Delete, - BringToFront(NodeId), -} - -#[derive(Clone)] -enum MenuItem { - Separator, - Builtin(MenuBuiltin), - Custom { - label: SharedString, - shortcut: Option, - action: ContextMenuCustomAction, - }, -} - -#[derive(Clone)] -struct OpenMenu { - anchor: Point, - /// World position of the right-click that opened this menu. - anchor_world: Point, - actions: Vec, -} - -impl Default for ContextMenuPlugin { - fn default() -> Self { - Self::new() - } -} - -impl ContextMenuPlugin { - pub fn new() -> Self { - Self { - open: None, - canvas_extras: Vec::new(), - } - } - - pub fn with_canvas_extras(canvas_extras: Vec) -> Self { - Self { - open: None, - canvas_extras, - } - } - - /// Append a canvas-background row with a custom label (e.g. “Add node…” → show input in meili). - pub fn canvas_row( - mut self, - label: impl Into, - on_select: impl for<'a> Fn(&mut PluginContext<'a>, Point) + Send + Sync + 'static, - ) -> Self { - self.canvas_extras - .push(ContextMenuCanvasExtra::new(label, on_select)); - self - } - - /// Same as [`Self::canvas_row`] but with a shortcut hint string shown on the right. - pub fn canvas_row_with_shortcut( - mut self, - label: impl Into, - shortcut: impl Into, - on_select: impl for<'a> Fn(&mut PluginContext<'a>, Point) + Send + Sync + 'static, - ) -> Self { - self.canvas_extras - .push(ContextMenuCanvasExtra::with_shortcut( - label, shortcut, on_select, - )); - self - } - - fn row_height(action: &MenuItem) -> f32 { - match action { - MenuItem::Separator => SEP_H, - _ => ROW_H, - } - } - - fn content_height(actions: &[MenuItem]) -> f32 { - actions.iter().map(Self::row_height).sum() - } - - fn menu_bounds(anchor: Point, actions: &[MenuItem]) -> gpui::Bounds { - let h = Self::content_height(actions) + MENU_PAD * 2.0; - gpui::Bounds::new(anchor, gpui::Size::new(px(MENU_W), px(h))) - } - - fn label_builtin(b: MenuBuiltin) -> &'static str { - match b { - MenuBuiltin::FitAllGraph => "Fit entire graph", - MenuBuiltin::Paste => "Paste", - MenuBuiltin::SelectAllViewport => "Select all in view", - MenuBuiltin::FocusSelection => "Focus selection", - MenuBuiltin::Copy => "Copy", - MenuBuiltin::Delete => "Delete", - MenuBuiltin::BringToFront(_) => "Bring to front", - } - } - - fn shortcut_hint_builtin(b: MenuBuiltin) -> Option<&'static str> { - #[cfg(target_os = "macos")] - { - match b { - MenuBuiltin::FitAllGraph => Some("⌘0"), - MenuBuiltin::Paste => Some("⌘V"), - MenuBuiltin::SelectAllViewport => Some("⌘A"), - MenuBuiltin::FocusSelection => Some("⌘⇧F"), - MenuBuiltin::Copy => Some("⌘C"), - MenuBuiltin::Delete => Some("⌫"), - MenuBuiltin::BringToFront(_) => None, - } - } - #[cfg(not(target_os = "macos"))] - { - match b { - MenuBuiltin::FitAllGraph => Some("Ctrl+0"), - MenuBuiltin::Paste => Some("Ctrl+V"), - MenuBuiltin::SelectAllViewport => Some("Ctrl+A"), - MenuBuiltin::FocusSelection => Some("Ctrl+Shift+F"), - MenuBuiltin::Copy => Some("Ctrl+C"), - MenuBuiltin::Delete => Some("Del"), - MenuBuiltin::BringToFront(_) => None, - } - } - } - - fn canvas_actions(&self, ctx: &PluginContext) -> Vec { - let mut v = Vec::new(); - v.push(MenuItem::Builtin(MenuBuiltin::FitAllGraph)); - v.push(MenuItem::Separator); - if has_clipboard_subgraph(ctx) { - v.push(MenuItem::Builtin(MenuBuiltin::Paste)); - v.push(MenuItem::Separator); - } - v.push(MenuItem::Builtin(MenuBuiltin::SelectAllViewport)); - v.push(MenuItem::Separator); - v.push(MenuItem::Builtin(MenuBuiltin::FocusSelection)); - for e in &self.canvas_extras { - v.push(MenuItem::Separator); - v.push(MenuItem::Custom { - label: e.label.clone(), - shortcut: e.shortcut.clone(), - action: e.on_select.clone(), - }); - } - v - } - - fn node_actions(nid: NodeId) -> Vec { - vec![ - MenuItem::Builtin(MenuBuiltin::Copy), - MenuItem::Separator, - MenuItem::Builtin(MenuBuiltin::Delete), - MenuItem::Builtin(MenuBuiltin::BringToFront(nid)), - MenuItem::Separator, - MenuItem::Builtin(MenuBuiltin::FocusSelection), - ] - } - - fn run_action(ctx: &mut PluginContext, action: &MenuItem, menu_world: Point) { - match action { - MenuItem::Separator => {} - MenuItem::Builtin(b) => match b { - MenuBuiltin::FitAllGraph => fit_entire_graph(ctx), - MenuBuiltin::Paste => { - if let Some(sub) = get_clipboard_subgraph(ctx) { - paste_subgraph_at_world(ctx, &sub, menu_world); - } - } - MenuBuiltin::SelectAllViewport => select_all_in_viewport(ctx), - MenuBuiltin::FocusSelection => focus_viewport_on_selection(ctx), - MenuBuiltin::Copy => { - if let Some(s) = extract_subgraph(ctx.graph) { - set_clipboard_subgraph(ctx, s); - } - } - MenuBuiltin::Delete => delete_selection(ctx), - MenuBuiltin::BringToFront(id) => ctx.bring_node_to_front(*id), - }, - MenuItem::Custom { action, .. } => action.call(ctx, menu_world), - } - ctx.notify(); - } - - fn row_at_dy(actions: &[MenuItem], dy: f32) -> Option { - if dy < 0.0 { - return None; - } - let mut y = 0.0; - for (i, a) in actions.iter().enumerate() { - let h = Self::row_height(a); - if dy < y + h { - return Some(i); - } - y += h; - } - None - } -} - -impl Plugin for ContextMenuPlugin { - fn name(&self) -> &'static str { - "context_menu" - } - - fn priority(&self) -> i32 { - 132 - } - - fn render_layer(&self) -> RenderLayer { - RenderLayer::Overlay - } - - fn render(&mut self, ctx: &mut RenderContext) -> Option { - let open = self.open.as_ref()?; - let panel_bg = ctx.theme.context_menu_background; - let panel_border = ctx.theme.context_menu_border; - let row_text = ctx.theme.context_menu_text; - let shortcut_text = ctx.theme.context_menu_shortcut_text; - let separator = ctx.theme.context_menu_separator; - - let rows = open.actions.iter().map(|a| match a { - MenuItem::Separator => div() - .w_full() - .h(px(SEP_H)) - .flex() - .items_center() - .px_2() - .child(div().w_full().h(px(1.0)).bg(rgb(separator))), - MenuItem::Builtin(b) => { - let label = div() - .flex_1() - .min_w(px(0.)) - .overflow_hidden() - .text_ellipsis() - .child(ContextMenuPlugin::label_builtin(*b)); - let shortcut = ContextMenuPlugin::shortcut_hint_builtin(*b).map(|h| { - div() - .flex_shrink_0() - .ml_2() - .text_xs() - .text_color(rgb(shortcut_text)) - .child(h) - }); - div() - .w_full() - .h(px(ROW_H)) - .flex() - .flex_row() - .items_center() - .px_2() - .text_sm() - .text_color(rgb(row_text)) - .child(label) - .children(shortcut) - } - MenuItem::Custom { - label, shortcut, .. - } => { - let label_el = div() - .flex_1() - .min_w(px(0.)) - .overflow_hidden() - .text_ellipsis() - .child(label.clone()); - let shortcut_el = shortcut.as_ref().map(|h| { - div() - .flex_shrink_0() - .ml_2() - .text_xs() - .text_color(rgb(shortcut_text)) - .child(h.clone()) - }); - div() - .w_full() - .h(px(ROW_H)) - .flex() - .flex_row() - .items_center() - .px_2() - .text_sm() - .text_color(rgb(row_text)) - .child(label_el) - .children(shortcut_el) - } - }); - - Some( - div() - .absolute() - .left(open.anchor.x) - .top(open.anchor.y) - .w(px(MENU_W)) - .p_1() - .bg(rgb(panel_bg)) - .border_1() - .border_color(rgb(panel_border)) - .rounded(px(6.0)) - .shadow_sm() - .children(rows) - .into_any_element(), - ) - } - - fn on_event( - &mut self, - event: &FlowEvent, - ctx: &mut PluginContext, - ) -> crate::plugin::EventResult { - if let FlowEvent::Input(InputEvent::MouseDown(ev)) = event { - if ev.button == MouseButton::Left { - if let Some(open) = self.open.take() { - let menu_world = open.anchor_world; - let b = Self::menu_bounds(open.anchor, &open.actions); - if b.contains(&ev.position) { - let dy: f32 = (ev.position.y - open.anchor.y).into(); - let inner_y = dy - MENU_PAD; - if let Some(row) = Self::row_at_dy(&open.actions, inner_y) { - let a = &open.actions[row]; - if !matches!(a, MenuItem::Separator) { - Self::run_action(ctx, a, menu_world); - } else { - ctx.notify(); - } - } else { - ctx.notify(); - } - return EventResult::Stop; - } - ctx.notify(); - return EventResult::Continue; - } - return EventResult::Continue; - } - - if ev.button == MouseButton::Right { - let world = ctx.screen_to_world(ev.position); - let actions = if let Some(nid) = ctx.hit_node(world) { - if !ctx.graph.selected_node().contains(&nid) { - ctx.clear_selected_edge(); - ctx.clear_selected_node(); - ctx.add_selected_node(nid, false); - } - Self::node_actions(nid) - } else { - self.canvas_actions(ctx) - }; - self.open = Some(OpenMenu { - anchor: ev.position, - anchor_world: world, - actions, - }); - ctx.notify(); - return EventResult::Stop; - } - } - EventResult::Continue - } -} diff --git a/crates/ferrum-flow/src/plugins/delete.rs b/crates/ferrum-flow/src/plugins/delete.rs deleted file mode 100644 index 1e567d3ec1..0000000000 --- a/crates/ferrum-flow/src/plugins/delete.rs +++ /dev/null @@ -1,278 +0,0 @@ -use crate::{ - Edge, EdgeId, GraphOp, Node, Port, - canvas::Command, - plugin::{FlowEvent, Plugin}, -}; -use std::collections::HashSet; - -pub struct DeletePlugin; - -impl DeletePlugin { - pub fn new() -> Self { - Self {} - } -} - -impl Default for DeletePlugin { - fn default() -> Self { - Self::new() - } -} - -pub(crate) fn delete_selection(ctx: &mut crate::plugin::PluginContext) { - ctx.execute_command(DeleteCommand::new(ctx)); -} - -impl Plugin for DeletePlugin { - fn name(&self) -> &'static str { - "delete" - } - - fn on_event( - &mut self, - event: &FlowEvent, - ctx: &mut crate::plugin::PluginContext, - ) -> crate::plugin::EventResult { - if let FlowEvent::Input(crate::plugin::InputEvent::KeyDown(ev)) = event - && (ev.keystroke.key == "delete" || ev.keystroke.key == "backspace") - { - ctx.execute_command(DeleteCommand::new(ctx)); - return crate::plugin::EventResult::Stop; - } - crate::plugin::EventResult::Continue - } -} - -struct DeleteCommand { - selected_edge: Vec, - originally_selected_edge_ids: HashSet, - selected_node: Vec, - selected_port: Vec, -} - -impl DeleteCommand { - fn collect_edges_for_selected_nodes( - graph: &crate::Graph, - selected_nodes: &[Node], - ) -> Vec { - let mut edge_ids = HashSet::new(); - let mut edges = Vec::new(); - - for node in selected_nodes { - for port_id in node.inputs().iter().chain(node.outputs().iter()) { - for edge in graph.edges().values() { - if (edge.source_port == *port_id || edge.target_port == *port_id) - && edge_ids.insert(edge.id) - { - edges.push(edge.clone()); - } - } - } - } - - edges - } - - fn new(ctx: &crate::plugin::PluginContext) -> Self { - let selected_node: Vec = ctx - .graph - .selected_node() - .iter() - .filter_map(|id| ctx.get_node(id).cloned()) - .collect(); - let mut selected_edge: Vec = ctx - .graph - .selected_edge() - .iter() - .filter_map(|id| ctx.graph.get_edge(id).cloned()) - .collect(); - let originally_selected_edge_ids: HashSet<_> = selected_edge.iter().map(|e| e.id).collect(); - let mut seen_edge_ids: HashSet<_> = selected_edge.iter().map(|e| e.id).collect(); - for edge in Self::collect_edges_for_selected_nodes(ctx.graph, &selected_node) { - if seen_edge_ids.insert(edge.id) { - selected_edge.push(edge); - } - } - - Self { - selected_edge, - originally_selected_edge_ids, - selected_port: selected_node - .iter() - .flat_map(|node| node.inputs().iter().chain(node.outputs().iter())) - .filter_map(|port_id| ctx.graph.get_port(port_id).cloned()) - .collect(), - selected_node, - } - } -} - -impl Command for DeleteCommand { - fn name(&self) -> &'static str { - "delete" - } - fn execute(&mut self, ctx: &mut crate::canvas::CommandContext) { - ctx.remove_selected_edge(); - ctx.remove_selected_node(); - } - fn undo(&mut self, ctx: &mut crate::canvas::CommandContext) { - for node in &self.selected_node { - ctx.add_node(node.clone()); - ctx.add_selected_node(node.id(), true); - } - - for port in &self.selected_port { - ctx.add_port(port.clone()); - } - - for edge in &self.selected_edge { - ctx.add_edge(edge.clone()); - if self.originally_selected_edge_ids.contains(&edge.id) { - ctx.add_selected_edge(edge.id, true); - } - } - } - - fn to_ops(&self, ctx: &mut crate::CommandContext) -> Vec { - let mut list = vec![]; - let mut removed_edges = HashSet::new(); - for node in &self.selected_node { - list.push(GraphOp::RemoveNode { id: node.id() }); - - let index = ctx.graph.node_order().iter().position(|v| *v == node.id()); - if let Some(index) = index { - list.push(GraphOp::NodeOrderRemove { index }) - } - } - - for port in &self.selected_port { - list.push(GraphOp::RemovePort(port.id())); - } - - for edge in &self.selected_edge { - if removed_edges.insert(edge.id) { - list.push(GraphOp::RemoveEdge(edge.id)); - } - } - - vec![GraphOp::Batch(list)] - } -} - -#[cfg(test)] -mod command_interop_tests { - use std::collections::HashSet; - - use crate::{Graph, command_interop::assert_command_interop}; - - use super::DeleteCommand; - - fn delete_command_like_new(graph: &Graph) -> DeleteCommand { - let selected_node: Vec = graph - .selected_node() - .iter() - .filter_map(|id| graph.get_node(id).cloned()) - .collect(); - let mut selected_edge: Vec = graph - .selected_edge() - .iter() - .filter_map(|id| graph.get_edge(id).cloned()) - .collect(); - let originally_selected_edge_ids: HashSet<_> = selected_edge.iter().map(|e| e.id).collect(); - let mut seen_edge_ids: HashSet<_> = selected_edge.iter().map(|e| e.id).collect(); - for edge in DeleteCommand::collect_edges_for_selected_nodes(graph, &selected_node) { - if seen_edge_ids.insert(edge.id) { - selected_edge.push(edge); - } - } - let selected_port: Vec = graph - .selected_node() - .iter() - .filter_map(|node_id| graph.get_node(node_id)) - .flat_map(|node| node.inputs().iter().chain(node.outputs().iter())) - .filter_map(|port_id| graph.get_port(port_id).cloned()) - .collect(); - DeleteCommand { - selected_edge, - originally_selected_edge_ids, - selected_node, - selected_port, - } - } - - #[test] - fn delete_command_interop_single_node_with_port() { - let mut base = Graph::new(); - let src_id = base - .create_node("x") - .position(-220.0, 0.0) - .output() - .build() - .unwrap(); - let dst_id = base - .create_node("x") - .position(220.0, 0.0) - .input() - .output() - .build() - .unwrap(); - let other_id = base - .create_node("x") - .position(440.0, 0.0) - .input() - .build() - .unwrap(); - // Put selected node at the end so execute+undo preserves node_order with current command behavior. - let selected_id = base - .create_node("x") - .position(0.0, 0.0) - .input() - .output() - .build() - .unwrap(); - - let selected_node = base.get_node(&selected_id).expect("selected node").clone(); - let src_node = base.get_node(&src_id).expect("src node").clone(); - let dst_node = base.get_node(&dst_id).expect("dst node").clone(); - let other_node = base.get_node(&other_id).expect("other node").clone(); - - // This edge is NOT selected, but should be deleted via node-cascade. - let _cascade_in = base - .create_edge() - .source(src_node.outputs()[0]) - .target(selected_node.inputs()[0]) - .build() - .expect("cascade in edge"); - // This edge IS selected and also touches selected node. - let selected_edge = base - .create_edge() - .source(selected_node.outputs()[0]) - .target(dst_node.inputs()[0]) - .build() - .expect("selected edge"); - // Unrelated edge should remain untouched. - let _unrelated = base - .create_edge() - .source(dst_node.outputs()[0]) - .target(other_node.inputs()[0]) - .build() - .expect("unrelated edge"); - - base.add_selected_node(selected_id, false); - base.add_selected_edge(selected_edge, true); - - let cmd = delete_command_like_new(&base); - assert_command_interop( - &base, - || { - Box::new(DeleteCommand { - selected_edge: cmd.selected_edge.clone(), - originally_selected_edge_ids: cmd.originally_selected_edge_ids.clone(), - selected_node: cmd.selected_node.clone(), - selected_port: cmd.selected_port.clone(), - }) - }, - "DeleteCommand", - ); - } -} diff --git a/crates/ferrum-flow/src/plugins/edge/command.rs b/crates/ferrum-flow/src/plugins/edge/command.rs deleted file mode 100644 index c46d98a084..0000000000 --- a/crates/ferrum-flow/src/plugins/edge/command.rs +++ /dev/null @@ -1,163 +0,0 @@ -use std::{collections::HashSet, vec}; - -use crate::{EdgeId, NodeId, canvas::Command, plugin::PluginContext}; - -pub(super) struct SelectEdgeCommand { - edge_id: EdgeId, - shift: bool, - old_selected_edge: HashSet, - old_selected_node: HashSet, -} - -impl SelectEdgeCommand { - pub(super) fn new(edge_id: EdgeId, shift: bool, ctx: &PluginContext) -> Self { - Self { - edge_id, - shift, - old_selected_edge: ctx.graph.selected_edge().clone(), - old_selected_node: ctx.graph.selected_node().clone(), - } - } -} - -impl Command for SelectEdgeCommand { - fn name(&self) -> &'static str { - "select_edge" - } - fn execute(&mut self, ctx: &mut crate::canvas::CommandContext) { - if !self.shift { - ctx.clear_selected_node(); - } - ctx.add_selected_edge(self.edge_id, self.shift); - } - fn undo(&mut self, ctx: &mut crate::canvas::CommandContext) { - ctx.graph.set_selected_node(self.old_selected_node.clone()); - ctx.graph.set_selected_edge(self.old_selected_edge.clone()); - } - fn to_ops(&self, ctx: &mut crate::CommandContext) -> Vec { - if !self.shift { - ctx.clear_selected_node(); - } - ctx.add_selected_edge(self.edge_id, self.shift); - vec![] - } -} - -pub(super) struct ClearEdgeCommand { - old_selected_edge: HashSet, -} - -impl ClearEdgeCommand { - pub(super) fn new(ctx: &PluginContext) -> Self { - Self { - old_selected_edge: ctx.graph.selected_edge().clone(), - } - } -} - -impl Command for ClearEdgeCommand { - fn name(&self) -> &'static str { - "clear_edge" - } - fn execute(&mut self, ctx: &mut crate::canvas::CommandContext) { - ctx.clear_selected_edge(); - } - fn undo(&mut self, ctx: &mut crate::canvas::CommandContext) { - ctx.graph.set_selected_edge(self.old_selected_edge.clone()); - } - - fn to_ops(&self, ctx: &mut crate::CommandContext) -> Vec { - ctx.clear_selected_edge(); - vec![] - } -} - -#[cfg(test)] -mod command_interop_tests { - use crate::{Graph, command_interop::assert_command_interop}; - - use super::{ClearEdgeCommand, SelectEdgeCommand}; - - #[test] - fn select_edge_command_interop() { - let mut base = Graph::new(); - let n1 = base - .create_node("a") - .position(0.0, 0.0) - .output() - .build() - .unwrap(); - let n2 = base - .create_node("b") - .position(100.0, 0.0) - .input() - .build() - .unwrap(); - let n1_node = base.get_node(&n1).expect("n1"); - let n2_node = base.get_node(&n2).expect("n2"); - let source_port = n1_node.outputs()[0]; - let target_port = n2_node.inputs()[0]; - let edge_id = base - .create_edge() - .source(source_port) - .target(target_port) - .build() - .expect("edge"); - - let old_selected_edge = base.selected_edge().clone(); - let old_selected_node = base.selected_node().clone(); - - assert_command_interop( - &base, - || { - Box::new(SelectEdgeCommand { - edge_id, - shift: false, - old_selected_edge: old_selected_edge.clone(), - old_selected_node: old_selected_node.clone(), - }) - }, - "SelectEdgeCommand", - ); - } - - #[test] - fn clear_edge_command_interop() { - let mut base = Graph::new(); - let n1 = base - .create_node("a") - .position(0.0, 0.0) - .output() - .build() - .unwrap(); - let n2 = base - .create_node("b") - .position(100.0, 0.0) - .input() - .build() - .unwrap(); - let n1_node = base.get_node(&n1).expect("n1"); - let n2_node = base.get_node(&n2).expect("n2"); - let source_port = n1_node.outputs()[0]; - let target_port = n2_node.inputs()[0]; - let edge_id = base - .create_edge() - .source(source_port) - .target(target_port) - .build() - .expect("edge"); - base.add_selected_edge(edge_id, false); - - let old_selected_edge = base.selected_edge().clone(); - - assert_command_interop( - &base, - || { - Box::new(ClearEdgeCommand { - old_selected_edge: old_selected_edge.clone(), - }) - }, - "ClearEdgeCommand", - ); - } -} diff --git a/crates/ferrum-flow/src/plugins/edge/mod.rs b/crates/ferrum-flow/src/plugins/edge/mod.rs deleted file mode 100644 index 66758c7a7d..0000000000 --- a/crates/ferrum-flow/src/plugins/edge/mod.rs +++ /dev/null @@ -1,286 +0,0 @@ -use std::collections::HashSet; - -use gpui::{ - Bounds, Element, MouseButton, PathBuilder, Pixels, Point, Styled as _, canvas, px, rgb, -}; - -use crate::{ - Edge, EdgeId, RenderContext, - plugin::{FlowEvent, Plugin, PluginContext}, - plugins::edge::command::ClearEdgeCommand, -}; - -mod command; - -use command::SelectEdgeCommand; - -pub struct EdgePlugin {} - -impl EdgePlugin { - pub fn new() -> Self { - Self {} - } -} - -impl Default for EdgePlugin { - fn default() -> Self { - Self::new() - } -} - -impl Plugin for EdgePlugin { - fn name(&self) -> &'static str { - "edge" - } - fn on_event( - &mut self, - event: &FlowEvent, - ctx: &mut crate::plugin::PluginContext, - ) -> crate::plugin::EventResult { - if let FlowEvent::Input(crate::plugin::InputEvent::MouseDown(ev)) = event { - if ev.button != MouseButton::Left { - return crate::plugin::EventResult::Continue; - } - let shift = ev.modifiers.shift; - if let Some(id) = hit_test_get_edge(ev.position, ctx) { - ctx.cache_port_offset_with_edge(&id); - ctx.execute_command(SelectEdgeCommand::new(id, shift, ctx)); - return crate::plugin::EventResult::Stop; - } else if !shift { - ctx.execute_command(ClearEdgeCommand::new(ctx)); - } - } - crate::plugin::EventResult::Continue - } - fn priority(&self) -> i32 { - 120 - } - fn render_layer(&self) -> crate::plugin::RenderLayer { - crate::plugin::RenderLayer::Edges - } - fn render(&mut self, ctx: &mut crate::RenderContext) -> Option { - let visible_nodes: HashSet<_> = ctx - .graph - .nodes() - .iter() - .filter(|(_, node)| ctx.is_node_visible_node(node)) - .map(|(id, _)| *id) - .collect(); - - let edges: Vec<_> = ctx - .graph - .edges() - .iter() - .filter(|(_, edge)| { - let Some(source_port) = ctx.graph.get_port(&edge.source_port) else { - return false; - }; - let Some(target_port) = ctx.graph.get_port(&edge.target_port) else { - return false; - }; - - visible_nodes.contains(&source_port.node_id()) - || visible_nodes.contains(&target_port.node_id()) - }) - .map(|(k, v)| (*k, edge_geometry2(v, ctx))) - .collect(); - - let edge_ids = edges.iter().map(|(id, _)| *id); - for edge_id in edge_ids { - ctx.cache_port_offset_with_edge(&edge_id); - } - - let selected_edges = ctx.graph.selected_edge().clone(); - let stroke = ctx.theme.edge_stroke; - let stroke_sel = ctx.theme.edge_stroke_selected; - - Some( - canvas( - move |_, _, _| (edges, selected_edges, stroke, stroke_sel), - move |bounds, (edges, selected_edges, stroke, stroke_sel), win, _| { - let origin = bounds.origin; - for (id, geometry) in edges.iter() { - let Some(EdgeGeometry { start, c1, c2, end }) = geometry else { - return; - }; - let mut line = PathBuilder::stroke(px(1.0)); - line.move_to(*start + origin); - line.cubic_bezier_to(*end + origin, *c1 + origin, *c2 + origin); - - let selected = selected_edges.iter().any(|i| *i == *id); - - if let Ok(line) = line.build() { - win.paint_path(line, rgb(if selected { stroke_sel } else { stroke })); - } - } - }, - ) - .absolute() - .size_full() - .into_any(), - ) - } -} - -pub struct EdgeGeometry { - pub start: Point, - pub c1: Point, - pub c2: Point, - pub end: Point, -} - -fn edge_geometry(edge: &Edge, ctx: &PluginContext) -> Option { - let Edge { - source_port: source_id, - target_port: target_id, - .. - } = edge; - - let start = ctx.port_screen_center_by_port_id(*source_id)?; - let end = ctx.port_screen_center_by_port_id(*target_id)?; - - let source_port = ctx.graph.get_port(source_id)?; - let target_port = ctx.graph.get_port(target_id)?; - - let c1 = ctx.edge_control_point(start, source_port.position()); - let c2 = ctx.edge_control_point(end, target_port.position()); - - Some(EdgeGeometry { start, c1, c2, end }) -} - -fn edge_geometry2(edge: &Edge, ctx: &RenderContext) -> Option { - let Edge { - source_port: source_id, - target_port: target_id, - .. - } = edge; - - let start = ctx.port_screen_center_by_port_id(*source_id)?; - let end = ctx.port_screen_center_by_port_id(*target_id)?; - - let source_port = ctx.graph.get_port(source_id)?; - let target_port = ctx.graph.get_port(target_id)?; - - let c1 = ctx.edge_control_point(start, source_port.position()); - let c2 = ctx.edge_control_point(end, target_port.position()); - - Some(EdgeGeometry { start, c1, c2, end }) -} - -fn hit_test_get_edge(mouse: Point, ctx: &PluginContext) -> Option { - let visible_nodes: HashSet<_> = ctx - .graph - .nodes() - .iter() - .filter(|(_, node)| ctx.is_node_visible_node(node)) - .map(|(id, _)| *id) - .collect(); - - let edges = ctx.graph.edges_values().filter(|edge| { - let Some(source_port) = ctx.graph.get_port(&edge.source_port) else { - return false; - }; - let Some(target_port) = ctx.graph.get_port(&edge.target_port) else { - return false; - }; - - visible_nodes.contains(&source_port.node_id()) - || visible_nodes.contains(&target_port.node_id()) - }); - for edge in edges { - let Some(geom) = edge_geometry(edge, ctx) else { - continue; - }; - - let bound = edge_bounds(&geom); - if !bound.contains(&mouse) { - continue; - } - - if hit_test_edge(mouse, &geom) { - return Some(edge.id); - } - } - - None -} - -pub fn edge_bounds(geom: &EdgeGeometry) -> Bounds { - let min_x = geom.start.x.min(geom.end.x).min(geom.c1.x).min(geom.c2.x); - let max_x = geom.start.x.max(geom.end.x).max(geom.c1.x).max(geom.c2.x); - - let min_y = geom.start.y.min(geom.end.y).min(geom.c1.y).min(geom.c2.y); - let max_y = geom.start.y.max(geom.end.y).max(geom.c1.y).max(geom.c2.y); - - Bounds::from_corners( - Point::new(min_x - px(10.0), min_y - px(10.0)), - Point::new(max_x + px(10.0), max_y + px(10.0)), - ) -} - -fn hit_test_edge(mouse: Point, geom: &EdgeGeometry) -> bool { - let points = sample_bezier(geom, 20); - - for segment in points.windows(2) { - let d = distance_to_segment(mouse, segment[0], segment[1]); - - if d < 8.0 { - return true; - } - } - - false -} - -fn sample_bezier(geom: &EdgeGeometry, steps: usize) -> Vec> { - let mut points = Vec::new(); - - for i in 0..=steps { - let t = i as f32 / steps as f32; - - let x = (1.0 - t).powi(3) * geom.start.x - + 3.0 * (1.0 - t).powi(2) * t * geom.c1.x - + 3.0 * (1.0 - t) * t * t * geom.c2.x - + t.powi(3) * geom.end.x; - - let y = (1.0 - t).powi(3) * geom.start.y - + 3.0 * (1.0 - t).powi(2) * t * geom.c1.y - + 3.0 * (1.0 - t) * t * t * geom.c2.y - + t.powi(3) * geom.end.y; - - points.push(Point::new(x, y)); - } - - points -} -pub fn distance_to_segment(p: Point, a: Point, b: Point) -> f32 { - let ap = vec_sub(p, a); - let ab = vec_sub(b, a); - - let ab_len2 = ab.0 * ab.0 + ab.1 * ab.1; - - if ab_len2 == 0.0 { - return vec_length(ap); - } - - let t = (vec_dot(ap, ab) / ab_len2).clamp(0.0, 1.0); - - let closest = Point::new(f32::from(a.x) + ab.0 * t, f32::from(a.y) + ab.1 * t); - - let dx = f32::from(p.x) - closest.x; - let dy = f32::from(p.y) - closest.y; - - (dx * dx + dy * dy).sqrt() -} - -fn vec_sub(a: Point, b: Point) -> (f32, f32) { - (f32::from(a.x - b.x), f32::from(a.y - b.y)) -} - -fn vec_dot(a: (f32, f32), b: (f32, f32)) -> f32 { - a.0 * b.0 + a.1 * b.1 -} - -fn vec_length(v: (f32, f32)) -> f32 { - (v.0 * v.0 + v.1 * v.1).sqrt() -} diff --git a/crates/ferrum-flow/src/plugins/fit_all.rs b/crates/ferrum-flow/src/plugins/fit_all.rs deleted file mode 100644 index c07f8ab0bb..0000000000 --- a/crates/ferrum-flow/src/plugins/fit_all.rs +++ /dev/null @@ -1,114 +0,0 @@ -use gpui::{Bounds, Point, px}; - -use crate::{ - Node, - plugin::{FlowEvent, InitPluginContext, Plugin, PluginContext, primary_platform_modifier}, - plugins::viewport_frame::{apply_frame_world_rect_direct, frame_world_rect}, -}; - -/// Zoom and pan so **all** nodes fit in the window (⌘0 / Ctrl+0). Undo restores the previous view. -/// -/// On [`Plugin::setup`], fits once using [`InitPluginContext::drawable_size`] (does not push an undo -/// entry; the initial view is not recorded as a command). -pub struct FitAllGraphPlugin; - -impl FitAllGraphPlugin { - pub fn new() -> Self { - Self - } -} - -impl Default for FitAllGraphPlugin { - fn default() -> Self { - Self::new() - } -} - -fn graph_world_bounds_graph<'a>( - nodes: impl Iterator + 'a, -) -> Option<(f32, f32, f32, f32)> { - let mut min_x = f32::MAX; - let mut min_y = f32::MAX; - let mut max_x = f32::MIN; - let mut max_y = f32::MIN; - let mut any = false; - - for n in nodes { - let (nx, ny) = n.position(); - let size = *n.size_ref(); - let x: f32 = nx.into(); - let y: f32 = ny.into(); - let w: f32 = size.width.into(); - let h: f32 = size.height.into(); - min_x = min_x.min(x); - min_y = min_y.min(y); - max_x = max_x.max(x + w); - max_y = max_y.max(y + h); - any = true; - } - - if !any { - return None; - } - - Some(( - min_x, - min_y, - (max_x - min_x).max(1.0), - (max_y - min_y).max(1.0), - )) -} - -fn graph_world_bounds(ctx: &PluginContext) -> Option<(f32, f32, f32, f32)> { - graph_world_bounds_graph(ctx.nodes().values()) -} - -fn fit_all(ctx: &mut PluginContext) { - let Some((bx, by, bw, bh)) = graph_world_bounds(ctx) else { - return; - }; - frame_world_rect(ctx, bx, by, bw, bh); -} - -pub(crate) fn fit_entire_graph(ctx: &mut PluginContext) { - fit_all(ctx); -} - -impl Plugin for FitAllGraphPlugin { - fn name(&self) -> &'static str { - "fit_all_graph" - } - - fn setup(&mut self, ctx: &mut InitPluginContext) { - let Some((bx, by, bw, bh)) = graph_world_bounds_graph(ctx.nodes().values()) else { - return; - }; - let win_w: f32 = ctx.drawable_size.width.into(); - let win_h: f32 = ctx.drawable_size.height.into(); - apply_frame_world_rect_direct(ctx, win_w, win_h, bx, by, bw, bh); - ctx.set_window_bounds(Some(Bounds::new( - Point::new(px(0.0), px(0.0)), - ctx.drawable_size, - ))); - } - - fn priority(&self) -> i32 { - 88 - } - - fn on_event( - &mut self, - event: &FlowEvent, - ctx: &mut PluginContext, - ) -> crate::plugin::EventResult { - if let FlowEvent::Input(crate::plugin::InputEvent::KeyDown(ev)) = event - && primary_platform_modifier(ev) - && !ev.keystroke.modifiers.shift - && ev.keystroke.key == "0" - { - fit_all(ctx); - return crate::plugin::EventResult::Stop; - } - crate::plugin::EventResult::Continue - } -} diff --git a/crates/ferrum-flow/src/plugins/focus_selection.rs b/crates/ferrum-flow/src/plugins/focus_selection.rs deleted file mode 100644 index 5cc2f6bddd..0000000000 --- a/crates/ferrum-flow/src/plugins/focus_selection.rs +++ /dev/null @@ -1,63 +0,0 @@ -use crate::{ - plugin::{FlowEvent, Plugin, PluginContext, primary_platform_modifier}, - plugins::viewport_frame::frame_world_rect, -}; - -/// Pan + zoom the viewport so selected nodes fit the window (⌘⇧F / Ctrl⇧F). Undo restores prior view. -pub struct FocusSelectionPlugin; - -impl FocusSelectionPlugin { - pub fn new() -> Self { - Self - } -} - -impl Default for FocusSelectionPlugin { - fn default() -> Self { - Self::new() - } -} - -fn focus_shortcut(ev: &gpui::KeyDownEvent) -> bool { - primary_platform_modifier(ev) && ev.keystroke.modifiers.shift -} - -fn focus_selected(ctx: &mut PluginContext) { - let Some(bounds) = ctx.graph.selection_bounds() else { - return; - }; - let bx: f32 = bounds.origin.x.into(); - let by: f32 = bounds.origin.y.into(); - let bw: f32 = bounds.size.width.into(); - let bh: f32 = bounds.size.height.into(); - frame_world_rect(ctx, bx, by, bw, bh); -} - -pub(crate) fn focus_viewport_on_selection(ctx: &mut PluginContext) { - focus_selected(ctx); -} - -impl Plugin for FocusSelectionPlugin { - fn name(&self) -> &'static str { - "focus_selection" - } - - fn priority(&self) -> i32 { - 90 - } - - fn on_event( - &mut self, - event: &FlowEvent, - ctx: &mut PluginContext, - ) -> crate::plugin::EventResult { - if let FlowEvent::Input(crate::plugin::InputEvent::KeyDown(ev)) = event - && focus_shortcut(ev) - && ev.keystroke.key == "f" - { - focus_selected(ctx); - return crate::plugin::EventResult::Stop; - } - crate::plugin::EventResult::Continue - } -} diff --git a/crates/ferrum-flow/src/plugins/history.rs b/crates/ferrum-flow/src/plugins/history.rs deleted file mode 100644 index a88ec5ce9c..0000000000 --- a/crates/ferrum-flow/src/plugins/history.rs +++ /dev/null @@ -1,39 +0,0 @@ -use crate::plugin::{FlowEvent, Plugin, primary_platform_modifier}; - -pub struct HistoryPlugin; - -impl HistoryPlugin { - pub fn new() -> Self { - Self {} - } -} - -impl Default for HistoryPlugin { - fn default() -> Self { - Self::new() - } -} - -impl Plugin for HistoryPlugin { - fn name(&self) -> &'static str { - "history" - } - - fn on_event( - &mut self, - event: &FlowEvent, - ctx: &mut crate::plugin::PluginContext, - ) -> crate::plugin::EventResult { - if let FlowEvent::Input(crate::plugin::InputEvent::KeyDown(ev)) = event { - let primary = primary_platform_modifier(ev); - if ev.keystroke.key == "z" && primary && ev.keystroke.modifiers.shift { - ctx.redo(); - return crate::plugin::EventResult::Stop; - } else if ev.keystroke.key == "z" && primary { - ctx.undo(); - return crate::plugin::EventResult::Stop; - } - } - crate::plugin::EventResult::Continue - } -} diff --git a/crates/ferrum-flow/src/plugins/minimap.rs b/crates/ferrum-flow/src/plugins/minimap.rs deleted file mode 100644 index 9d41a735b2..0000000000 --- a/crates/ferrum-flow/src/plugins/minimap.rs +++ /dev/null @@ -1,443 +0,0 @@ -//! Overview minimap: full-graph bounds in world space, current viewport indicator, click-to-center. - -use std::collections::HashMap; - -use gpui::{ - Bounds, Element, MouseButton, PathBuilder, Pixels, Point, Size, Styled as _, canvas, px, rgb, -}; - -use crate::{ - NodeId, Viewport, - canvas::{Command, CommandContext}, - plugin::{ - EventResult, FlowEvent, InputEvent, Plugin, PluginContext, RenderContext, RenderLayer, - }, -}; - -const MAP_W: f32 = 200.0; -const MAP_H: f32 = 140.0; -const OUTER_MARGIN: f32 = 16.0; -const INNER_INSET: f32 = 3.0; -const WORLD_PAD: f32 = 96.0; - -/// Warm-start capacity for the viewport-visible node map; typical sessions stay in low hundreds. -/// Capped by total node count so tiny graphs do not over-allocate. -const VISIBLE_NODE_MAP_CAPACITY_HINT: usize = 128; - -/// Last-computed layout for hit-testing (updated each [`MinimapPlugin::render`]). -#[derive(Clone)] -struct MinimapLayout { - chrome: Bounds, - inner: Bounds, - world_x0: f32, - world_y0: f32, - world_w: f32, - world_h: f32, -} - -impl MinimapLayout { - fn contains_chrome(&self, p: Point) -> bool { - self.chrome.contains(&p) - } - - /// Maps a screen position inside the chrome to world coordinates (clamped to the mapped extent). - fn screen_to_world(&self, screen: Point) -> Point { - let ix: f32 = self.inner.origin.x.into(); - let iy: f32 = self.inner.origin.y.into(); - let iw: f32 = self.inner.size.width.into(); - let ih: f32 = self.inner.size.height.into(); - let sx: f32 = screen.x.into(); - let sy: f32 = screen.y.into(); - let u = ((sx - ix) / iw.max(1.0)).clamp(0.0, 1.0); - let v = ((sy - iy) / ih.max(1.0)).clamp(0.0, 1.0); - let wx = self.world_x0 + u * self.world_w; - let wy = self.world_y0 + v * self.world_h; - Point::new(px(wx), px(wy)) - } -} - -fn graph_world_extent(ctx: &RenderContext) -> (f32, f32, f32, f32) { - let nodes: Vec<_> = ctx - .graph - .nodes() - .values() - .filter(|n| ctx.is_node_visible_node(n)) - .collect(); - if nodes.is_empty() { - return (0.0, 0.0, 640.0, 480.0); - } - let mut min_x = f32::MAX; - let mut min_y = f32::MAX; - let mut max_x = f32::MIN; - let mut max_y = f32::MIN; - for n in nodes { - let (nx, ny) = n.position(); - let size = *n.size_ref(); - let x: f32 = nx.into(); - let y: f32 = ny.into(); - let w: f32 = size.width.into(); - let h: f32 = size.height.into(); - min_x = min_x.min(x); - min_y = min_y.min(y); - max_x = max_x.max(x + w); - max_y = max_y.max(y + h); - } - let w = (max_x - min_x + 2.0 * WORLD_PAD).max(120.0); - let h = (max_y - min_y + 2.0 * WORLD_PAD).max(120.0); - (min_x - WORLD_PAD, min_y - WORLD_PAD, w, h) -} - -fn visible_world_aabb(viewport: &Viewport, win: &Bounds) -> (f32, f32, f32, f32) { - let w: f32 = win.size.width.into(); - let h: f32 = win.size.height.into(); - let corners = [ - viewport.screen_to_world(Point::new(px(0.0), px(0.0))), - viewport.screen_to_world(Point::new(px(w), px(0.0))), - viewport.screen_to_world(Point::new(px(w), px(h))), - viewport.screen_to_world(Point::new(px(0.0), px(h))), - ]; - let mut min_x = f32::MAX; - let mut min_y = f32::MAX; - let mut max_x = f32::MIN; - let mut max_y = f32::MIN; - for c in corners { - let x: f32 = c.x.into(); - let y: f32 = c.y.into(); - min_x = min_x.min(x); - min_y = min_y.min(y); - max_x = max_x.max(x); - max_y = max_y.max(y); - } - ( - min_x, - min_y, - (max_x - min_x).max(1.0), - (max_y - min_y).max(1.0), - ) -} - -fn build_layout(ctx: &RenderContext) -> Option { - let win = ctx.window_bounds()?; - let ww: f32 = win.size.width.into(); - let wh: f32 = win.size.height.into(); - if ww < MAP_W + OUTER_MARGIN || wh < MAP_H + OUTER_MARGIN { - return None; - } - - let map_w = px(MAP_W); - let map_h = px(MAP_H); - let ox = win.size.width - map_w - px(OUTER_MARGIN); - let oy = win.size.height - map_h - px(OUTER_MARGIN); - let chrome = Bounds::new(Point::new(ox, oy), Size::new(map_w, map_h)); - - let inset = px(INNER_INSET); - let inner = Bounds::new( - chrome.origin + Point::new(inset, inset), - Size::new( - chrome.size.width - inset * 2.0, - chrome.size.height - inset * 2.0, - ), - ); - - let (wx0, wy0, ww, wh) = graph_world_extent(ctx); - - Some(MinimapLayout { - chrome, - inner, - world_x0: wx0, - world_y0: wy0, - world_w: ww.max(1.0), - world_h: wh.max(1.0), - }) -} - -fn world_to_inner_pt(wx: f32, wy: f32, layout: &MinimapLayout) -> Point { - let u = ((wx - layout.world_x0) / layout.world_w).clamp(0.0, 1.0); - let v = ((wy - layout.world_y0) / layout.world_h).clamp(0.0, 1.0); - let ix: f32 = layout.inner.origin.x.into(); - let iy: f32 = layout.inner.origin.y.into(); - let iw: f32 = layout.inner.size.width.into(); - let ih: f32 = layout.inner.size.height.into(); - Point::new(px(ix + u * iw), px(iy + v * ih)) -} - -fn center_viewport_on_world(ctx: &mut PluginContext, world: Point) { - let Some(wb) = ctx.window_bounds() else { - return; - }; - let cx: f32 = (wb.size.width / 2.0).into(); - let cy: f32 = (wb.size.height / 2.0).into(); - let z = ctx.zoom(); - let wx: f32 = world.x.into(); - let wy: f32 = world.y.into(); - let from = ctx.offset(); - ctx.set_offset_xy(px(cx - wx * z), px(cy - wy * z)); - let to = ctx.offset(); - ctx.execute_command(MinimapPanCommand { from, to }); -} - -struct MinimapPanCommand { - from: Point, - to: Point, -} - -impl Command for MinimapPanCommand { - fn name(&self) -> &'static str { - "minimap_pan" - } - - fn execute(&mut self, ctx: &mut CommandContext) { - ctx.set_offset(self.to); - } - - fn undo(&mut self, ctx: &mut CommandContext) { - ctx.set_offset(self.from); - } - - fn to_ops(&self, _ctx: &mut crate::CommandContext) -> Vec { - vec![] - } -} - -/// Renders a bottom-right overview map and pans the viewport when the user clicks it. -/// -/// Uses priority **135** so clicks hit the minimap before [`crate::plugins::SelectionPlugin`] (100) -/// starts a canvas selection. -pub struct MinimapPlugin { - last_layout: Option, -} - -impl MinimapPlugin { - pub fn new() -> Self { - Self { last_layout: None } - } -} - -impl Default for MinimapPlugin { - fn default() -> Self { - Self::new() - } -} - -impl Plugin for MinimapPlugin { - fn name(&self) -> &'static str { - "minimap" - } - - fn on_event(&mut self, event: &FlowEvent, ctx: &mut PluginContext) -> EventResult { - if let FlowEvent::Input(InputEvent::MouseDown(ev)) = event - && let Some(ref layout) = self.last_layout - && layout.contains_chrome(ev.position) - { - if ev.button == MouseButton::Right { - return EventResult::Stop; - } else if ev.button == MouseButton::Left { - let world = layout.screen_to_world(ev.position); - center_viewport_on_world(ctx, world); - ctx.notify(); - return EventResult::Stop; - } - } - EventResult::Continue - } - - fn priority(&self) -> i32 { - 135 - } - - fn render_layer(&self) -> RenderLayer { - RenderLayer::Overlay - } - - fn render(&mut self, ctx: &mut RenderContext) -> Option { - let layout = build_layout(ctx)?; - self.last_layout = Some(layout.clone()); - - let inner = layout.inner; - - // One visibility pass: rects for node quads, and world-space centers for edges (no second - // `get_node` per endpoint). - let map_cap = VISIBLE_NODE_MAP_CAPACITY_HINT.min(ctx.graph.nodes().len()); - let mut visible_centers = HashMap::::with_capacity(map_cap); - let nodes: Vec<_> = ctx - .graph - .nodes() - .values() - .filter_map(|n| { - if !ctx.is_node_visible(&n.id()) { - return None; - } - let (nx, ny) = n.position(); - let size = *n.size_ref(); - let x: f32 = nx.into(); - let y: f32 = ny.into(); - let w: f32 = size.width.into(); - let h: f32 = size.height.into(); - visible_centers.insert(n.id(), (x + w * 0.5, y + h * 0.5)); - Some((x, y, w, h)) - }) - .collect(); - - let edges: Vec<_> = ctx - .graph - .edges_values() - .filter_map(|e| { - let s = ctx.graph.get_port(&e.source_port)?; - let t = ctx.graph.get_port(&e.target_port)?; - let (sx, sy) = visible_centers.get(&s.node_id())?; - let (tx, ty) = visible_centers.get(&t.node_id())?; - Some((*sx, *sy, *tx, *ty)) - }) - .collect(); - - let win_bounds = ctx.window_bounds()?; - let (vx0, vy0, vw, vh) = visible_world_aabb(ctx.viewport(), &win_bounds); - let v_tl = world_to_inner_pt(vx0, vy0, &layout); - let v_br = world_to_inner_pt(vx0 + vw, vy0 + vh, &layout); - - let minimap_background = ctx.theme.minimap_background; - let minimap_border = ctx.theme.minimap_border; - let minimap_edge = ctx.theme.minimap_edge; - let minimap_node_fill = ctx.theme.minimap_node_fill; - let minimap_node_stroke = ctx.theme.minimap_node_stroke; - let minimap_viewport_stroke = ctx.theme.minimap_viewport_stroke; - - Some( - canvas( - move |_, _, _| (), - move |bounds, _, win, _| { - let origin = bounds.origin; - // Inner background - if let Ok(p) = rect_fill_path(offset_bounds(inner, origin)) { - win.paint_path(p, rgb(minimap_background)); - } - if let Ok(p) = rect_stroke_path(offset_bounds(inner, origin), px(1.0)) { - win.paint_path(p, rgb(minimap_border)); - } - - // Edges (straight segments between node centers) - for (sx, sy, tx, ty) in edges { - let a = world_to_inner_pt(sx, sy, &layout); - let b = world_to_inner_pt(tx, ty, &layout); - let mut line = PathBuilder::stroke(px(1.0)); - line.move_to(a + origin); - line.line_to(b + origin); - if let Ok(p) = line.build() { - win.paint_path(p, rgb(minimap_edge)); - } - } - - for (x, y, nw, nh) in nodes { - let p0 = world_to_inner_pt(x, y, &layout); - let p1 = world_to_inner_pt(x + nw, y + nh, &layout); - let min_x = f32::min(f32::from(p0.x), f32::from(p1.x)); - let max_x = f32::max(f32::from(p0.x), f32::from(p1.x)); - let min_y = f32::min(f32::from(p0.y), f32::from(p1.y)); - let max_y = f32::max(f32::from(p0.y), f32::from(p1.y)); - let rw = (max_x - min_x).max(2.0); - let rh = (max_y - min_y).max(2.0); - let o = Point::new(px(min_x), px(min_y)) + origin; - let s = Size::new(px(rw), px(rh)); - if let Ok(p) = rect_fill_bounds(o, s) { - win.paint_path(p, rgb(minimap_node_fill)); - } - if let Ok(p) = rect_stroke_bounds(o, s, px(1.0)) { - win.paint_path(p, rgb(minimap_node_stroke)); - } - } - - // Viewport frame - let min_x = f32::min(f32::from(v_tl.x), f32::from(v_br.x)); - let max_x = f32::max(f32::from(v_tl.x), f32::from(v_br.x)); - let min_y = f32::min(f32::from(v_tl.y), f32::from(v_br.y)); - let max_y = f32::max(f32::from(v_tl.y), f32::from(v_br.y)); - let vo = Point::new(px(min_x), px(min_y)) + origin; - let vs = Size::new(px((max_x - min_x).max(2.0)), px((max_y - min_y).max(2.0))); - if let Ok(p) = rect_stroke_bounds(vo, vs, px(1.5)) { - win.paint_path(p, rgb(minimap_viewport_stroke)); - } - }, - ) - .absolute() - .size_full() - .into_any(), - ) - } -} - -fn offset_bounds(bounds: Bounds, offset: Point) -> Bounds { - Bounds::new(bounds.origin + offset, bounds.size) -} - -fn rect_fill_path(b: Bounds) -> Result, anyhow::Error> { - rect_fill_bounds(b.origin, b.size) -} - -fn rect_fill_bounds( - o: Point, - s: Size, -) -> Result, anyhow::Error> { - let x0: f32 = o.x.into(); - let y0: f32 = o.y.into(); - let w: f32 = s.width.into(); - let h: f32 = s.height.into(); - let pts = [ - Point::new(px(x0), px(y0)), - Point::new(px(x0 + w), px(y0)), - Point::new(px(x0 + w), px(y0 + h)), - Point::new(px(x0), px(y0 + h)), - ]; - let mut pb = PathBuilder::fill(); - pb.add_polygon(&pts, true); - pb.build() -} - -fn rect_stroke_path(b: Bounds, width: Pixels) -> Result, anyhow::Error> { - rect_stroke_bounds(b.origin, b.size, width) -} - -fn rect_stroke_bounds( - o: Point, - s: Size, - width: Pixels, -) -> Result, anyhow::Error> { - let x0: f32 = o.x.into(); - let y0: f32 = o.y.into(); - let w: f32 = s.width.into(); - let h: f32 = s.height.into(); - let mut line = PathBuilder::stroke(width); - line.move_to(Point::new(px(x0), px(y0))); - line.line_to(Point::new(px(x0 + w), px(y0))); - line.line_to(Point::new(px(x0 + w), px(y0 + h))); - line.line_to(Point::new(px(x0), px(y0 + h))); - line.close(); - line.build() -} - -#[cfg(test)] -mod command_interop_tests { - use gpui::{Point, px}; - - use crate::{Graph, command_interop::assert_command_interop}; - - use super::MinimapPanCommand; - - #[test] - fn minimap_pan_command_interop() { - let base = Graph::new(); - let cmd = MinimapPanCommand { - from: Point::new(px(1.0), px(2.0)), - to: Point::new(px(10.0), px(20.0)), - }; - assert_command_interop( - &base, - || { - Box::new(MinimapPanCommand { - from: cmd.from, - to: cmd.to, - }) - }, - "MinimapPanCommand", - ); - } -} diff --git a/crates/ferrum-flow/src/plugins/mod.rs b/crates/ferrum-flow/src/plugins/mod.rs deleted file mode 100644 index 12e43dd563..0000000000 --- a/crates/ferrum-flow/src/plugins/mod.rs +++ /dev/null @@ -1,44 +0,0 @@ -mod align; -mod background; -mod clipboard; -mod context_menu; -mod delete; -mod edge; -mod fit_all; -mod focus_selection; -mod history; -mod minimap; -mod node; -mod port; -mod select_all_viewport; -mod selection; -mod snap_guides; -mod toast; -mod viewport; -mod viewport_frame; -mod zoom_controls; - -pub use align::AlignPlugin; -pub use background::BackgroundPlugin; -pub use clipboard::ClipboardPlugin; -pub use context_menu::{ContextMenuCanvasExtra, ContextMenuCustomAction, ContextMenuPlugin}; -pub use delete::DeletePlugin; -pub use edge::EdgePlugin; -pub use fit_all::FitAllGraphPlugin; -pub use focus_selection::FocusSelectionPlugin; -pub use history::HistoryPlugin; -pub use minimap::MinimapPlugin; -pub use node::{ - ActiveNodeDrag, NODE_DRAG_TICK_INTERVAL, NodeDragEvent, NodeInteractionPlugin, NodePlugin, -}; -pub use port::{ - CreateEdge, CreateNode, CreatePort, DefaultEdgeValidator, EdgeValidationError, - EdgeValidationErrorCode, EdgeValidator, PortInteractionPlugin, edge_bezier, filled_disc_path, - port_screen_big_bounds, port_screen_bounds, -}; -pub use select_all_viewport::SelectAllViewportPlugin; -pub use selection::SelectionPlugin; -pub use snap_guides::SnapGuidesPlugin; -pub use toast::{ToastLevel, ToastMessage, ToastPlugin}; -pub use viewport::ViewportPlugin; -pub use zoom_controls::ZoomControlsPlugin; diff --git a/crates/ferrum-flow/src/plugins/node/command.rs b/crates/ferrum-flow/src/plugins/node/command.rs deleted file mode 100644 index 38e64dc0b2..0000000000 --- a/crates/ferrum-flow/src/plugins/node/command.rs +++ /dev/null @@ -1,178 +0,0 @@ -use std::collections::HashSet; - -use gpui::{Pixels, Point}; - -use crate::{EdgeId, GraphOp, NodeId, canvas::Command, plugin::PluginContext}; - -pub struct SelecteNodeCommand { - node_id: NodeId, - shift: bool, - old_node_order: Vec, - old_selected_edge: HashSet, - old_selected_node: HashSet, -} - -impl SelecteNodeCommand { - pub fn new(node_id: NodeId, shift: bool, ctx: &PluginContext) -> Self { - Self { - node_id, - shift, - old_node_order: ctx.graph.node_order().clone(), - old_selected_edge: ctx.graph.selected_edge().clone(), - old_selected_node: ctx.graph.selected_node().clone(), - } - } -} - -impl Command for SelecteNodeCommand { - fn name(&self) -> &'static str { - "select_node" - } - fn execute(&mut self, ctx: &mut crate::canvas::CommandContext) { - if !self.shift { - ctx.clear_selected_edge(); - } - ctx.add_selected_node(self.node_id, self.shift); - ctx.bring_node_to_front(self.node_id); - } - fn undo(&mut self, ctx: &mut crate::canvas::CommandContext) { - ctx.graph.set_selected_node(self.old_selected_node.clone()); - ctx.graph.set_selected_edge(self.old_selected_edge.clone()); - let a = ctx.graph.node_order_mut(); - *a = self.old_node_order.clone(); - } - - fn to_ops(&self, ctx: &mut crate::CommandContext) -> Vec { - if !self.shift { - ctx.clear_selected_edge(); - } - ctx.add_selected_node(self.node_id, self.shift); - - let mut list = vec![]; - let index = ctx - .graph - .node_order() - .iter() - .position(|v| *v == self.node_id); - if let Some(index) = index { - list.push(GraphOp::NodeOrderRemove { index }) - } - list.push(GraphOp::NodeOrderInsert { id: self.node_id }); - list - } -} - -pub struct DragNodesCommand { - from: Vec<(NodeId, Point)>, - to: Vec<(NodeId, Point)>, -} - -impl DragNodesCommand { - pub fn new(start_positions: &[(NodeId, Point)], ctx: &PluginContext) -> Self { - let mut to = Vec::new(); - for (node_id, _) in start_positions { - if let Some(node) = ctx.get_node(node_id) { - to.push((*node_id, node.point())); - } - } - Self { - from: start_positions.to_vec(), - to, - } - } - - /// Explicit before/after positions (same node order, same length). Use for align / distribute. - pub fn from_positions( - from: Vec<(NodeId, Point)>, - to: Vec<(NodeId, Point)>, - ) -> Self { - Self { from, to } - } -} - -impl Command for DragNodesCommand { - fn name(&self) -> &'static str { - "drag_nodes" - } - fn execute(&mut self, ctx: &mut crate::canvas::CommandContext) { - for (id, point) in self.to.iter() { - if let Some(node) = ctx.get_node_mut(id) { - node.set_position_with_point(*point); - } - } - } - fn undo(&mut self, ctx: &mut crate::canvas::CommandContext) { - for (id, point) in self.from.iter() { - if let Some(node) = ctx.get_node_mut(id) { - node.set_position_with_point(*point); - } - } - } - - fn to_ops(&self, _ctx: &mut crate::CommandContext) -> Vec { - let mut list = vec![]; - for (id, point) in self.to.iter() { - list.push(GraphOp::MoveNode { - id: *id, - x: Into::::into(point.x), - y: Into::::into(point.y), - }) - } - - vec![GraphOp::Batch(list)] - } -} - -#[cfg(test)] -mod command_interop_tests { - use gpui::{Point, px}; - - use crate::{Graph, command_interop::assert_command_interop}; - - use super::{DragNodesCommand, SelecteNodeCommand}; - - #[test] - fn select_node_command_interop() { - let mut base = Graph::new(); - let n1 = base.create_node("a").position(0.0, 0.0).build().unwrap(); - let _n2 = base.create_node("b").position(50.0, 0.0).build().unwrap(); - - let old_node_order = base.node_order().to_vec(); - let old_selected_edge = base.selected_edge().clone(); - let old_selected_node = base.selected_node().clone(); - - assert_command_interop( - &base, - || { - Box::new(SelecteNodeCommand { - node_id: n1, - shift: false, - old_node_order: old_node_order.clone(), - old_selected_edge: old_selected_edge.clone(), - old_selected_node: old_selected_node.clone(), - }) - }, - "SelecteNodeCommand", - ); - } - - #[test] - fn drag_nodes_command_interop() { - let mut base = Graph::new(); - let n = base.create_node("n").position(0.0, 0.0).build().unwrap(); - let from = vec![(n, Point::new(px(0.0), px(0.0)))]; - let to = vec![(n, Point::new(px(30.0), px(40.0)))]; - let cmd = DragNodesCommand::from_positions(from, to); - - assert_command_interop( - &base, - || { - Box::new(DragNodesCommand::from_positions( - cmd.from.clone(), - cmd.to.clone(), - )) - }, - "DragNodesCommand", - ); - } -} diff --git a/crates/ferrum-flow/src/plugins/node/drag_events.rs b/crates/ferrum-flow/src/plugins/node/drag_events.rs deleted file mode 100644 index 6bee31a6d2..0000000000 --- a/crates/ferrum-flow/src/plugins/node/drag_events.rs +++ /dev/null @@ -1,31 +0,0 @@ -//! Custom [`FlowEvent`](crate::plugin::FlowEvent) payloads for primary (left-button) node dragging. -//! Emitted by [`super::interaction::NodeDragInteraction`]. Other plugins (e.g. snap guides) may -//! subscribe via [`FlowEvent::as_custom`](crate::plugin::FlowEvent::as_custom). -//! -//! [`NodeDragEvent::Tick`] carries [`std::sync::Arc`] so the emitter can share the same id list across -//! ticks without reallocating (custom events cannot borrow interaction state). - -use std::sync::Arc; -use std::time::Duration; - -use crate::NodeId; - -/// Stored in [`crate::SharedState`] while [`super::interaction::NodeDragInteraction`] is active in -/// the dragging phase: these node ids are rendered on the interaction layer only; [`super::NodePlugin`] -/// skips them in the static nodes layer to cut work per frame. -#[derive(Clone, Debug)] -pub struct ActiveNodeDrag(pub Arc<[NodeId]>); - -/// Default throttle for [`NodeDragEvent::Tick`] ([`crate::plugins::NodeInteractionPlugin::new`]). -/// Use [`crate::plugins::NodeInteractionPlugin::with_drag_tick_interval`] to change it. -pub const NODE_DRAG_TICK_INTERVAL: Duration = Duration::from_millis(50); - -/// Primary node drag lifecycle on the canvas (left-button drag from [`super::NodeInteractionPlugin`]). -#[derive(Debug, Clone)] -pub enum NodeDragEvent { - /// Throttled while dragging; [`crate::Graph`] already holds updated positions for these nodes. - /// Same slice is reused for the whole drag (cheap [`Arc::clone`] per tick). - Tick(Arc<[NodeId]>), - /// Drag finished: click without move, or pointer released after a drag. - End, -} diff --git a/crates/ferrum-flow/src/plugins/node/interaction.rs b/crates/ferrum-flow/src/plugins/node/interaction.rs deleted file mode 100644 index 3fe9dc69a4..0000000000 --- a/crates/ferrum-flow/src/plugins/node/interaction.rs +++ /dev/null @@ -1,230 +0,0 @@ -use std::sync::Arc; -use std::time::{Duration, Instant}; - -use gpui::{MouseButton, Pixels, Point, px}; - -use crate::{ - NodeId, - canvas::{Interaction, InteractionResult}, - plugin::{EventResult, FlowEvent, InputEvent, Plugin, PluginContext}, - plugins::node::{ - ActiveNodeDrag, NODE_DRAG_TICK_INTERVAL, NodeDragEvent, - command::{DragNodesCommand, SelecteNodeCommand}, - }, -}; - -const DRAG_THRESHOLD: Pixels = px(2.0); -const DRAG_COMMAND_INTERVAL: Duration = Duration::from_millis(50); - -/// Configures [`NodeDragInteraction`] sampling for [`NodeDragEvent::Tick`]. -pub struct NodeInteractionPlugin { - drag_tick_interval: Duration, -} - -impl NodeInteractionPlugin { - pub fn new() -> Self { - Self { - drag_tick_interval: NODE_DRAG_TICK_INTERVAL, - } - } - - /// Override the drag tick interval (e.g. lower for snappier alignment feedback, higher to reduce load). - pub fn with_drag_tick_interval(interval: Duration) -> Self { - Self { - drag_tick_interval: interval, - } - } -} - -impl Default for NodeInteractionPlugin { - fn default() -> Self { - Self::new() - } -} - -impl Plugin for NodeInteractionPlugin { - fn name(&self) -> &'static str { - "node_interaction" - } - - fn on_event(&mut self, event: &FlowEvent, ctx: &mut PluginContext) -> EventResult { - if let FlowEvent::Input(InputEvent::MouseDown(ev)) = event { - if ev.button != MouseButton::Left { - return EventResult::Continue; - } - let mouse_world = ctx.screen_to_world(ev.position); - - if let Some(node_id) = ctx.hit_node(mouse_world) { - ctx.start_interaction(NodeDragInteraction::start( - node_id, - mouse_world, - ev.modifiers.shift, - self.drag_tick_interval, - )); - - return EventResult::Stop; - } else { - ctx.clear_selected_node(); - } - } - - EventResult::Continue - } - - fn priority(&self) -> i32 { - 120 - } -} - -pub struct NodeDragInteraction { - state: NodeDragState, - drag_tick_interval: Duration, - last_drag_command_at: Option, - last_node_drag_tick_at: Option, -} - -enum NodeDragState { - Pending { - node_id: NodeId, - start_mouse: Point, - shift: bool, - }, - Draging { - start_mouse: Point, - start_positions: Vec<(NodeId, Point)>, - /// Stable for this drag; cheap to [`Arc::clone`] into each [`NodeDragEvent::Tick`]. - dragged_ids: Arc<[NodeId]>, - }, -} - -impl NodeDragInteraction { - fn start( - node_id: NodeId, - start_mouse: Point, - shift: bool, - drag_tick_interval: Duration, - ) -> Self { - Self { - state: NodeDragState::Pending { - node_id, - start_mouse, - shift, - }, - drag_tick_interval, - last_drag_command_at: None, - last_node_drag_tick_at: None, - } - } -} - -impl Interaction for NodeDragInteraction { - fn on_mouse_move( - &mut self, - ev: &gpui::MouseMoveEvent, - ctx: &mut PluginContext, - ) -> crate::canvas::InteractionResult { - match &self.state { - NodeDragState::Pending { - node_id, - start_mouse, - .. - } => { - let delta = ctx.screen_to_world(ev.position) - *start_mouse; - if delta.x.abs() > DRAG_THRESHOLD || delta.y.abs() > DRAG_THRESHOLD { - let mut nodes = vec![]; - - if ctx.graph.selected_node().contains(node_id) { - for id in ctx.graph.selected_node() { - if let Some(node) = ctx.nodes().get(id) { - nodes.push((*id, node.point())); - } - } - } else if let Some(node) = ctx.nodes().get(node_id) { - nodes.push((*node_id, node.point())); - } - let dragged_ids: Arc<[NodeId]> = - nodes.iter().map(|(id, _)| *id).collect::>().into(); - self.state = NodeDragState::Draging { - start_mouse: ev.position, - start_positions: nodes, - dragged_ids: Arc::clone(&dragged_ids), - }; - ctx.shared_state.insert(ActiveNodeDrag(dragged_ids)); - - ctx.notify(); - } - } - NodeDragState::Draging { - start_mouse, - start_positions, - dragged_ids, - } => { - let dx = ctx.screen_length_to_world(ev.position.x - start_mouse.x); - let dy = ctx.screen_length_to_world(ev.position.y - start_mouse.y); - for (id, point) in start_positions.iter() { - if let Some(node) = ctx.get_node_mut(id) { - node.set_position(point.x + dx, point.y + dy); - } - } - - let now = Instant::now(); - - if ctx.has_sync_plugin() { - let should_command = self - .last_drag_command_at - .map(|t| now.duration_since(t) >= DRAG_COMMAND_INTERVAL) - .unwrap_or(true); - if should_command { - ctx.execute_command(DragNodesCommand::new(start_positions, ctx)); - self.last_drag_command_at = Some(now); - } - } - - let should_tick = self - .last_node_drag_tick_at - .map(|t| now.duration_since(t) >= self.drag_tick_interval) - .unwrap_or(true); - if should_tick { - self.last_node_drag_tick_at = Some(now); - ctx.emit(FlowEvent::custom(NodeDragEvent::Tick(Arc::clone( - dragged_ids, - )))); - } else { - ctx.notify(); - } - } - } - InteractionResult::Continue - } - fn on_mouse_up( - &mut self, - _ev: &gpui::MouseUpEvent, - ctx: &mut PluginContext, - ) -> crate::canvas::InteractionResult { - ctx.shared_state.remove::(); - match &self.state { - NodeDragState::Pending { node_id, shift, .. } => { - ctx.emit(FlowEvent::custom(NodeDragEvent::End)); - ctx.execute_command(SelecteNodeCommand::new(*node_id, *shift, ctx)); - InteractionResult::End - } - NodeDragState::Draging { - start_positions, .. - } => { - ctx.emit(FlowEvent::custom(NodeDragEvent::End)); - ctx.execute_command(DragNodesCommand::new(start_positions, ctx)); - InteractionResult::End - } - } - } - fn render(&self, ctx: &mut crate::plugin::RenderContext) -> Option { - match &self.state { - NodeDragState::Draging { dragged_ids, .. } => Some(super::render_node_cards( - ctx, - dragged_ids.as_ref(), - "draging-node-cards", - )), - NodeDragState::Pending { .. } => None, - } - } -} diff --git a/crates/ferrum-flow/src/plugins/node/mod.rs b/crates/ferrum-flow/src/plugins/node/mod.rs deleted file mode 100644 index 0bfc77d29f..0000000000 --- a/crates/ferrum-flow/src/plugins/node/mod.rs +++ /dev/null @@ -1,131 +0,0 @@ -mod command; -mod drag_events; -mod interaction; - -pub use command::DragNodesCommand; -pub use drag_events::{ActiveNodeDrag, NODE_DRAG_TICK_INTERVAL, NodeDragEvent}; -use gpui::{Element as _, ElementId, InteractiveElement as _, ParentElement, div}; -pub use interaction::NodeInteractionPlugin; - -/// Renders the given nodes (and their ports) like [`NodePlugin`], for use on the interaction overlay. -pub(super) fn render_node_cards( - ctx: &mut RenderContext, - node_ids: &[crate::NodeId], - id: &'static str, -) -> gpui::AnyElement { - ctx.cache_port_offset_with_nodes(node_ids); - let list = node_ids.iter().filter_map(|node_id| { - let node = ctx.graph.nodes().get(node_id)?; - let render = ctx.renderers.get(node.renderer_key()); - - let node_render = render.render(node, ctx); - - let port_ids: Vec = ctx.cached_port_ids_for_node(node_id).collect(); - let ports = port_ids.iter().filter_map(|port_id| { - let port = ctx.graph.get_port(port_id)?; - render.port_render(node, port, ctx) - }); - - Some( - div() - .id(ElementId::Uuid(*node_id.as_uuid())) - .child(node_render) - .children(ports), - ) - }); - - div().id(id).children(list).into_any() -} - -use std::sync::Arc; - -use crate::NodeId; -use crate::plugin::{Plugin, RenderContext}; -use crate::viewport::ViewportVisibilityCacheKey; - -/// Invalidates [`NodePlugin::static_layer_node_ids`] when the viewport changes **or** the active -/// node-drag overlay set changes ([`ActiveNodeDrag`] `Arc` identity + length). -#[derive(Clone, Copy, Debug, PartialEq)] -struct NodeStaticLayerCacheKey { - viewport: ViewportVisibilityCacheKey, - nodes_len: usize, - node_order_len: usize, - node_order_tail: Option, - /// `None` when not dragging; else [`Arc::as_ptr`] + len of the shared drag id list. - drag_arc: Option<(usize, usize)>, -} - -impl NodeStaticLayerCacheKey { - fn from_render_ctx(ctx: &RenderContext) -> Self { - let drag = ctx.get_shared_state::(); - Self { - viewport: ctx.viewport().visibility_cache_key(), - nodes_len: ctx.graph.nodes().len(), - node_order_len: ctx.graph.node_order().len(), - node_order_tail: ctx - .graph - .node_order() - .last() - .map(|id| id.as_uuid().as_u128()), - drag_arc: drag.map(|d| { - let p = Arc::as_ptr(&d.0); - (p.cast::() as usize, d.0.len()) - }), - } - } -} - -pub struct NodePlugin { - static_layer_cache_key: Option, - /// Viewport-visible nodes for the static [`RenderLayer::Nodes`] layer, already excluding - /// [`ActiveNodeDrag`] ids (those render on the interaction overlay). - static_layer_node_ids: Vec, -} - -impl NodePlugin { - pub fn new() -> Self { - Self { - static_layer_cache_key: None, - static_layer_node_ids: Vec::new(), - } - } -} - -impl Default for NodePlugin { - fn default() -> Self { - Self::new() - } -} - -impl Plugin for NodePlugin { - fn name(&self) -> &'static str { - "node" - } - fn priority(&self) -> i32 { - 60 - } - fn render_layer(&self) -> crate::plugin::RenderLayer { - crate::plugin::RenderLayer::Nodes - } - fn render(&mut self, ctx: &mut RenderContext) -> Option { - let key = NodeStaticLayerCacheKey::from_render_ctx(ctx); - if self.static_layer_cache_key != Some(key) { - self.static_layer_cache_key = Some(key); - let active = ctx.get_shared_state::(); - self.static_layer_node_ids = ctx - .graph - .node_order() - .iter() - .filter(|node_id| ctx.is_node_visible(node_id)) - .filter(|node_id| !active.is_some_and(|d| d.0.contains(node_id))) - .copied() - .collect(); - } - - Some(render_node_cards( - ctx, - &self.static_layer_node_ids, - "static-layer-node-cards", - )) - } -} diff --git a/crates/ferrum-flow/src/plugins/port/command.rs b/crates/ferrum-flow/src/plugins/port/command.rs deleted file mode 100644 index fed682bfec..0000000000 --- a/crates/ferrum-flow/src/plugins/port/command.rs +++ /dev/null @@ -1,158 +0,0 @@ -use crate::{Edge, GraphOp, Node, Port, canvas::Command}; - -pub struct CreateEdge { - edge: Edge, -} - -impl CreateEdge { - pub fn new(edge: Edge) -> Self { - Self { edge } - } -} - -impl Command for CreateEdge { - fn name(&self) -> &'static str { - "create_edge" - } - fn execute(&mut self, ctx: &mut crate::canvas::CommandContext) { - ctx.add_edge(self.edge.clone()); - } - fn undo(&mut self, ctx: &mut crate::canvas::CommandContext) { - ctx.remove_edge(&self.edge.id); - } - - fn to_ops(&self, _ctx: &mut crate::CommandContext) -> Vec { - vec![GraphOp::AddEdge(self.edge.clone())] - } -} - -pub struct CreateNode { - node: Node, -} - -impl CreateNode { - pub fn new(node: Node) -> Self { - Self { node } - } -} - -impl Command for CreateNode { - fn name(&self) -> &'static str { - "create_node" - } - fn execute(&mut self, ctx: &mut crate::canvas::CommandContext) { - ctx.add_node(self.node.clone()); - } - fn to_ops(&self, _ctx: &mut crate::CommandContext) -> Vec { - vec![ - GraphOp::AddNode(self.node.clone()), - GraphOp::NodeOrderInsert { id: self.node.id() }, - ] - } - fn undo(&mut self, ctx: &mut crate::canvas::CommandContext) { - ctx.remove_node(&self.node.id()); - } -} - -pub struct CreatePort { - port: Port, -} - -impl CreatePort { - pub fn new(port: Port) -> Self { - Self { port } - } -} - -impl Command for CreatePort { - fn name(&self) -> &'static str { - "create_port" - } - fn execute(&mut self, ctx: &mut crate::canvas::CommandContext) { - ctx.add_port(self.port.clone()); - ctx.port_offset_cache.clear_node(&self.port.node_id()); - } - fn to_ops(&self, _ctx: &mut crate::CommandContext) -> Vec { - vec![GraphOp::AddPort(self.port.clone())] - } - fn undo(&mut self, ctx: &mut crate::canvas::CommandContext) { - let node_id = self.port.node_id(); - ctx.remove_port(&self.port.id()); - ctx.port_offset_cache.clear_node(&node_id); - } -} - -#[cfg(test)] -mod command_interop_tests { - use serde_json::json; - - use crate::{ - CreateEdge, CreateNode, CreatePort, Graph, PortBuilder, PortKind, PortPosition, PortType, - command_interop::assert_command_interop, - }; - - #[test] - fn create_node_command_interop() { - let mut base = Graph::new(); - let (node, _ports, _) = base - .create_node("x") - .position(100.0, 80.0) - .data(json!({ "k": "v" })) - .build_raw(); - - assert_command_interop( - &base, - || Box::new(CreateNode::new(node.clone())), - "CreateNode", - ); - } - - #[test] - fn create_port_command_interop() { - let mut base = Graph::new(); - let node_id = base.create_node("x").position(0.0, 0.0).build().unwrap(); - let port = PortBuilder::new(base.next_port_id()) - .kind(PortKind::Output) - .node_id(node_id) - .index(0) - .position(PortPosition::Right) - .size(12.0, 12.0) - .port_type(PortType::Any) - .build(); - - assert_command_interop( - &base, - || Box::new(CreatePort::new(port.clone())), - "CreatePort", - ); - } - - #[test] - fn create_edge_command_interop() { - let mut base = Graph::new(); - let n1 = base - .create_node("a") - .position(0.0, 0.0) - .output() - .build() - .unwrap(); - let n2 = base - .create_node("b") - .position(100.0, 0.0) - .input() - .build() - .unwrap(); - let n1_node = base.get_node(&n1).expect("source node exists"); - let n2_node = base.get_node(&n2).expect("target node exists"); - let edge = base - .new_edge() - .source(n1_node.outputs()[0]) - .target(n2_node.inputs()[0]); - - assert_command_interop( - &base, - || Box::new(CreateEdge::new(edge.clone())), - "CreateEdge", - ); - } -} diff --git a/crates/ferrum-flow/src/plugins/port/interaction.rs b/crates/ferrum-flow/src/plugins/port/interaction.rs deleted file mode 100644 index 880eb3659e..0000000000 --- a/crates/ferrum-flow/src/plugins/port/interaction.rs +++ /dev/null @@ -1,458 +0,0 @@ -use std::{collections::HashSet, sync::Arc}; - -use gpui::{Bounds, Element, MouseButton, Pixels, Point, Styled as _, canvas, px, rgb}; - -use crate::{ - DefaultEdgeValidator, EdgeValidator, Graph, PortId, PortKind, PortPosition, ToastMessage, - canvas::Interaction, - plugin::{FlowEvent, InputEvent, Plugin, RenderContext}, - plugins::port::{edge_bezier, filled_disc_path, port_screen_big_bounds, port_screen_bounds}, -}; - -use super::command::CreateEdge; - -/// Dangling link from a port to a world-space endpoint (shown with a dot until the user clicks it). -#[derive(Clone, Copy)] -struct PendingPortLink { - source_port: PortId, - end_world: Point, -} - -/// Internal: interaction finished on empty canvas — queue for [`PortInteractionPlugin`]. -#[derive(Clone, Copy)] -struct PendingLinkCommitted { - source_port: PortId, - end_world: Point, -} - -pub struct PortInteractionPlugin { - pending: Option, - validator: Arc, -} - -impl Default for PortInteractionPlugin { - fn default() -> Self { - Self::new() - } -} - -impl PortInteractionPlugin { - pub fn new() -> Self { - Self { - pending: None, - validator: Arc::new(DefaultEdgeValidator), - } - } - - pub fn validator(mut self, validator: impl EdgeValidator + 'static) -> Self { - self.validator = Arc::new(validator); - self - } - - fn facing_position(p: PortPosition) -> PortPosition { - match p { - PortPosition::Left => PortPosition::Right, - PortPosition::Right => PortPosition::Left, - PortPosition::Top => PortPosition::Bottom, - PortPosition::Bottom => PortPosition::Top, - } - } - - fn pending_dot_contains_screen( - ctx: &crate::plugin::PluginContext, - end_world: Point, - screen: Point, - ) -> bool { - let c = ctx.world_to_screen(end_world); - let dx: f32 = (screen.x - c.x).into(); - let dy: f32 = (screen.y - c.y).into(); - let rf: f32 = px(10.0).into(); - dx * dx + dy * dy <= rf * rf - } - - fn finish_pending_link(&mut self, ctx: &mut crate::plugin::PluginContext, p: PendingPortLink) { - let Some(source) = ctx.graph.get_port(&p.source_port).cloned() else { - return; - }; - - let mut builder = ctx.create_node(""); - builder = match source.kind() { - PortKind::Output => builder.input(), - PortKind::Input => builder.output(), - }; - - let (mut new_node, new_ports, _) = builder.build_raw(); - - let Some(connect_port) = (match source.kind() { - PortKind::Output => new_ports.iter().find(|p| p.kind() == PortKind::Input), - PortKind::Input => new_ports.iter().find(|p| p.kind() == PortKind::Output), - }) else { - return; - }; - - let mut scratch = Graph::new(); - scratch.add_node(new_node.clone()); - for port in &new_ports { - scratch.add_port(port.clone()); - } - let offset = ctx.port_world_offset_relative(&scratch, &new_node, connect_port); - new_node.set_position_with_point(Point::new( - p.end_world.x - offset.x, - p.end_world.y - offset.y, - )); - - let edge = match source.kind() { - PortKind::Output => { - let Some(in_port) = new_node.inputs().first().copied() else { - return; - }; - ctx.new_edge().source(p.source_port).target(in_port) - } - PortKind::Input => { - let Some(out_port) = new_node.outputs().first().copied() else { - return; - }; - ctx.new_edge().source(out_port).target(p.source_port) - } - }; - - ctx.execute_command(super::command::CreateNode::new(new_node)); - for port in new_ports { - ctx.execute_command(super::command::CreatePort::new(port)); - } - - ctx.execute_command(CreateEdge::new(edge)); - } - - #[allow(clippy::too_many_arguments)] - fn paint_wire_and_dot( - win: &mut gpui::Window, - origin: Point, - start: Point, - end: Point, - start_position: PortPosition, - target_position: PortPosition, - viewport: &crate::Viewport, - line_rgb: u32, - dot_rgb: u32, - ) { - if let Ok(path) = edge_bezier( - start + origin, - start_position, - target_position, - end + origin, - viewport, - ) { - win.paint_path(path, rgb(line_rgb)); - } - if let Ok(dot) = filled_disc_path(end + origin, px(6.0)) { - win.paint_path(dot, rgb(dot_rgb)); - } - } -} - -impl Plugin for PortInteractionPlugin { - fn name(&self) -> &'static str { - "port_interaction" - } - - fn on_event( - &mut self, - event: &FlowEvent, - ctx: &mut crate::plugin::PluginContext, - ) -> crate::plugin::EventResult { - if let Some(p) = event.as_custom::() { - self.pending = Some(PendingPortLink { - source_port: p.source_port, - end_world: p.end_world, - }); - return crate::plugin::EventResult::Stop; - } - - if let FlowEvent::Input(InputEvent::MouseDown(ev)) = event { - if ev.button != MouseButton::Left { - return crate::plugin::EventResult::Continue; - } - if let Some(pend) = self.pending - && Self::pending_dot_contains_screen(ctx, pend.end_world, ev.position) - { - self.pending = None; - self.finish_pending_link(ctx, pend); - return crate::plugin::EventResult::Stop; - } - - let visible_nodes: HashSet<_> = ctx - .graph - .nodes() - .iter() - .filter(|(_, node)| ctx.is_node_visible_node(node)) - .map(|(id, _)| *id) - .collect(); - let candidate_ports: Vec = ctx - .graph - .ports() - .iter() - .filter(|(_, port)| visible_nodes.contains(&port.node_id())) - .map(|(_, port)| (port.id(), port.position())) - .filter_map(|(id, position)| { - let bounds = port_screen_bounds(id, ctx)?; - let big_bounds = port_screen_big_bounds(id, ctx)?; - Some(PortHitCandidate { - id, - position, - bounds, - big_bounds, - }) - }) - .collect(); - - let mouse_world = ctx.screen_to_world(ev.position); - let port_hit = candidate_ports - .iter() - .find(|c| c.bounds.contains(&mouse_world)) - .map(|c| (c.id, c.position)); - - if let Some((port_id, position)) = port_hit { - self.pending = None; - ctx.start_interaction(PortConnecting { - port_id, - position, - target_position: PortPosition::Left, - candidate_ports, - mouse: Some(ev.position), - validator: self.validator.clone(), - validation_error: None, - hovered_port: None, - }); - return crate::plugin::EventResult::Stop; - } - - if self.pending.take().is_some() { - ctx.notify(); - } - } - - crate::plugin::EventResult::Continue - } - - fn priority(&self) -> i32 { - 125 - } - - fn render(&mut self, ctx: &mut RenderContext) -> Option { - let p = self.pending.as_ref()?; - let start = ctx.port_screen_center_by_port_id(p.source_port)?; - let end = ctx.world_to_screen(p.end_world); - let source_port = ctx.graph.get_port(&p.source_port)?; - let start_position = source_port.position(); - let target_position = Self::facing_position(start_position); - let viewport = ctx.viewport().clone(); - let line_rgb = ctx.theme.port_preview_line; - let dot_rgb = ctx.theme.port_preview_dot; - - Some( - canvas( - move |_, _, _| (start_position, target_position, viewport, line_rgb, dot_rgb), - move |bounds, (sp, tp, vp, lr, dr), win, _| { - Self::paint_wire_and_dot(win, bounds.origin, start, end, sp, tp, &vp, lr, dr); - }, - ) - .absolute() - .size_full() - .into_any(), - ) - } - - fn render_layer(&self) -> crate::plugin::RenderLayer { - crate::plugin::RenderLayer::Interaction - } -} - -struct PortConnecting { - port_id: PortId, - position: PortPosition, - target_position: PortPosition, - /// Visible port candidates captured when the interaction starts with precomputed hit bounds. - candidate_ports: Vec, - /// Cursor in **screen** space (matches port screen center / bezier end). - mouse: Option>, - validator: Arc, - /// Validation state for current drag target. `Some(Err)` means invalid link preview. - validation_error: Option<()>, - /// Candidate port currently hovered by cursor (if any). - hovered_port: Option, -} - -#[derive(Clone, Copy)] -struct PortHitCandidate { - id: PortId, - position: PortPosition, - bounds: Bounds, - big_bounds: Bounds, -} - -impl Interaction for PortConnecting { - fn on_mouse_move( - &mut self, - event: &gpui::MouseMoveEvent, - ctx: &mut crate::plugin::PluginContext, - ) -> crate::canvas::InteractionResult { - self.mouse = Some(event.position); - let mouse_world = ctx.screen_to_world(event.position); - self.validation_error = None; - self.hovered_port = None; - if let Some(candidate) = self - .candidate_ports - .iter() - .find(|c| c.big_bounds.contains(&mouse_world)) - { - let port_id = candidate.id; - if port_id != self.port_id { - self.target_position = candidate.position; - self.hovered_port = Some(port_id); - - let Some(source_port) = ctx.graph.get_port(&self.port_id) else { - ctx.notify(); - return crate::canvas::InteractionResult::Continue; - }; - let Some(target_port) = ctx.graph.get_port(&port_id) else { - ctx.notify(); - return crate::canvas::InteractionResult::Continue; - }; - - let (source_port, target_port) = match (source_port.kind(), target_port.kind()) { - (PortKind::Input, PortKind::Output) => (target_port, source_port), - _ => (source_port, target_port), - }; - - if self - .validator - .validate(source_port, target_port, ctx) - .is_err() - { - self.validation_error = Some(()); - } - } - } - ctx.notify(); - crate::canvas::InteractionResult::Continue - } - - fn on_mouse_up( - &mut self, - ev: &gpui::MouseUpEvent, - ctx: &mut crate::plugin::PluginContext, - ) -> crate::canvas::InteractionResult { - let mouse_world = ctx.screen_to_world(ev.position); - if let Some(candidate) = self - .candidate_ports - .iter() - .find(|c| c.bounds.contains(&mouse_world)) - { - let port_id = candidate.id; - let Some(target_port) = ctx.graph.get_port(&port_id) else { - return crate::canvas::InteractionResult::End; - }; - let Some(source_port) = ctx.graph.get_port(&self.port_id) else { - return crate::canvas::InteractionResult::End; - }; - - let (source_port, target_port) = match (source_port.kind(), target_port.kind()) { - (PortKind::Input, PortKind::Output) => (target_port, source_port), - _ => (source_port, target_port), - }; - - match self.validator.validate(source_port, target_port, ctx) { - Ok(_) => { - let edge = ctx - .new_edge() - .source(source_port.id()) - .target(target_port.id()); - ctx.execute_command(CreateEdge::new(edge)); - } - Err(err) => { - ctx.emit(FlowEvent::custom(ToastMessage::error( - err.message().to_string(), - ))); - } - } - - return crate::canvas::InteractionResult::End; - } - - ctx.emit(FlowEvent::custom(PendingLinkCommitted { - source_port: self.port_id, - end_world: mouse_world, - })); - crate::canvas::InteractionResult::End - } - - fn render(&self, ctx: &mut RenderContext) -> Option { - let mouse = self.mouse?; - let start = ctx.port_screen_center_by_port_id(self.port_id)?; - let position = self.position; - let target_position = self.target_position; - let viewport = ctx.viewport().clone(); - let has_validation_error = self.validation_error.is_some(); - let line_rgb = if has_validation_error { - ctx.theme.error - } else { - ctx.theme.port_preview_line - }; - let dot_rgb = if has_validation_error { - ctx.theme.error - } else { - ctx.theme.port_preview_dot - }; - let target_highlight = if has_validation_error { - self.hovered_port.and_then(|port_id| { - let port = ctx.graph.get_port(&port_id)?; - let center = ctx.port_screen_center_by_port_id(port_id)?; - let size = *port.size_ref(); - let width: f32 = (size.width * ctx.viewport().zoom()).into(); - let height: f32 = (size.height * ctx.viewport().zoom()).into(); - let radius = px(width.min(height) / 2.0); - Some((center, radius)) - }) - } else { - None - }; - - Some( - canvas( - move |_, _, _| { - ( - position, - target_position, - viewport, - line_rgb, - dot_rgb, - target_highlight, - ) - }, - move |bounds, (position, target_position, viewport, lr, dr, th), win, _| { - let origin = bounds.origin; - PortInteractionPlugin::paint_wire_and_dot( - win, - origin, - start, - mouse, - position, - target_position, - &viewport, - lr, - dr, - ); - if let Some((center, radius)) = th - && let Ok(dot) = filled_disc_path(center + origin, radius) - { - win.paint_path(dot, rgb(lr)); - } - }, - ) - .absolute() - .size_full() - .into_any(), - ) - } -} diff --git a/crates/ferrum-flow/src/plugins/port/mod.rs b/crates/ferrum-flow/src/plugins/port/mod.rs deleted file mode 100644 index 193403abad..0000000000 --- a/crates/ferrum-flow/src/plugins/port/mod.rs +++ /dev/null @@ -1,16 +0,0 @@ -mod interaction; -mod utils; - -pub use interaction::PortInteractionPlugin; - -mod command; -mod validator; - -pub use command::{CreateEdge, CreateNode, CreatePort}; -pub use validator::{ - DefaultEdgeValidator, EdgeValidationError, EdgeValidationErrorCode, EdgeValidator, -}; - -#[allow(deprecated, unused_imports)] -pub use utils::port_screen_position; -pub use utils::{edge_bezier, filled_disc_path, port_screen_big_bounds, port_screen_bounds}; diff --git a/crates/ferrum-flow/src/plugins/port/utils.rs b/crates/ferrum-flow/src/plugins/port/utils.rs deleted file mode 100644 index 973a8f787b..0000000000 --- a/crates/ferrum-flow/src/plugins/port/utils.rs +++ /dev/null @@ -1,81 +0,0 @@ -use gpui::{Bounds, Path, PathBuilder, Pixels, Point, Size, px}; - -use crate::{PortId, PortPosition, RenderContext, Viewport}; - -#[deprecated(note = "use `ctx.port_screen_center_by_port_id(port_id)`")] -#[allow(dead_code)] // kept for re-export; callers should migrate to `RenderContext` methods -pub fn port_screen_position(port_id: PortId, ctx: &RenderContext) -> Option> { - ctx.port_screen_center_by_port_id(port_id) -} - -pub fn port_screen_bounds( - port_id: PortId, - ctx: &crate::plugin::PluginContext, -) -> Option> { - let port = &ctx.graph.get_port(&port_id)?; - let node = &ctx.nodes().get(&port.node_id())?; - - let node_pos = node.point(); - - let offset = ctx.port_offset_cached(&port.node_id(), &port_id)?; - let size = *port.size_ref(); - - Some(Bounds::new( - node_pos + offset - Point::new(size.width / 2.0, size.height / 2.0), - size, - )) -} - -pub fn port_screen_big_bounds( - port_id: PortId, - ctx: &crate::plugin::PluginContext, -) -> Option> { - let mut bounds = port_screen_bounds(port_id, ctx)?; - - let offset_width = px(15.0) - bounds.size.width / 2.0; - let offset_height = px(15.0) - bounds.size.height / 2.0; - - bounds.origin -= Point::new(offset_width, offset_height); - - bounds.size = Size { - width: px(30.0), - height: px(30.0), - }; - - Some(bounds) -} - -/// Filled circle in screen space (for dangling-connection endpoint marker). -pub fn filled_disc_path( - center: Point, - radius: Pixels, -) -> Result, anyhow::Error> { - let r: f32 = radius.into(); - let cx: f32 = center.x.into(); - let cy: f32 = center.y.into(); - const SEGMENTS: usize = 28; - let mut pts: Vec> = Vec::with_capacity(SEGMENTS); - for i in 0..SEGMENTS { - let t = i as f32 / SEGMENTS as f32 * std::f32::consts::TAU; - pts.push(Point::new(px(cx + r * t.cos()), px(cy + r * t.sin()))); - } - let mut pb = PathBuilder::fill(); - pb.add_polygon(&pts, true); - pb.build() -} - -pub fn edge_bezier( - start: Point, - start_position: PortPosition, - end_poisition: PortPosition, - end: Point, - viewport: &Viewport, -) -> Result, anyhow::Error> { - let control_a = viewport.edge_control_point(start, start_position); - let control_b = viewport.edge_control_point(end, end_poisition); - let mut line = PathBuilder::stroke(px(1.0)); - line.move_to(start); - line.cubic_bezier_to(end, control_a, control_b); - - line.build() -} diff --git a/crates/ferrum-flow/src/plugins/port/validator.rs b/crates/ferrum-flow/src/plugins/port/validator.rs deleted file mode 100644 index 75f9126463..0000000000 --- a/crates/ferrum-flow/src/plugins/port/validator.rs +++ /dev/null @@ -1,111 +0,0 @@ -use std::fmt::Display; - -use crate::{PluginContext, Port, PortKind}; - -/// Validates whether an edge may be created between two ports. -pub trait EdgeValidator: Send + Sync { - fn validate( - &self, - from: &Port, - to: &Port, - ctx: &PluginContext, - ) -> Result<(), EdgeValidationError>; -} - -#[derive(Debug, Clone)] -pub struct EdgeValidationError { - code: EdgeValidationErrorCode, - message: String, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum EdgeValidationErrorCode { - /// The two ports are not one output and one input. - KindMismatch, - /// Both ports belong to the same node. - SameNode, - /// Port types are incompatible (reserved for stricter validators). - TypeMismatch, - /// Target input already has a connection (reserved for stricter validators). - AlreadyConnected, - /// Plugin-specific failure reason. - Custom(String), -} - -impl EdgeValidationError { - pub fn new(code: EdgeValidationErrorCode, message: String) -> Self { - Self { code, message } - } - - pub fn kind_mismatch(message: String) -> Self { - Self::new(EdgeValidationErrorCode::KindMismatch, message) - } - - pub fn same_node(message: String) -> Self { - Self::new(EdgeValidationErrorCode::SameNode, message) - } - - pub fn type_mismatch(message: String) -> Self { - Self::new(EdgeValidationErrorCode::TypeMismatch, message) - } - - pub fn already_connected(message: String) -> Self { - Self::new(EdgeValidationErrorCode::AlreadyConnected, message) - } - - pub fn custom(ty: String, message: String) -> Self { - Self::new(EdgeValidationErrorCode::Custom(ty), message) - } - - pub fn code(&self) -> &EdgeValidationErrorCode { - &self.code - } - - pub fn message(&self) -> &str { - &self.message - } -} - -impl Display for EdgeValidationErrorCode { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - EdgeValidationErrorCode::KindMismatch => write!(f, "KindMismatch"), - EdgeValidationErrorCode::SameNode => write!(f, "SameNode"), - EdgeValidationErrorCode::TypeMismatch => write!(f, "TypeMismatch"), - EdgeValidationErrorCode::AlreadyConnected => write!(f, "AlreadyConnected"), - EdgeValidationErrorCode::Custom(ty) => write!(f, "Custom({})", ty), - } - } -} - -/// Permissive default: requires one output and one input on different nodes; ignores -/// `port_type` and does not check for duplicate edges. -#[derive(Debug, Default, Clone, Copy)] -pub struct DefaultEdgeValidator; - -impl EdgeValidator for DefaultEdgeValidator { - fn validate( - &self, - from: &Port, - to: &Port, - _ctx: &PluginContext, - ) -> Result<(), EdgeValidationError> { - if from.node_id() == to.node_id() { - return Err(EdgeValidationError::same_node( - "Cannot connect two ports on the same node.".into(), - )); - } - - let one_output_one_input = matches!( - (from.kind(), to.kind()), - (PortKind::Output, PortKind::Input) | (PortKind::Input, PortKind::Output) - ); - if !one_output_one_input { - return Err(EdgeValidationError::kind_mismatch( - "A connection must be between an output port and an input port.".into(), - )); - } - - Ok(()) - } -} diff --git a/crates/ferrum-flow/src/plugins/select_all_viewport.rs b/crates/ferrum-flow/src/plugins/select_all_viewport.rs deleted file mode 100644 index 3e8dbab2a2..0000000000 --- a/crates/ferrum-flow/src/plugins/select_all_viewport.rs +++ /dev/null @@ -1,68 +0,0 @@ -use std::collections::HashSet; - -use crate::plugin::{FlowEvent, Plugin, PluginContext, primary_platform_modifier}; - -/// Select every node and edge that intersects the current window viewport (⌘A / Ctrl+A). -pub struct SelectAllViewportPlugin; - -impl SelectAllViewportPlugin { - pub fn new() -> Self { - Self - } -} - -impl Default for SelectAllViewportPlugin { - fn default() -> Self { - Self::new() - } -} - -fn select_visible(ctx: &mut PluginContext) { - let visible_nodes: HashSet<_> = ctx - .graph - .node_order() - .iter() - .filter(|id| ctx.is_node_visible(id)) - .copied() - .collect(); - - let visible_edges: HashSet<_> = ctx - .graph - .edges_values() - .filter(|e| ctx.is_edge_visible(e)) - .map(|e| e.id) - .collect(); - - ctx.graph.set_selected_node(visible_nodes); - ctx.graph.set_selected_edge(visible_edges); -} - -pub(crate) fn select_all_in_viewport(ctx: &mut PluginContext) { - select_visible(ctx); -} - -impl Plugin for SelectAllViewportPlugin { - fn name(&self) -> &'static str { - "select_all_viewport" - } - - fn priority(&self) -> i32 { - 93 - } - - fn on_event( - &mut self, - event: &FlowEvent, - ctx: &mut PluginContext, - ) -> crate::plugin::EventResult { - if let FlowEvent::Input(crate::plugin::InputEvent::KeyDown(ev)) = event - && primary_platform_modifier(ev) - && ev.keystroke.key == "a" - { - select_visible(ctx); - ctx.notify(); - return crate::plugin::EventResult::Stop; - } - crate::plugin::EventResult::Continue - } -} diff --git a/crates/ferrum-flow/src/plugins/selection/mod.rs b/crates/ferrum-flow/src/plugins/selection/mod.rs deleted file mode 100644 index b21ea004fb..0000000000 --- a/crates/ferrum-flow/src/plugins/selection/mod.rs +++ /dev/null @@ -1,340 +0,0 @@ -use std::collections::HashMap; -use std::time::{Duration, Instant}; - -use gpui::{ - AnyElement, Bounds, Element, MouseButton, MouseMoveEvent, MouseUpEvent, Pixels, Point, Size, - Styled, div, px, rgb, rgba, -}; - -use crate::{ - FlowTheme, NodeId, - canvas::{Interaction, InteractionResult}, - plugin::{ - EventResult, FlowEvent, InputEvent, Plugin, PluginContext, RenderContext, RenderLayer, - }, -}; - -const DRAG_THRESHOLD: Pixels = px(2.0); -const DRAG_COMMAND_INTERVAL: Duration = Duration::from_millis(50); - -pub struct SelectionPlugin { - selected: Option, -} - -struct Selected { - bounds: Bounds, - nodes: HashMap>, -} - -impl SelectionPlugin { - pub fn new() -> Self { - Self { selected: None } - } -} - -impl Default for SelectionPlugin { - fn default() -> Self { - Self::new() - } -} - -impl Plugin for SelectionPlugin { - fn name(&self) -> &'static str { - "selection" - } - - fn on_event( - &mut self, - event: &FlowEvent, - ctx: &mut crate::plugin::PluginContext, - ) -> EventResult { - if let FlowEvent::Input(InputEvent::MouseDown(ev)) = event { - if ev.button != MouseButton::Left { - return EventResult::Continue; - } - if !ev.modifiers.shift { - let start = ctx.screen_to_world(ev.position); - if let Some(Selected { bounds, nodes }) = self.selected.take() - && bounds.contains(&start) - { - ctx.start_interaction(SelectionInteraction::start_move(start, bounds, nodes)); - - return EventResult::Stop; - } - - ctx.start_interaction(SelectionInteraction::new(start)); - return EventResult::Stop; - } - } else if let Some(SelectedEvent { bounds, nodes }) = event.as_custom() { - self.selected = if nodes.is_empty() { - None - } else { - Some(Selected { - bounds: *bounds, - nodes: nodes.clone(), - }) - }; - return EventResult::Stop; - } else if let FlowEvent::Input(InputEvent::Hover(false)) = event { - self.selected = None; - } - EventResult::Continue - } - - fn priority(&self) -> i32 { - 100 - } - - fn render_layer(&self) -> RenderLayer { - RenderLayer::Selection - } - - fn render(&mut self, ctx: &mut RenderContext) -> Option { - self.selected.as_ref().map(|Selected { bounds, .. }| { - let top_left = ctx.world_to_screen(bounds.origin); - - let size = Size::new( - ctx.world_length_to_screen(bounds.size.width), - ctx.world_length_to_screen(bounds.size.height), - ); - render_rect(Bounds::new(top_left, size), ctx.theme) - }) - } -} - -pub struct SelectionInteraction { - state: SelectionState, - last_drag_command_at: Option, -} - -enum SelectionState { - Pending { - start: Point, - }, - Selecting { - start: Point, - end: Point, - }, - Moving { - start_mouse: Point, - start_bounds: Bounds, - bounds: Bounds, - nodes: HashMap>, - }, -} -struct SelectedEvent { - bounds: Bounds, - nodes: HashMap>, -} - -impl SelectionInteraction { - pub fn new(start: Point) -> Self { - Self { - state: SelectionState::Pending { start }, - last_drag_command_at: None, - } - } - pub fn start_move( - mouse: Point, - bounds: Bounds, - nodes: HashMap>, - ) -> Self { - Self { - state: SelectionState::Moving { - start_mouse: mouse, - start_bounds: bounds, - bounds, - nodes, - }, - last_drag_command_at: None, - } - } -} - -impl Interaction for SelectionInteraction { - fn on_mouse_move(&mut self, ev: &MouseMoveEvent, ctx: &mut PluginContext) -> InteractionResult { - let mouse_world = ctx.screen_to_world(ev.position); - match &mut self.state { - SelectionState::Pending { start } => { - let delta = mouse_world - *start; - - if delta.x.abs() > DRAG_THRESHOLD && delta.y.abs() > DRAG_THRESHOLD { - self.state = SelectionState::Selecting { - start: *start, - end: mouse_world, - }; - - ctx.notify(); - } - } - - SelectionState::Selecting { end, .. } => { - *end = mouse_world; - ctx.notify(); - } - - SelectionState::Moving { - start_mouse, - start_bounds, - bounds, - nodes, - } => { - let delta = mouse_world - *start_mouse; - - for (id, start_pos) in nodes.iter() { - if let Some(node) = ctx.get_node_mut(id) { - node.set_position(start_pos.x + delta.x, start_pos.y + delta.y); - } - } - *bounds = Bounds::new(start_bounds.origin + delta, start_bounds.size); - - if ctx.has_sync_plugin() { - let now = Instant::now(); - let should_command = self - .last_drag_command_at - .map(|t| now.duration_since(t) >= DRAG_COMMAND_INTERVAL) - .unwrap_or(true); - if should_command { - let start_position: Vec<_> = - nodes.iter().map(|(id, point)| (*id, *point)).collect(); - ctx.execute_command(super::node::DragNodesCommand::new( - &start_position, - ctx, - )); - self.last_drag_command_at = Some(now); - } - } - - ctx.notify(); - } - } - - InteractionResult::Continue - } - fn on_mouse_up(&mut self, _ev: &MouseUpEvent, ctx: &mut PluginContext) -> InteractionResult { - match &mut self.state { - SelectionState::Pending { .. } => InteractionResult::End, - - SelectionState::Selecting { start, end } => { - let rect = normalize_rect(*start, *end); - - ctx.clear_selected_node(); - - let mut nodes: HashMap> = HashMap::new(); - let mut min_x = f32::MAX; - let mut min_y = f32::MAX; - let mut max_x = f32::MIN; - let mut max_y = f32::MIN; - - for node in ctx - .graph - .nodes() - .values() - .filter(|node| ctx.is_node_visible_node(node)) - .filter(|node| rect.intersects(&node.bounds())) - { - let (x, y) = node.position(); - let size = *node.size_ref(); - nodes.insert(node.id(), node.point()); - min_x = min_x.min(x.into()); - min_y = min_y.min(y.into()); - max_x = max_x.max((x + size.width).into()); - max_y = max_y.max((y + size.height).into()); - } - - for id in nodes.keys().copied() { - ctx.add_selected_node(id, true); - } - - let bounds = if nodes.is_empty() { - rect - } else { - Bounds::new( - Point::new(px(min_x), px(min_y)), - Size::new(px(max_x - min_x), px(max_y - min_y)), - ) - }; - - ctx.cancel_interaction(); - ctx.emit(FlowEvent::custom(SelectedEvent { bounds, nodes })); - - InteractionResult::End - } - - SelectionState::Moving { bounds, nodes, .. } => { - let bounds = *bounds; - - let mut new_nodes = HashMap::new(); - for (id, _) in nodes.iter() { - ctx.add_selected_node(*id, true); - if let Some(node) = ctx.get_node(id) { - new_nodes.insert(*id, node.point()); - } - } - - let start_position: Vec<_> = - nodes.iter().map(|(id, point)| (*id, *point)).collect(); - - ctx.execute_command(super::node::DragNodesCommand::new(&start_position, ctx)); - - ctx.emit(FlowEvent::custom(SelectedEvent { - bounds, - nodes: new_nodes, - })); - - InteractionResult::End - } - } - } - fn render(&self, ctx: &mut RenderContext) -> Option { - match &self.state { - SelectionState::Selecting { start, end } => { - let rect = normalize_rect(*start, *end); - - let top_left = ctx.world_to_screen(rect.origin); - - let size = Size::new( - ctx.world_length_to_screen(rect.size.width), - ctx.world_length_to_screen(rect.size.height), - ); - - Some(render_rect(Bounds::new(top_left, size), ctx.theme)) - } - - SelectionState::Moving { bounds, .. } => { - let top_left = ctx.world_to_screen(bounds.origin); - - let size = Size::new( - ctx.world_length_to_screen(bounds.size.width), - ctx.world_length_to_screen(bounds.size.height), - ); - Some(render_rect(Bounds::new(top_left, size), ctx.theme)) - } - - _ => None, - } - } -} - -fn normalize_rect(start: Point, end: Point) -> Bounds { - let x = start.x.min(end.x); - let y = start.y.min(end.y); - - let w = (end.x - start.x).abs(); - let h = (end.y - start.y).abs(); - - Bounds::new(Point::new(x, y), Size::new(w, h)) -} - -fn render_rect(bounds: Bounds, theme: &FlowTheme) -> AnyElement { - div() - .absolute() - .left(bounds.origin.x) - .top(bounds.origin.y) - .w(bounds.size.width) - .h(bounds.size.height) - .border(px(1.0)) - .border_color(rgb(theme.selection_rect_border)) - .bg(rgba(theme.selection_rect_fill_rgba)) - .into_any() -} diff --git a/crates/ferrum-flow/src/plugins/snap_guides.rs b/crates/ferrum-flow/src/plugins/snap_guides.rs deleted file mode 100644 index 9630511f58..0000000000 --- a/crates/ferrum-flow/src/plugins/snap_guides.rs +++ /dev/null @@ -1,244 +0,0 @@ -//! Alignment guides while dragging nodes. -//! -//! Subscribes to [`NodeDragEvent`](crate::plugins::node::NodeDragEvent) from -//! [`crate::plugins::NodeInteractionPlugin`] and runs -//! [`compute_alignment_guides`] only here. -//! This keeps [`crate::canvas::InteractionState`] free of overlay-specific fields. - -use std::collections::HashSet; - -use gpui::{AnyElement, Div, Element, ParentElement, Pixels, Point, Styled, div, px, rgb}; - -use crate::{ - Graph, NodeId, - plugin::{EventResult, FlowEvent, Plugin, PluginContext, RenderContext, RenderLayer}, - plugins::node::NodeDragEvent, - theme::FlowTheme, -}; - -/// Screen-space snap distance, converted to world units via `threshold / zoom`. -const SNAP_SCREEN_PX: f32 = 4.0; - -/// World-space lines to draw as alignment guides (full width / height of the canvas view). -#[derive(Debug, Clone, Default)] -struct AlignmentGuides { - pub vertical_x: Vec, - pub horizontal_y: Vec, -} - -fn union_drag_bounds(graph: &Graph, dragged_ids: &[NodeId]) -> Option> { - let mut min_x = f32::MAX; - let mut min_y = f32::MAX; - let mut max_x = f32::MIN; - let mut max_y = f32::MIN; - let mut any = false; - - for id in dragged_ids { - let Some(n) = graph.get_node(id) else { - continue; - }; - any = true; - let b = n.bounds(); - let l: f32 = b.origin.x.into(); - let t: f32 = b.origin.y.into(); - let r: f32 = (b.origin.x + b.size.width).into(); - let bot: f32 = (b.origin.y + b.size.height).into(); - min_x = min_x.min(l); - min_y = min_y.min(t); - max_x = max_x.max(r); - max_y = max_y.max(bot); - } - - if !any { - return None; - } - - Some(gpui::Bounds::new( - Point::new(min_x.into(), min_y.into()), - gpui::Size::new((max_x - min_x).into(), (max_y - min_y).into()), - )) -} - -fn dedup_sorted_coords(mut v: Vec) -> Vec { - v.sort_by(|a, b| f32::total_cmp(&(*a).into(), &(*b).into())); - v.dedup_by(|a, b| { - let af: f32 = (*a).into(); - let bf: f32 = (*b).into(); - af == bf - }); - v -} - -/// Computes alignment guides for the current drag. Skips nodes that are not on-screen and uses an -/// AABB broadphase so distant nodes are not considered. -fn compute_alignment_guides( - ctx: &PluginContext, - dragged_ids: &[NodeId], -) -> Option { - let dragged_set: HashSet<&NodeId> = dragged_ids.iter().collect(); - let thr = ctx.screen_length_to_world(px(SNAP_SCREEN_PX)); - let union = union_drag_bounds(ctx.graph, dragged_ids)?; - let dl = union.origin.x; - let dr = union.origin.x + union.size.width; - let dcx = (dl + dr) * 0.5; - let dt = union.origin.y; - let db = union.origin.y + union.size.height; - let dcy = (dt + db) * 0.5; - - let drag_x_lo = dl - thr; - let drag_x_hi = dr + thr; - let drag_y_lo = dt - thr; - let drag_y_hi = db + thr; - - let drag_xs = [dl, dcx, dr]; - let drag_ys = [dt, dcy, db]; - - let mut ref_x: Vec = Vec::new(); - let mut ref_y: Vec = Vec::new(); - - for (id, node) in ctx.graph.nodes() { - if dragged_set.contains(id) { - continue; - } - if !ctx.is_node_visible_node(node) { - continue; - } - - let b = node.bounds(); - let rl = b.origin.x; - let rr = rl + b.size.width; - let rcx = (rl + rr) * 0.5; - let rt = b.origin.y; - let rb = rt + b.size.height; - let rcy = (rt + rb) * 0.5; - - let can_vertical = !(rr < drag_x_lo || rl > drag_x_hi); - let can_horizontal = !(rb < drag_y_lo || rt > drag_y_hi); - if !can_vertical && !can_horizontal { - continue; - } - - if can_vertical { - ref_x.extend([rl, rcx, rr]); - } - if can_horizontal { - ref_y.extend([rt, rcy, rb]); - } - } - - let mut vertical_x = Vec::new(); - for rx in ref_x { - if drag_xs.iter().any(|dx| (*dx - rx).abs() <= thr) { - vertical_x.push(rx); - } - } - - let mut horizontal_y = Vec::new(); - for ry in ref_y { - if drag_ys.iter().any(|dy| (*dy - ry).abs() <= thr) { - horizontal_y.push(ry); - } - } - - vertical_x = dedup_sorted_coords(vertical_x); - horizontal_y = dedup_sorted_coords(horizontal_y); - - if vertical_x.is_empty() && horizontal_y.is_empty() { - return None; - } - - Some(AlignmentGuides { - vertical_x, - horizontal_y, - }) -} - -pub struct SnapGuidesPlugin { - guides: Option, -} - -impl SnapGuidesPlugin { - pub fn new() -> Self { - Self { guides: None } - } -} - -impl Default for SnapGuidesPlugin { - fn default() -> Self { - Self::new() - } -} - -impl Plugin for SnapGuidesPlugin { - fn name(&self) -> &'static str { - "snap_guides" - } - - fn on_event(&mut self, event: &FlowEvent, ctx: &mut PluginContext) -> EventResult { - if let Some(evt) = event.as_custom::() { - match evt { - NodeDragEvent::Tick(ids) => { - self.guides = compute_alignment_guides(ctx, ids.as_ref()); - ctx.notify(); - } - NodeDragEvent::End => { - self.guides = None; - ctx.notify(); - } - } - } - EventResult::Continue - } - - fn render(&mut self, ctx: &mut RenderContext) -> Option { - let guides = self.guides.as_ref()?; - let wb = ctx.window_bounds()?; - let w = wb.size.width; - let h = wb.size.height; - let theme = ctx.theme; - - let vx = guides.vertical_x.iter().map(|wx| vline(*wx, h, ctx, theme)); - let hy = guides - .horizontal_y - .iter() - .map(|wy| hline(*wy, w, ctx, theme)); - - Some( - div() - .absolute() - .size_full() - .children(vx.chain(hy)) - .into_any(), - ) - } - - fn priority(&self) -> i32 { - 118 - } - - fn render_layer(&self) -> RenderLayer { - RenderLayer::Interaction - } -} - -fn vline(wx: Pixels, win_h: Pixels, ctx: &RenderContext<'_>, theme: &FlowTheme) -> Div { - let sx = ctx.world_to_screen(Point::new(wx, px(0.0))).x; - div() - .absolute() - .left(sx) - .top(px(0.0)) - .w(px(1.0)) - .h(win_h) - .bg(rgb(theme.selection_rect_border)) -} - -fn hline(wy: Pixels, win_w: Pixels, ctx: &RenderContext<'_>, theme: &FlowTheme) -> Div { - let sy = ctx.world_to_screen(Point::new(px(0.0), wy)).y; - div() - .absolute() - .left(px(0.0)) - .top(sy) - .w(win_w) - .h(px(1.0)) - .bg(rgb(theme.selection_rect_border)) -} diff --git a/crates/ferrum-flow/src/plugins/toast.rs b/crates/ferrum-flow/src/plugins/toast.rs deleted file mode 100644 index 71fc983065..0000000000 --- a/crates/ferrum-flow/src/plugins/toast.rs +++ /dev/null @@ -1,172 +0,0 @@ -use std::{ - collections::VecDeque, - time::{Duration, Instant}, -}; - -use gpui::{Element as _, ParentElement as _, Styled as _, div, px, rgb}; - -use crate::{ - FlowTheme, - plugin::{FlowEvent, Plugin, PluginContext, RenderContext}, -}; - -const DEFAULT_TOAST_DURATION: Duration = Duration::from_millis(3000); -const MAX_TOASTS: usize = 4; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ToastLevel { - Info, - Success, - Warning, - Error, -} - -#[derive(Debug, Clone)] -pub struct ToastMessage { - text: String, - level: ToastLevel, - duration: Duration, -} - -impl ToastMessage { - pub fn new(text: impl Into, level: ToastLevel) -> Self { - Self { - text: text.into(), - level, - duration: DEFAULT_TOAST_DURATION, - } - } - - pub fn info(text: impl Into) -> Self { - Self::new(text, ToastLevel::Info) - } - - pub fn success(text: impl Into) -> Self { - Self::new(text, ToastLevel::Success) - } - - pub fn warning(text: impl Into) -> Self { - Self::new(text, ToastLevel::Warning) - } - - pub fn error(text: impl Into) -> Self { - Self::new(text, ToastLevel::Error) - } - - pub fn with_duration(mut self, duration: Duration) -> Self { - self.duration = duration; - self - } -} - -#[derive(Debug, Clone)] -struct ToastItem { - text: String, - level: ToastLevel, - expires_at: Instant, -} - -pub struct ToastPlugin { - queue: VecDeque, -} - -impl Default for ToastPlugin { - fn default() -> Self { - Self::new() - } -} - -impl ToastPlugin { - pub fn new() -> Self { - Self { - queue: VecDeque::new(), - } - } - - fn gc_expired(&mut self) { - let now = Instant::now(); - self.queue.retain(|item| item.expires_at > now); - } - - fn push(&mut self, msg: ToastMessage) { - self.gc_expired(); - self.queue.push_back(ToastItem { - text: msg.text, - level: msg.level, - expires_at: Instant::now() + msg.duration, - }); - while self.queue.len() > MAX_TOASTS { - let _ = self.queue.pop_front(); - } - } - - fn bg_color(level: ToastLevel, theme: &FlowTheme) -> u32 { - match level { - ToastLevel::Info => theme.info, - ToastLevel::Success => theme.success, - ToastLevel::Warning => theme.warning, - ToastLevel::Error => theme.error, - } - } -} - -impl Plugin for ToastPlugin { - fn name(&self) -> &'static str { - "toast" - } - - fn on_event( - &mut self, - event: &FlowEvent, - ctx: &mut PluginContext, - ) -> crate::plugin::EventResult { - self.gc_expired(); - if let Some(msg) = event.as_custom::() { - let duration = msg.duration; - self.push(msg.clone()); - ctx.schedule_after(duration); - ctx.notify(); - } - crate::plugin::EventResult::Continue - } - - fn priority(&self) -> i32 { - 10 - } - - fn render_layer(&self) -> crate::plugin::RenderLayer { - crate::plugin::RenderLayer::Overlay - } - - fn render(&mut self, ctx: &mut RenderContext) -> Option { - self.gc_expired(); - if self.queue.is_empty() { - return None; - } - - let items = self.queue.iter().rev().map(|item| { - div() - .mb_2() - .max_w(px(360.0)) - .rounded(px(8.0)) - .bg(rgb(Self::bg_color(item.level, ctx.theme))) - .px_3() - .py_2() - .child( - div() - .text_sm() - .text_color(rgb(0x00FFFFFF)) - .child(item.text.clone()), - ) - }); - - Some( - div() - .absolute() - .right(px(12.0)) - .bottom(px(12.0)) - .children(items) - .into_any(), - ) - } -} diff --git a/crates/ferrum-flow/src/plugins/viewport.rs b/crates/ferrum-flow/src/plugins/viewport.rs deleted file mode 100644 index 1ad4017afe..0000000000 --- a/crates/ferrum-flow/src/plugins/viewport.rs +++ /dev/null @@ -1,152 +0,0 @@ -use gpui::{MouseButton, Pixels, Point, px}; - -use crate::{ - canvas::{Command, Interaction, InteractionResult}, - plugin::{EventResult, FlowEvent, InputEvent, Plugin}, -}; - -pub struct ViewportPlugin; - -impl ViewportPlugin { - pub fn new() -> Self { - Self {} - } -} - -impl Default for ViewportPlugin { - fn default() -> Self { - Self::new() - } -} - -impl Plugin for ViewportPlugin { - fn name(&self) -> &'static str { - "viewport" - } - - fn on_event( - &mut self, - event: &crate::plugin::FlowEvent, - ctx: &mut crate::plugin::PluginContext, - ) -> EventResult { - if let FlowEvent::Input(InputEvent::MouseDown(ev)) = event - && ((ev.button == MouseButton::Left && ev.modifiers.shift) - || ev.button == MouseButton::Middle) - { - ctx.start_interaction(Panning { - start_mouse: ev.position, - start_offset: ctx.offset(), - }); - return EventResult::Stop; - } else if let FlowEvent::Input(InputEvent::Wheel(ev)) = event { - let cursor = ev.position; - - let before = ctx.screen_to_world(cursor); - - let delta = f32::from(ev.delta.pixel_delta(px(1.0)).y); - if delta == 0.0 { - return EventResult::Continue; - } - - let zoom_delta = if delta > 0.0 { 0.9 } else { 1.1 }; - - ctx.set_zoom(ctx.zoom_scaled_by(zoom_delta).clamp(0.1, 3.0)); - - let after = ctx.world_to_screen(before); - - ctx.translate_offset(cursor.x - after.x, cursor.y - after.y); - ctx.notify(); - } - EventResult::Continue - } - - fn priority(&self) -> i32 { - 10 - } -} - -struct Panning { - start_mouse: Point, - start_offset: Point, -} - -impl Interaction for Panning { - fn on_mouse_move( - &mut self, - ev: &gpui::MouseMoveEvent, - ctx: &mut crate::plugin::PluginContext, - ) -> InteractionResult { - let dx = ev.position.x - self.start_mouse.x; - let dy = ev.position.y - self.start_mouse.y; - - ctx.set_offset(Point::new( - self.start_offset.x + dx, - self.start_offset.y + dy, - )); - ctx.notify(); - - InteractionResult::Continue - } - - fn on_mouse_up( - &mut self, - _event: &gpui::MouseUpEvent, - ctx: &mut crate::plugin::PluginContext, - ) -> crate::canvas::InteractionResult { - ctx.execute_command(PanningCommand { - from: self.start_offset, - to: ctx.offset(), - }); - ctx.cancel_interaction(); - InteractionResult::End - } -} - -struct PanningCommand { - from: Point, - to: Point, -} - -impl Command for PanningCommand { - fn name(&self) -> &'static str { - "panning" - } - fn execute(&mut self, ctx: &mut crate::canvas::CommandContext) { - ctx.set_offset(self.to); - } - fn undo(&mut self, ctx: &mut crate::canvas::CommandContext) { - ctx.set_offset(self.from); - } - - fn to_ops(&self, _ctx: &mut crate::CommandContext) -> Vec { - vec![] - } -} - -#[cfg(test)] -mod command_interop_tests { - use gpui::{Point, px}; - - use crate::{Graph, command_interop::assert_command_interop}; - - use super::PanningCommand; - - #[test] - fn panning_command_interop() { - let base = Graph::new(); - let cmd = PanningCommand { - from: Point::new(px(0.0), px(0.0)), - to: Point::new(px(12.0), px(34.0)), - }; - assert_command_interop( - &base, - || { - Box::new(PanningCommand { - from: cmd.from, - to: cmd.to, - }) - }, - "PanningCommand", - ); - } -} diff --git a/crates/ferrum-flow/src/plugins/viewport_frame.rs b/crates/ferrum-flow/src/plugins/viewport_frame.rs deleted file mode 100644 index 975eb72b49..0000000000 --- a/crates/ferrum-flow/src/plugins/viewport_frame.rs +++ /dev/null @@ -1,166 +0,0 @@ -use gpui::{Pixels, Point, px}; - -use crate::{ - InitPluginContext, - canvas::{Command, CommandContext}, - plugin::PluginContext, -}; - -/// Same zoom limits as [`crate::plugins::ViewportPlugin`] wheel zoom. -pub(crate) const ZOOM_MIN: f32 = 0.7; -pub(crate) const ZOOM_MAX: f32 = 3.0; -/// Inset from window edges (ratio per side). -pub(crate) const MARGIN_RATIO: f32 = 0.08; - -fn frame_params( - win_w: f32, - win_h: f32, - bx: f32, - by: f32, - bw: f32, - bh: f32, -) -> Option<(f32, Point)> { - if win_w <= 0.0 || win_h <= 0.0 { - return None; - } - - let bw_safe = bw.max(1.0); - let bh_safe = bh.max(1.0); - - let avail_w = win_w * (1.0 - 2.0 * MARGIN_RATIO); - let avail_h = win_h * (1.0 - 2.0 * MARGIN_RATIO); - let z = (avail_w / bw_safe) - .min(avail_h / bh_safe) - .clamp(ZOOM_MIN, ZOOM_MAX); - - let cx = bx + bw / 2.0; - let cy = by + bh / 2.0; - let center_x = win_w / 2.0; - let center_y = win_h / 2.0; - let new_offset = Point::new(px(center_x - cx * z), px(center_y - cy * z)); - - Some((z, new_offset)) -} - -pub(crate) struct ViewportFrameCommand { - pub from_zoom: f32, - pub from_offset: Point, - pub to_zoom: f32, - pub to_offset: Point, -} - -impl Command for ViewportFrameCommand { - fn name(&self) -> &'static str { - "viewport_frame" - } - - fn execute(&mut self, ctx: &mut CommandContext) { - ctx.set_zoom(self.to_zoom); - ctx.set_offset(self.to_offset); - } - - fn undo(&mut self, ctx: &mut CommandContext) { - ctx.set_zoom(self.from_zoom); - ctx.set_offset(self.from_offset); - } - - fn to_ops(&self, ctx: &mut CommandContext) -> Vec { - ctx.set_zoom(self.to_zoom); - ctx.set_offset(self.to_offset); - vec![] - } -} - -/// Pan + zoom so the given world-space axis-aligned box (position + size) fits the window. -pub(crate) fn frame_world_rect(ctx: &mut PluginContext, bx: f32, by: f32, bw: f32, bh: f32) { - let Some(wb) = ctx.window_bounds() else { - return; - }; - - let win_w: f32 = wb.size.width.into(); - let win_h: f32 = wb.size.height.into(); - let Some((z, new_offset)) = frame_params(win_w, win_h, bx, by, bw, bh) else { - return; - }; - - let from_zoom = ctx.zoom(); - let from_offset = ctx.offset(); - let zoom_changed = (from_zoom - z).abs() > 1e-4; - let ox: f32 = from_offset.x.into(); - let oy: f32 = from_offset.y.into(); - let nx: f32 = new_offset.x.into(); - let ny: f32 = new_offset.y.into(); - let offset_changed = (ox - nx).abs() > 0.5 || (oy - ny).abs() > 0.5; - if !zoom_changed && !offset_changed { - return; - } - - ctx.execute_command(ViewportFrameCommand { - from_zoom, - from_offset, - to_zoom: z, - to_offset: new_offset, - }); -} - -/// Same geometry as [`frame_world_rect`], but writes [`Viewport`] directly (no undo stack). -/// For [`crate::plugin::InitPluginContext`] / plugin [`Plugin::setup`]. -pub(crate) fn apply_frame_world_rect_direct( - ctx: &mut InitPluginContext, - win_w: f32, - win_h: f32, - bx: f32, - by: f32, - bw: f32, - bh: f32, -) { - let Some((z, new_offset)) = frame_params(win_w, win_h, bx, by, bw, bh) else { - return; - }; - - let zoom_changed = (ctx.zoom() - z).abs() > 1e-4; - let off = ctx.offset(); - let ox: f32 = off.x.into(); - let oy: f32 = off.y.into(); - let nx: f32 = new_offset.x.into(); - let ny: f32 = new_offset.y.into(); - let offset_changed = (ox - nx).abs() > 0.5 || (oy - ny).abs() > 0.5; - if !zoom_changed && !offset_changed { - return; - } - - ctx.set_zoom(z); - ctx.set_offset(new_offset); -} - -#[cfg(test)] -mod command_interop_tests { - use gpui::{Point, px}; - - use crate::{Graph, command_interop::assert_command_interop}; - - use super::ViewportFrameCommand; - - #[test] - fn viewport_frame_command_interop() { - let base = Graph::new(); - let cmd = ViewportFrameCommand { - from_zoom: 1.0, - from_offset: Point::new(px(0.0), px(0.0)), - to_zoom: 1.1, - to_offset: Point::new(px(8.0), px(9.0)), - }; - assert_command_interop( - &base, - || { - Box::new(ViewportFrameCommand { - from_zoom: cmd.from_zoom, - from_offset: cmd.from_offset, - to_zoom: cmd.to_zoom, - to_offset: cmd.to_offset, - }) - }, - "ViewportFrameCommand", - ); - } -} diff --git a/crates/ferrum-flow/src/plugins/zoom_controls.rs b/crates/ferrum-flow/src/plugins/zoom_controls.rs deleted file mode 100644 index 522933be78..0000000000 --- a/crates/ferrum-flow/src/plugins/zoom_controls.rs +++ /dev/null @@ -1,314 +0,0 @@ -//! Bottom-left zoom controls (left to right: **+ − ↺ ⛶**): zoom in, zoom out, reset scale, fit entire graph. - -use gpui::{ - Bounds, IntoElement as _, MouseButton, ParentElement as _, Pixels, Point, Size, Styled as _, - div, px, rgb, -}; - -/// Unicode minus sign (not ASCII hyphen). -const LABEL_ZOOM_OUT: &str = "\u{2212}"; -const LABEL_ZOOM_IN: &str = "+"; -/// Anticlockwise open circle arrow — common “reset view” symbol. -const LABEL_RESET_ZOOM: &str = "\u{21BA}"; -/// Square four corners — “frame / fit content” (same action as [`crate::plugins::FitAllGraphPlugin`]). -const LABEL_FIT_ENTIRE_GRAPH: &str = "\u{26F6}"; - -use crate::{ - canvas::{Command, CommandContext}, - plugin::{ - EventResult, FlowEvent, InputEvent, Plugin, PluginContext, RenderContext, RenderLayer, - }, -}; - -use super::fit_all::fit_entire_graph; -use super::viewport_frame::{ZOOM_MAX, ZOOM_MIN}; - -const MARGIN: f32 = 16.0; -/// Square control size (width = height). -const BTN: f32 = 36.0; -const GAP: f32 = 6.0; -/// Same step as [`crate::plugins::ViewportPlugin`] wheel zoom. -const ZOOM_STEP: f32 = 1.1; - -struct ZoomControlsLayout { - zoom_in: Bounds, - zoom_out: Bounds, - reset: Bounds, - fit_entire_graph: Bounds, -} - -impl ZoomControlsLayout { - fn hit(&self, p: Point) -> Option { - if self.zoom_in.contains(&p) { - Some(Hit::ZoomIn) - } else if self.zoom_out.contains(&p) { - Some(Hit::ZoomOut) - } else if self.reset.contains(&p) { - Some(Hit::ResetZoom) - } else if self.fit_entire_graph.contains(&p) { - Some(Hit::FitEntireGraph) - } else { - None - } - } -} - -#[derive(Copy, Clone)] -enum Hit { - ZoomIn, - ZoomOut, - ResetZoom, - FitEntireGraph, -} - -fn bar_outer_size() -> (f32, f32) { - let w = 4.0 * BTN + 3.0 * GAP; - (w, BTN) -} - -fn build_layout(window_bounds: Bounds) -> ZoomControlsLayout { - let wh: f32 = window_bounds.size.height.into(); - let (_, bar_h) = bar_outer_size(); - let s = px(BTN); - let m = px(MARGIN); - - let y0 = px(wh - MARGIN - bar_h); - let x0 = m; - - let zoom_in = Bounds::new(Point::new(x0, y0), Size::new(s, s)); - let zoom_out = Bounds::new( - Point::new(px(f32::from(x0) + BTN + GAP), y0), - Size::new(s, s), - ); - let reset = Bounds::new( - Point::new(px(f32::from(x0) + 2.0 * (BTN + GAP)), y0), - Size::new(s, s), - ); - let fit_entire_graph = Bounds::new( - Point::new(px(f32::from(x0) + 3.0 * (BTN + GAP)), y0), - Size::new(s, s), - ); - - ZoomControlsLayout { - zoom_in, - zoom_out, - reset, - fit_entire_graph, - } -} - -struct ViewportZoomCommand { - from_zoom: f32, - from_offset: Point, - to_zoom: f32, - to_offset: Point, -} - -impl Command for ViewportZoomCommand { - fn name(&self) -> &'static str { - "viewport_zoom" - } - - fn execute(&mut self, ctx: &mut CommandContext) { - ctx.set_zoom(self.to_zoom); - ctx.set_offset(self.to_offset); - } - - fn undo(&mut self, ctx: &mut CommandContext) { - ctx.set_zoom(self.from_zoom); - ctx.set_offset(self.from_offset); - } - - fn to_ops(&self, ctx: &mut crate::CommandContext) -> Vec { - ctx.set_zoom(self.to_zoom); - ctx.set_offset(self.to_offset); - vec![] - } -} - -fn apply_zoom(ctx: &mut PluginContext, anchor_screen: Point, to_zoom: f32) { - let to_zoom = to_zoom.clamp(ZOOM_MIN, ZOOM_MAX); - let from_zoom = ctx.zoom(); - let from_offset = ctx.offset(); - if (from_zoom - to_zoom).abs() < 1e-5 { - return; - } - let anchor_world = ctx.screen_to_world(anchor_screen); - let wx: f32 = anchor_world.x.into(); - let wy: f32 = anchor_world.y.into(); - let ax: f32 = anchor_screen.x.into(); - let ay: f32 = anchor_screen.y.into(); - let to_offset = Point::new(px(ax - wx * to_zoom), px(ay - wy * to_zoom)); - ctx.execute_command(ViewportZoomCommand { - from_zoom, - from_offset, - to_zoom, - to_offset, - }); -} - -fn window_center_screen(ctx: &PluginContext) -> Option> { - let wb = ctx.window_bounds()?; - let cx: f32 = (wb.size.width / 2.0).into(); - let cy: f32 = (wb.size.height / 2.0).into(); - Some(Point::new(px(cx), px(cy))) -} - -fn zoom_by_factor(ctx: &mut PluginContext, factor: f32) { - let Some(center) = window_center_screen(ctx) else { - return; - }; - apply_zoom(ctx, center, ctx.zoom_scaled_by(factor)); -} - -fn reset_zoom(ctx: &mut PluginContext) { - let Some(center) = window_center_screen(ctx) else { - return; - }; - apply_zoom(ctx, center, 1.0); -} - -/// Bottom-left **+** / **−** / **↺** / **⛶** (fit all); priority **128** so clicks beat canvas selection. -pub struct ZoomControlsPlugin { - last_layout: Option, -} - -impl Default for ZoomControlsPlugin { - fn default() -> Self { - Self::new() - } -} - -impl ZoomControlsPlugin { - pub fn new() -> Self { - Self { last_layout: None } - } -} - -impl Plugin for ZoomControlsPlugin { - fn name(&self) -> &'static str { - "zoom_controls" - } - - fn on_event(&mut self, event: &FlowEvent, ctx: &mut PluginContext) -> EventResult { - if let FlowEvent::Input(InputEvent::MouseDown(ev)) = event - && ev.button == MouseButton::Left - && let Some(ref layout) = self.last_layout - && let Some(hit) = layout.hit(ev.position) - { - match hit { - Hit::ZoomIn => zoom_by_factor(ctx, ZOOM_STEP), - Hit::ZoomOut => zoom_by_factor(ctx, 1.0 / ZOOM_STEP), - Hit::ResetZoom => reset_zoom(ctx), - Hit::FitEntireGraph => fit_entire_graph(ctx), - } - ctx.notify(); - return EventResult::Stop; - } - EventResult::Continue - } - - fn priority(&self) -> i32 { - 128 - } - - fn render_layer(&self) -> RenderLayer { - RenderLayer::Overlay - } - - fn render(&mut self, ctx: &mut RenderContext) -> Option { - let win = ctx.window_bounds().unwrap_or_else(|| { - let vs = ctx.window.viewport_size(); - Bounds::new(Point::new(px(0.0), px(0.0)), Size::new(vs.width, vs.height)) - }); - let wh: f32 = win.size.height.into(); - let (bar_w, bar_h) = bar_outer_size(); - if wh < MARGIN + bar_h + 1.0 { - self.last_layout = None; - return None; - } - - let layout = build_layout(win); - self.last_layout = Some(layout); - - let bar_w_px = px(bar_w); - - let btn_bg = ctx.theme.zoom_controls_background; - let btn_border = ctx.theme.zoom_controls_border; - let btn_text = ctx.theme.zoom_controls_text; - - let mk_btn = move |label: &'static str| { - div() - .w(px(BTN)) - .h(px(BTN)) - .flex() - .items_center() - .justify_center() - .rounded(px(6.0)) - .bg(rgb(btn_bg)) - .border_1() - .border_color(rgb(btn_border)) - .text_sm() - .font_weight(gpui::FontWeight::MEDIUM) - .text_color(rgb(btn_text)) - .child(label) - }; - - Some( - div() - .absolute() - .size_full() - .child( - div() - .absolute() - .bottom(px(MARGIN)) - .left(px(MARGIN)) - .w(bar_w_px) - .h(px(bar_h)) - .flex() - .flex_row() - .gap(px(GAP)) - .items_center() - .children(vec![ - mk_btn(LABEL_ZOOM_IN), - mk_btn(LABEL_ZOOM_OUT), - mk_btn(LABEL_RESET_ZOOM), - mk_btn(LABEL_FIT_ENTIRE_GRAPH), - ]), - ) - .into_any_element(), - ) - } -} - -#[cfg(test)] -mod command_interop_tests { - use gpui::{Point, px}; - - use crate::{Graph, command_interop::assert_command_interop}; - - use super::ViewportZoomCommand; - - #[test] - fn viewport_zoom_command_interop() { - let base = Graph::new(); - let cmd = ViewportZoomCommand { - from_zoom: 1.0, - from_offset: Point::new(px(0.0), px(0.0)), - to_zoom: 1.25, - to_offset: Point::new(px(5.0), px(6.0)), - }; - assert_command_interop( - &base, - || { - Box::new(ViewportZoomCommand { - from_zoom: cmd.from_zoom, - from_offset: cmd.from_offset, - to_zoom: cmd.to_zoom, - to_offset: cmd.to_offset, - }) - }, - "ViewportZoomCommand", - ); - } -} diff --git a/crates/ferrum-flow/src/port_screen.rs b/crates/ferrum-flow/src/port_screen.rs deleted file mode 100644 index c47ecff147..0000000000 --- a/crates/ferrum-flow/src/port_screen.rs +++ /dev/null @@ -1,60 +0,0 @@ -//! Screen-space port layout for canvas rendering ([`PortScreenFrame`]). - -use gpui::{ - Div, ElementId, InteractiveElement as _, Pixels, Point, Size, Stateful, Styled as _, div, -}; - -use crate::PortId; - -/// Screen-space layout for one port after the viewport transform. -/// -/// Prefer resolving via [`crate::plugin::RenderContext::port_screen_frame`] (or -/// [`crate::plugin::PluginContext::port_screen_frame`] during interaction). -/// -/// Typical patterns: -/// - Default disc: [`Self::anchor_div`] then chain `.rounded_full()`, colors, borders. -/// - Custom chrome: build children inside [`Self::anchor_div`], or use [`Self::center`] -/// / [`Self::scaled_size`] for labels, sockets, multi-layer ports. -/// - Larger hit target: [`Self::anchor_div`] then override `.w`/`.h` while keeping [`Self::center`]. -#[derive(Clone, Copy, Debug)] -pub struct PortScreenFrame { - /// Port center in screen pixels (aligned with edge curve endpoints). - pub center: Point, - /// Logical port size from graph data (same units as on the node card). - pub size: Size, - pub zoom: f32, - pub(crate) port_id: PortId, -} - -impl PortScreenFrame { - /// `size` scaled by [`Self::zoom`], i.e. the on-screen port box size. - pub fn scaled_size(&self) -> Size { - let z = self.zoom; - Size { - width: self.size.width * z, - height: self.size.height * z, - } - } - - /// Top-left of the axis-aligned rectangle centered on [`Self::center`]. - pub fn origin(&self) -> Point { - let s = self.scaled_size(); - Point::new( - self.center.x - s.width / 2.0, - self.center.y - s.height / 2.0, - ) - } - - /// `absolute` container covering the default port hit box; chain GPUI styles and children. - pub fn anchor_div(self) -> Stateful
{ - let s = self.scaled_size(); - let o = self.origin(); - div() - .id(ElementId::Uuid(*self.port_id.as_uuid())) - .absolute() - .left(o.x) - .top(o.y) - .w(s.width) - .h(s.height) - } -} diff --git a/crates/ferrum-flow/src/shared_state.rs b/crates/ferrum-flow/src/shared_state.rs deleted file mode 100644 index 1e02c106e1..0000000000 --- a/crates/ferrum-flow/src/shared_state.rs +++ /dev/null @@ -1,55 +0,0 @@ -//! Type-erased map for data shared between plugins on one [`crate::canvas::FlowCanvas`]. -//! -//! Store values under their concrete Rust type (`TypeId`). Each type may appear at most once. -//! Prefer newtype wrappers per feature to avoid collisions (e.g. `struct MyPluginState(u32)`). - -use std::any::{Any, TypeId}; -use std::collections::HashMap; -use std::fmt; - -/// Keyed by [`TypeId`]; values must be `'static` and [`Send`]. -pub struct SharedState { - inner: HashMap>, -} - -impl fmt::Debug for SharedState { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("SharedState") - .field("len", &self.inner.len()) - .finish() - } -} - -impl SharedState { - pub(crate) fn new() -> Self { - Self { - inner: HashMap::new(), - } - } - - /// Inserts a value, returning the previous one of the same type if any. - pub fn insert(&mut self, value: T) -> Option { - let id = TypeId::of::(); - let old = self.inner.remove(&id); - self.inner.insert(id, Box::new(value)); - old.and_then(|b| b.downcast::().ok().map(|b| *b)) - } - - pub fn get(&self) -> Option<&T> { - self.inner.get(&TypeId::of::())?.downcast_ref() - } - - pub fn get_mut(&mut self) -> Option<&mut T> { - self.inner.get_mut(&TypeId::of::())?.downcast_mut() - } - - pub fn remove(&mut self) -> Option { - self.inner - .remove(&TypeId::of::()) - .and_then(|b| b.downcast::().ok().map(|b| *b)) - } - - pub fn contains(&self) -> bool { - self.inner.contains_key(&TypeId::of::()) - } -} diff --git a/crates/ferrum-flow/src/theme.rs b/crates/ferrum-flow/src/theme.rs deleted file mode 100644 index d735ffccc6..0000000000 --- a/crates/ferrum-flow/src/theme.rs +++ /dev/null @@ -1,139 +0,0 @@ -//! Canvas-wide visual tokens. Plugins can replace or tweak values in -//! [`crate::plugin::InitPluginContext::theme`] / [`crate::plugin::PluginContext::theme`]. -//! -//! Colors are `u32` in **GPUI `rgb` / `rgba` layout**: `0x00RRGGBB` for opaque colors -//! (first byte unused by [`gpui::rgb`]), and `0xRRGGBBAA` for [`gpui::rgba`] fills. - -/// Default canvas chrome: node cards, grid, edges, selection marquee. -#[derive(Debug, Clone, PartialEq)] -pub struct FlowTheme { - /// Default node card background ([`gpui::rgb`]). - pub node_card_background: u32, - /// Default node card border when not selected. - pub node_card_border: u32, - /// Default node card border when selected. - pub node_card_border_selected: u32, - - /// Unknown node type card background. - pub undefined_node_background: u32, - /// Unknown node type card border. - pub undefined_node_border: u32, - - /// Primary label on default node cards. - pub node_caption_text: u32, - /// Label on undefined-type node cards. - pub undefined_node_caption_text: u32, - - /// Default circular port fill ([`NodeRenderer::port_render`](crate::NodeRenderer::port_render); - /// layout via [`crate::plugin::RenderContext::port_screen_frame`]). - pub default_port_fill: u32, - - /// Main surface color behind the dot grid. - pub background: u32, - /// Dot color for the background grid. - pub background_grid_dot: u32, - - /// Edge curve when not selected. - pub edge_stroke: u32, - /// Edge curve when selected. - pub edge_stroke_selected: u32, - - /// Marquee / move-preview rectangle outline ([`gpui::rgb`]). - pub selection_rect_border: u32, - /// Marquee / move-preview fill ([`gpui::rgba`], e.g. `0x78A0FF4c`). - pub selection_rect_fill_rgba: u32, - - /// Temporary line while dragging a link from a port. - pub port_preview_line: u32, - /// Endpoint disc while dragging a link from a port (muted so it does not overpower the canvas). - pub port_preview_dot: u32, - - /// Minimap inner panel fill ([`crate::MinimapPlugin`]). - pub minimap_background: u32, - /// Minimap inner panel outline. - pub minimap_border: u32, - /// Minimap graph edges (straight segments between node centers). - pub minimap_edge: u32, - /// Minimap node rectangle fill. - pub minimap_node_fill: u32, - /// Minimap node rectangle outline. - pub minimap_node_stroke: u32, - /// Minimap viewport / visible-area frame. - pub minimap_viewport_stroke: u32, - - /// Zoom bar button fill ([`crate::ZoomControlsPlugin`]). - pub zoom_controls_background: u32, - /// Zoom bar button border. - pub zoom_controls_border: u32, - /// Zoom bar glyph color. - pub zoom_controls_text: u32, - - /// Context menu panel fill ([`crate::ContextMenuPlugin`]). - pub context_menu_background: u32, - /// Context menu panel outline. - pub context_menu_border: u32, - /// Context menu row label. - pub context_menu_text: u32, - /// Context menu shortcut hint (muted). - pub context_menu_shortcut_text: u32, - /// Context menu separator rule between rows. - pub context_menu_separator: u32, - - /// common error color. - pub error: u32, - /// common info color. - pub info: u32, - /// common success color. - pub success: u32, - /// common warning color. - pub warning: u32, -} - -impl Default for FlowTheme { - #[allow(clippy::mixed_case_hex_literals)] - fn default() -> Self { - Self { - node_card_background: 0x00FFFFFF, - node_card_border: 0x001A192B, - node_card_border_selected: 0x00FF7800, - undefined_node_background: 0x00F5F5F5, - undefined_node_border: 0x00FF9800, - node_caption_text: 0x001A192B, - undefined_node_caption_text: 0x005F6368, - default_port_fill: 0x001A192B, - background: 0x00f8f9fb, - background_grid_dot: 0x009F9FA7, - edge_stroke: 0x00b1b1b8, - edge_stroke_selected: 0x00FF7800, - selection_rect_border: 0x0078A0FF, - selection_rect_fill_rgba: 0x78A0FF4c, - port_preview_line: 0x00b1b1b8, - port_preview_dot: 0x007189a3, - minimap_background: 0x00f8f9fb, - minimap_border: 0x00b1b1b8, - minimap_edge: 0x00b1b1b8, - minimap_node_fill: 0x00FFFFFF, - minimap_node_stroke: 0x001a192b, - minimap_viewport_stroke: 0x0078a0ff, - zoom_controls_background: 0x00fcfcfc, - zoom_controls_border: 0x00c8c8d0, - zoom_controls_text: 0x001a192b, - context_menu_background: 0x00fcfcfc, - context_menu_border: 0x00c8c8d0, - context_menu_text: 0x001a192b, - context_menu_shortcut_text: 0x007a7a88, - context_menu_separator: 0x00e0e0e8, - error: 0x00FF1744, - info: 0x001F2937, - success: 0x001E8E3E, - warning: 0x00B35A00, - } - } -} - -impl FlowTheme { - /// Same as [`Default::default`]; kept for explicit call sites. - pub fn light() -> Self { - Self::default() - } -} diff --git a/crates/ferrum-flow/src/viewport.rs b/crates/ferrum-flow/src/viewport.rs deleted file mode 100644 index 11526d7128..0000000000 --- a/crates/ferrum-flow/src/viewport.rs +++ /dev/null @@ -1,192 +0,0 @@ -use gpui::{Bounds, Pixels, Point, Size, Window, px}; - -use crate::{Node, PortPosition}; - -/// Fingerprint of [`Viewport`] fields that affect [`Viewport::is_node_visible`]. -/// Used by [`crate::NodePlugin`] to avoid rescanning the full node list every frame. -#[derive(Debug, Clone, Copy, PartialEq)] -pub(crate) struct ViewportVisibilityCacheKey { - pub zoom: f32, - pub offset_x: f32, - pub offset_y: f32, - pub has_window: bool, - pub window_w: f32, - pub window_h: f32, -} - -#[derive(Debug, Clone)] -pub struct Viewport { - zoom: f32, - offset: Point, - window_bounds: Option>, -} - -impl Viewport { - pub(crate) fn new() -> Self { - Self { - zoom: 1.0, - offset: Point::new(px(0.0), px(0.0)), - window_bounds: None, - } - } - - /// Sets [`Self::window_bounds`] to the window’s drawable area (`Window::viewport_size`), - /// origin `(0, 0)`. Skips assignment when width/height are unchanged. - /// - /// Prefer this over `Window::bounds()` for hit-testing and overlay layout: the latter is in - /// global space and can be larger than the content viewport. - pub fn sync_drawable_bounds(&mut self, window: &Window) { - let vs = window.viewport_size(); - let unchanged = self - .window_bounds - .is_some_and(|b| b.size.width == vs.width && b.size.height == vs.height); - if !unchanged { - self.window_bounds = Some(Bounds::new( - Point::new(px(0.0), px(0.0)), - Size::new(vs.width, vs.height), - )); - } - } - - /// Sets [`Self::window_bounds`] to the canvas element's local drawable area. - pub fn sync_canvas_bounds(&mut self, bounds: Bounds) { - let unchanged = self.window_bounds.is_some_and(|b| { - b.size.width == bounds.size.width && b.size.height == bounds.size.height - }); - if !unchanged { - self.window_bounds = Some(Bounds::new( - Point::new(px(0.0), px(0.0)), - Size::new(bounds.size.width, bounds.size.height), - )); - } - } - - pub fn zoom(&self) -> f32 { - self.zoom - } - - pub fn set_zoom(&mut self, zoom: f32) { - self.zoom = zoom; - } - - /// Compute a new zoom value by multiplying current zoom with `factor`. - pub fn zoom_scaled_by(&self, factor: f32) -> f32 { - self.zoom * factor - } - - pub fn offset(&self) -> Point { - self.offset - } - - pub fn set_offset(&mut self, offset: Point) { - self.offset = offset; - } - - pub fn set_offset_xy(&mut self, x: Pixels, y: Pixels) { - self.offset = Point::new(x, y); - } - - pub fn translate_offset(&mut self, dx: Pixels, dy: Pixels) { - self.offset.x += dx; - self.offset.y += dy; - } - - pub fn window_bounds(&self) -> Option> { - self.window_bounds - } - - pub fn set_window_bounds(&mut self, bounds: Option>) { - self.window_bounds = bounds; - } - - /// Convert a world-space scalar length to screen-space scalar length. - pub fn world_scalar_to_screen(&self, value: f32) -> f32 { - value * self.zoom - } - - /// Convert a screen-space scalar length to world-space scalar length. - pub fn screen_scalar_to_world(&self, value: f32) -> f32 { - value / self.zoom - } - - /// Convert a world-space pixel length to screen-space pixel length. - pub fn world_length_to_screen(&self, value: Pixels) -> Pixels { - value * self.zoom - } - - /// Convert a screen-space pixel length to world-space pixel length. - pub fn screen_length_to_world(&self, value: Pixels) -> Pixels { - value / self.zoom - } - - pub fn world_to_screen(&self, p: Point) -> Point { - Point::new( - self.world_length_to_screen(p.x) + self.offset.x, - self.world_length_to_screen(p.y) + self.offset.y, - ) - } - - pub fn screen_to_world(&self, p: Point) -> Point { - Point::new( - self.screen_length_to_world(p.x - self.offset.x), - self.screen_length_to_world(p.y - self.offset.y), - ) - } - - /// Bezier control point for an edge tangent at a port direction. - pub fn edge_control_point( - &self, - source: Point, - position: PortPosition, - ) -> Point { - match position { - PortPosition::Top => { - source - Point::new(px(0.0), px(self.world_scalar_to_screen(50.0))) - } - PortPosition::Left => { - source - Point::new(px(self.world_scalar_to_screen(50.0)), px(0.0)) - } - PortPosition::Right => { - source + Point::new(px(self.world_scalar_to_screen(50.0)), px(0.0)) - } - PortPosition::Bottom => { - source + Point::new(px(0.0), px(self.world_scalar_to_screen(50.0))) - } - } - } - - pub fn is_node_visible(&self, node: &Node) -> bool { - let Some(window_bounds) = self.window_bounds else { - return false; - }; - - let screen = self.world_to_screen(node.point()); - let size = *node.size_ref(); - - screen.x + self.world_length_to_screen(size.width) > px(0.0) - && screen.x < window_bounds.size.width - && screen.y + self.world_length_to_screen(size.height) > px(0.0) - && screen.y < window_bounds.size.height - } - - pub(crate) fn visibility_cache_key(&self) -> ViewportVisibilityCacheKey { - match self.window_bounds { - Some(b) => ViewportVisibilityCacheKey { - zoom: self.zoom, - offset_x: self.offset.x.into(), - offset_y: self.offset.y.into(), - has_window: true, - window_w: b.size.width.into(), - window_h: b.size.height.into(), - }, - None => ViewportVisibilityCacheKey { - zoom: self.zoom, - offset_x: self.offset.x.into(), - offset_y: self.offset.y.into(), - has_window: false, - window_w: 0.0, - window_h: 0.0, - }, - } - } -} From a10f010e8721cc14a18898753cadea3d04e13664 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=83=A1=E9=A3=9E?= <1835698775@qq.com> Date: Fri, 8 May 2026 20:47:27 +0800 Subject: [PATCH 22/45] =?UTF-8?q?feat(er=5Fdiagram):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E6=BB=9A=E5=8A=A8=E6=9D=A1=E6=94=AF=E6=8C=81=E5=B9=B6=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E9=BC=A0=E6=A0=87=E4=BA=8B=E4=BB=B6=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 scrollbar_plugin 模块,实现水平和垂直滚动条的渲染与交互 - 在 scroll_pan_plugin 中集成滚动条状态,支持拖动滚动条控制视图偏移 - 修改 pan_mode_plugin,使用本地画布坐标判断鼠标点击位置 - 增加滚轮平移支持,优化滚动时的视图偏移与刷新调度 - 完善滚动条拖动交互,支持鼠标移动和释放事件处理 - 新增滚动条轨道和滑块的渲染逻辑,包含颜色样式和位置计算 - 实现滚动条滑块长度的最小值限制,保证滑块可用性 - 在 lsp completions 中修复触发逻辑,避免光标在触发点前时错误弹出补全菜单 - 添加相关单元测试,覆盖滚动条长度计算和补全菜单显示条件 --- crates/db_view/src/er_diagram/mod.rs | 1 + .../db_view/src/er_diagram/pan_mode_plugin.rs | 3 +- .../src/er_diagram/scroll_pan_plugin.rs | 72 +++- .../src/er_diagram/scrollbar_plugin.rs | 311 ++++++++++++++++++ crates/ui/src/input/lsp/completions.rs | 20 +- 5 files changed, 390 insertions(+), 17 deletions(-) create mode 100644 crates/db_view/src/er_diagram/scrollbar_plugin.rs diff --git a/crates/db_view/src/er_diagram/mod.rs b/crates/db_view/src/er_diagram/mod.rs index b7b81f5ff3..8a02654c14 100644 --- a/crates/db_view/src/er_diagram/mod.rs +++ b/crates/db_view/src/er_diagram/mod.rs @@ -1,6 +1,7 @@ mod loader; mod pan_mode_plugin; mod scroll_pan_plugin; +mod scrollbar_plugin; use db::GlobalDbState; use ferrum_flow::{ diff --git a/crates/db_view/src/er_diagram/pan_mode_plugin.rs b/crates/db_view/src/er_diagram/pan_mode_plugin.rs index 649ee7105c..1d8508ddcd 100644 --- a/crates/db_view/src/er_diagram/pan_mode_plugin.rs +++ b/crates/db_view/src/er_diagram/pan_mode_plugin.rs @@ -90,9 +90,10 @@ impl Plugin for ErDiagramPanModePlugin { if let FlowEvent::Input(InputEvent::MouseDown(ev)) = event && ev.button == MouseButton::Left { + let pointer_position = ctx.window_pointer_to_canvas_local(ev.position); if self .last_bounds - .is_some_and(|bounds| bounds.contains(&ev.position)) + .is_some_and(|bounds| bounds.contains(&pointer_position)) { let active = ctx .shared_state diff --git a/crates/db_view/src/er_diagram/scroll_pan_plugin.rs b/crates/db_view/src/er_diagram/scroll_pan_plugin.rs index a49457a8fd..d3fc534889 100644 --- a/crates/db_view/src/er_diagram/scroll_pan_plugin.rs +++ b/crates/db_view/src/er_diagram/scroll_pan_plugin.rs @@ -1,11 +1,23 @@ -use ferrum_flow::{EventResult, FlowEvent, InputEvent, Plugin, PluginContext}; -use gpui::{Pixels, Point, px}; +use std::time::Duration; -pub struct ErDiagramScrollPanPlugin; +use ferrum_flow::{ + EventResult, FlowEvent, InputEvent, Plugin, PluginContext, RenderContext, RenderLayer, +}; +use gpui::{MouseButton, Pixels, Point, px}; + +use crate::er_diagram::scrollbar_plugin::{ScrollbarDragInteraction, ScrollbarState}; + +pub struct ErDiagramScrollPanPlugin { + scrollbars: ScrollbarState, + refresh_scheduled: bool, +} impl ErDiagramScrollPanPlugin { pub fn new() -> Self { - Self + Self { + scrollbars: ScrollbarState::default(), + refresh_scheduled: false, + } } } @@ -15,23 +27,49 @@ impl Plugin for ErDiagramScrollPanPlugin { } fn on_event(&mut self, event: &FlowEvent, ctx: &mut PluginContext) -> EventResult { - if let FlowEvent::Input(InputEvent::Wheel(ev)) = event { - let delta = ev.delta.pixel_delta(px(1.0)); - let pan = wheel_delta_to_pan(delta); - let dx = pan.x; - let dy = pan.y; - if dx != px(0.0) || dy != px(0.0) { - ctx.translate_offset(dx, dy); - ctx.notify(); - return EventResult::Stop; + match event { + FlowEvent::DrawableBoundsReady => { + if !self.refresh_scheduled { + self.refresh_scheduled = true; + ctx.schedule_after(Duration::from_millis(16)); + } + EventResult::Continue } + FlowEvent::Input(InputEvent::Wheel(ev)) => { + let delta = ev.delta.pixel_delta(px(1.0)); + let pan = wheel_delta_to_pan(delta); + let dx = pan.x; + let dy = pan.y; + if dx != px(0.0) || dy != px(0.0) { + ctx.translate_offset(dx, dy); + ctx.notify(); + return EventResult::Stop; + } + EventResult::Continue + } + FlowEvent::Input(InputEvent::MouseDown(ev)) if ev.button == MouseButton::Left => { + let pointer_position = ctx.window_pointer_to_canvas_local(ev.position); + if let Some(axis) = self.scrollbars.axis_at(pointer_position) { + ctx.start_interaction(ScrollbarDragInteraction::new(axis, ev.position, ctx)); + return EventResult::Stop; + } + EventResult::Continue + } + _ => EventResult::Continue, } - EventResult::Continue + } + + fn render(&mut self, ctx: &mut RenderContext) -> Option { + self.scrollbars.render(ctx) } fn priority(&self) -> i32 { 130 } + + fn render_layer(&self) -> RenderLayer { + RenderLayer::Overlay + } } fn wheel_delta_to_pan(delta: Point) -> Point { @@ -41,6 +79,7 @@ fn wheel_delta_to_pan(delta: Point) -> Point { #[cfg(test)] mod tests { use super::wheel_delta_to_pan; + use crate::er_diagram::scrollbar_plugin::thumb_length; use gpui::{Point, px}; #[test] @@ -50,4 +89,9 @@ mod tests { Point::new(px(0.0), px(24.0)) ); } + + #[test] + fn thumb_length_has_minimum_size() { + assert_eq!(thumb_length(px(100.0), px(10.0), px(1000.0)), px(32.0)); + } } diff --git a/crates/db_view/src/er_diagram/scrollbar_plugin.rs b/crates/db_view/src/er_diagram/scrollbar_plugin.rs new file mode 100644 index 0000000000..0f389f59c6 --- /dev/null +++ b/crates/db_view/src/er_diagram/scrollbar_plugin.rs @@ -0,0 +1,311 @@ +use ferrum_flow::{Interaction, InteractionResult, PluginContext, RenderContext}; +use gpui::{ + Bounds, IntoElement, ParentElement as _, Pixels, Point, Size, Styled as _, div, hsla, px, +}; + +const SCROLLBAR_MARGIN: f32 = 8.0; +const SCROLLBAR_THICKNESS: f32 = 8.0; +const SCROLLBAR_MIN_THUMB: f32 = 32.0; +const CONTENT_PADDING: f32 = 80.0; + +#[derive(Clone, Copy)] +pub(super) enum ScrollbarAxis { + Horizontal, + Vertical, +} + +#[derive(Default)] +pub(super) struct ScrollbarState { + horizontal_thumb: Option>, + vertical_thumb: Option>, +} + +impl ScrollbarState { + pub(super) fn axis_at(&self, position: Point) -> Option { + if self + .horizontal_thumb + .is_some_and(|bounds| bounds.contains(&position)) + { + return Some(ScrollbarAxis::Horizontal); + } + if self + .vertical_thumb + .is_some_and(|bounds| bounds.contains(&position)) + { + return Some(ScrollbarAxis::Vertical); + } + None + } + + pub(super) fn render(&mut self, ctx: &mut RenderContext) -> Option { + let Some(metrics) = ScrollbarMetrics::from_render_context(ctx) else { + self.horizontal_thumb = None; + self.vertical_thumb = None; + return None; + }; + self.horizontal_thumb = metrics.horizontal_thumb; + self.vertical_thumb = metrics.vertical_thumb; + + let track_color = hsla(0.0, 0.0, 0.0, 0.18); + let thumb_color = hsla(0.0, 0.0, 0.45, 0.55); + Some( + div() + .absolute() + .size_full() + .child(render_track(metrics.horizontal_track, track_color)) + .child(render_track(metrics.vertical_track, track_color)) + .child(render_thumb(metrics.horizontal_thumb, thumb_color)) + .child(render_thumb(metrics.vertical_thumb, thumb_color)) + .into_any_element(), + ) + } +} + +fn render_track(track: Option>, color: gpui::Hsla) -> impl IntoElement { + div().children(track.map(|track| render_bar(track, color))) +} + +fn render_thumb(thumb: Option>, color: gpui::Hsla) -> impl IntoElement { + div().children(thumb.map(|thumb| render_bar(thumb, color))) +} + +fn render_bar(bounds: Bounds, color: gpui::Hsla) -> impl IntoElement { + div() + .absolute() + .left(bounds.origin.x) + .top(bounds.origin.y) + .w(bounds.size.width) + .h(bounds.size.height) + .rounded(px(SCROLLBAR_THICKNESS / 2.0)) + .bg(color) +} + +pub(super) struct ScrollbarDragInteraction { + axis: ScrollbarAxis, + start_mouse: Point, + start_offset: Point, + world_bounds: Option, + window_bounds: Option>, + zoom: f32, +} + +impl ScrollbarDragInteraction { + pub(super) fn new( + axis: ScrollbarAxis, + start_mouse: Point, + ctx: &PluginContext, + ) -> Self { + Self { + axis, + start_mouse, + start_offset: ctx.offset(), + world_bounds: graph_world_bounds(ctx), + window_bounds: ctx.window_bounds(), + zoom: ctx.zoom(), + } + } +} + +impl Interaction for ScrollbarDragInteraction { + fn on_mouse_move( + &mut self, + ev: &gpui::MouseMoveEvent, + ctx: &mut PluginContext, + ) -> InteractionResult { + let Some(bounds) = self.world_bounds else { + return InteractionResult::End; + }; + let Some(window_bounds) = self.window_bounds else { + return InteractionResult::End; + }; + let next_offset = match self.axis { + ScrollbarAxis::Horizontal => { + let delta = ev.position.x - self.start_mouse.x; + let content_width = bounds.width * self.zoom; + let track_width = scrollbar_horizontal_track(window_bounds).size.width; + let movable = (track_width + - thumb_length(track_width, window_bounds.size.width, content_width)) + .max(px(1.0)); + let scrollable = (content_width - window_bounds.size.width).max(px(1.0)); + Point::new( + self.start_offset.x - delta * pixel_ratio(scrollable, movable), + self.start_offset.y, + ) + } + ScrollbarAxis::Vertical => { + let delta = ev.position.y - self.start_mouse.y; + let content_height = bounds.height * self.zoom; + let track_height = scrollbar_vertical_track(window_bounds).size.height; + let movable = (track_height + - thumb_length(track_height, window_bounds.size.height, content_height)) + .max(px(1.0)); + let scrollable = (content_height - window_bounds.size.height).max(px(1.0)); + Point::new( + self.start_offset.x, + self.start_offset.y - delta * pixel_ratio(scrollable, movable), + ) + } + }; + ctx.set_offset(next_offset); + ctx.notify(); + InteractionResult::Continue + } + + fn on_mouse_up( + &mut self, + _event: &gpui::MouseUpEvent, + ctx: &mut PluginContext, + ) -> InteractionResult { + ctx.cancel_interaction(); + InteractionResult::End + } +} + +#[derive(Clone, Copy)] +struct WorldBounds { + min_x: f32, + min_y: f32, + width: Pixels, + height: Pixels, +} + +struct ScrollbarMetrics { + horizontal_track: Option>, + horizontal_thumb: Option>, + vertical_track: Option>, + vertical_thumb: Option>, +} + +impl ScrollbarMetrics { + fn from_render_context(ctx: &RenderContext) -> Option { + let bounds = graph_world_bounds_from_render(ctx)?; + let window_bounds = ctx.window_bounds()?; + let zoom = ctx.zoom(); + let content_width = bounds.width * zoom; + let content_height = bounds.height * zoom; + let horizontal_track = (content_width > window_bounds.size.width) + .then(|| scrollbar_horizontal_track(window_bounds)); + let vertical_track = (content_height > window_bounds.size.height) + .then(|| scrollbar_vertical_track(window_bounds)); + let horizontal_thumb = horizontal_track.map(|track| { + horizontal_thumb_bounds( + track, + window_bounds.size.width, + content_width, + bounds, + zoom, + ctx.offset().x, + ) + }); + let vertical_thumb = vertical_track.map(|track| { + vertical_thumb_bounds( + track, + window_bounds.size.height, + content_height, + bounds, + zoom, + ctx.offset().y, + ) + }); + Some(Self { + horizontal_track, + horizontal_thumb, + vertical_track, + vertical_thumb, + }) + } +} + +fn graph_world_bounds(ctx: &PluginContext) -> Option { + ctx.graph.nodes_world_aabb().map(world_bounds_from_aabb) +} + +fn graph_world_bounds_from_render(ctx: &RenderContext) -> Option { + ctx.graph.nodes_world_aabb().map(world_bounds_from_aabb) +} + +fn world_bounds_from_aabb((min_x, min_y, width, height): (f32, f32, f32, f32)) -> WorldBounds { + WorldBounds { + min_x: min_x - CONTENT_PADDING, + min_y: min_y - CONTENT_PADDING, + width: px(width + 2.0 * CONTENT_PADDING), + height: px(height + 2.0 * CONTENT_PADDING), + } +} + +fn scrollbar_horizontal_track(window_bounds: Bounds) -> Bounds { + Bounds::new( + Point::new( + px(SCROLLBAR_MARGIN), + window_bounds.size.height - px(SCROLLBAR_MARGIN + SCROLLBAR_THICKNESS), + ), + Size::new( + window_bounds.size.width - px(2.0 * SCROLLBAR_MARGIN + SCROLLBAR_THICKNESS), + px(SCROLLBAR_THICKNESS), + ), + ) +} + +fn scrollbar_vertical_track(window_bounds: Bounds) -> Bounds { + Bounds::new( + Point::new( + window_bounds.size.width - px(SCROLLBAR_MARGIN + SCROLLBAR_THICKNESS), + px(SCROLLBAR_MARGIN), + ), + Size::new( + px(SCROLLBAR_THICKNESS), + window_bounds.size.height - px(2.0 * SCROLLBAR_MARGIN + SCROLLBAR_THICKNESS), + ), + ) +} + +fn horizontal_thumb_bounds( + track: Bounds, + viewport_width: Pixels, + content_width: Pixels, + bounds: WorldBounds, + zoom: f32, + offset_x: Pixels, +) -> Bounds { + let length = thumb_length(track.size.width, viewport_width, content_width); + let movable = (track.size.width - length).max(px(0.0)); + let scrollable = (content_width - viewport_width).max(px(1.0)); + let content_start = px(bounds.min_x) * zoom + offset_x; + let ratio = (-content_start / scrollable).clamp(0.0, 1.0); + Bounds::new( + Point::new(track.origin.x + movable * ratio, track.origin.y), + Size::new(length, track.size.height), + ) +} + +fn vertical_thumb_bounds( + track: Bounds, + viewport_height: Pixels, + content_height: Pixels, + bounds: WorldBounds, + zoom: f32, + offset_y: Pixels, +) -> Bounds { + let length = thumb_length(track.size.height, viewport_height, content_height); + let movable = (track.size.height - length).max(px(0.0)); + let scrollable = (content_height - viewport_height).max(px(1.0)); + let content_start = px(bounds.min_y) * zoom + offset_y; + let ratio = (-content_start / scrollable).clamp(0.0, 1.0); + Bounds::new( + Point::new(track.origin.x, track.origin.y + movable * ratio), + Size::new(track.size.width, length), + ) +} + +pub(super) fn thumb_length( + track_length: Pixels, + viewport_length: Pixels, + content_length: Pixels, +) -> Pixels { + (track_length * pixel_ratio(viewport_length, content_length)) + .clamp(px(SCROLLBAR_MIN_THUMB), track_length) +} + +fn pixel_ratio(numerator: Pixels, denominator: Pixels) -> f32 { + f32::from(numerator) / f32::from(denominator) +} diff --git a/crates/ui/src/input/lsp/completions.rs b/crates/ui/src/input/lsp/completions.rs index b594281dac..59d56a4fc6 100644 --- a/crates/ui/src/input/lsp/completions.rs +++ b/crates/ui/src/input/lsp/completions.rs @@ -30,11 +30,19 @@ fn completion_menu_action( new_offset: usize, start_offset: usize, ) -> CompletionMenuAction { + if new_offset < start_offset { + return if has_existing_menu { + CompletionMenuAction::Hide + } else { + CompletionMenuAction::Ignore + }; + } + if !has_existing_menu && !is_trigger { return CompletionMenuAction::Ignore; } - if has_existing_menu && (full_text.trim().is_empty() || new_offset < start_offset) { + if has_existing_menu && full_text.trim().is_empty() { return CompletionMenuAction::Hide; } @@ -152,7 +160,7 @@ impl InputState { // It will check if menu is open before showing the suggestion. self.schedule_inline_completion(window, cx); - let start = range.end; + let start = range.start; let new_offset = self.cursor(); let existing_menu = match self.context_menu.as_ref() { Some(ContextMenu::Completion(menu)) => Some(menu), @@ -390,6 +398,14 @@ mod tests { ); } + #[test] + fn ignores_trigger_without_existing_menu_when_cursor_is_before_trigger_start() { + assert_eq!( + completion_menu_action(false, true, "n", 1, 3), + CompletionMenuAction::Ignore + ); + } + #[test] fn refreshes_existing_menu_on_delete_when_text_still_has_context() { assert_eq!( From ff69fca5a71ba31f84a06205bf3fcc48ceb3ee8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=83=A1=E9=A3=9E?= <1835698775@qq.com> Date: Fri, 8 May 2026 20:52:49 +0800 Subject: [PATCH 23/45] =?UTF-8?q?fix(er=5Fdiagram):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E6=BB=9A=E5=8A=A8=E9=9D=A2=E6=9D=BF=E6=8F=92=E4=BB=B6=E5=88=B7?= =?UTF-8?q?=E6=96=B0=E8=B0=83=E5=BA=A6=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在刷新调度开始时缓存所有节点端口偏移 - 调用通知方法确保界面正确更新 - 保持原有的定时调度逻辑不变 --- crates/db_view/src/er_diagram/scroll_pan_plugin.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/db_view/src/er_diagram/scroll_pan_plugin.rs b/crates/db_view/src/er_diagram/scroll_pan_plugin.rs index d3fc534889..f1a61244e3 100644 --- a/crates/db_view/src/er_diagram/scroll_pan_plugin.rs +++ b/crates/db_view/src/er_diagram/scroll_pan_plugin.rs @@ -31,6 +31,8 @@ impl Plugin for ErDiagramScrollPanPlugin { FlowEvent::DrawableBoundsReady => { if !self.refresh_scheduled { self.refresh_scheduled = true; + ctx.cache_all_node_port_offset(); + ctx.notify(); ctx.schedule_after(Duration::from_millis(16)); } EventResult::Continue From b8a2091303736143b56704260a1caba05f193d69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=83=A1=E9=A3=9E?= <1835698775@qq.com> Date: Fri, 8 May 2026 20:59:29 +0800 Subject: [PATCH 24/45] =?UTF-8?q?docs(readme):=20=E6=B7=BB=E5=8A=A0ER?= =?UTF-8?q?=E5=9B=BE=E5=8F=8A=E8=87=B4=E8=B0=A2=E9=83=A8=E5=88=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在英文README中添加ER图及相关致谢链接 - 在中文README中添加ER图及对应致谢说明 - 保持两者内容一致,增强文档信息完整性 - 提升项目文档的可读性和参考价值 --- README.md | 5 +++++ README_CN.md | 5 +++++ er.png | Bin 0 -> 290459 bytes 3 files changed, 10 insertions(+) create mode 100644 er.png diff --git a/README.md b/README.md index dbe18679d2..992499482e 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,11 @@ **Edit files directly from the app, with syntax highlighting and autocomplete.** ![remote_file_editor](remote_file_editor.png) +**ER Diagram** +![ER Diagram](er.png) + +Thanks to [ferrum-flow](https://github.com/tu6ge/ferrum-flow.git). + ## Platform Support | Platform | Architecture | Rendering | diff --git a/README_CN.md b/README_CN.md index 4ccbd854f7..396b268381 100644 --- a/README_CN.md +++ b/README_CN.md @@ -75,6 +75,11 @@ **直接在应用程序中编辑文件,具备语法高亮显示和自动完成功能。** ![remote_file_editor](remote_file_editor.png) +**ER 图** +![ER 图](er.png) + +感谢 [ferrum-flow](https://github.com/tu6ge/ferrum-flow.git)。 + ## 平台支持 | 平台 | 架构 | 渲染后端 | diff --git a/er.png b/er.png new file mode 100644 index 0000000000000000000000000000000000000000..5cb6c3ba0682cd565686c957cb883d64cb6e73c1 GIT binary patch literal 290459 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 z z{@81MRK@B#Zc7evZxZVazirI5kq+e=Z!MNW#KP}H78tf{$PxFoM~}yWx~gg>OZk(S zvQBz_L7%HT=kc+l*L~=N_1EQ~1tiirg!?;NBTBwDzoHIbL7h)S7pa4^LI%v3nHV%S z5B>dcdaU{d?snJbzC-1~`1l^&c zQgW$!_62Y12f%~C0wWoWdd-^baA3V6UJ6Uv`@^Q}K>yJP7GXFgBe1uJ5cMulo zs)jz~yH$a4R7YLUA#Plufv`m2^Oe25cTA5v-$jF}lx;;jw5IQYF^Ajd;qFTA?e>#2q- zetg0!qQBx`*5$gIter9nO+pZEx(q{OOFDWaVUT1#^$z&J!rI~ks8rpa2Oi)1d9S*b z79;8Fn|G&Q;+#>1KmKO6yWJzKrliDfJt2@g!0>)x6%l)P=IriME2^|Q_kD}e@~%O0 z3JPdA&Eb8HM!B`U%}yKwCH~iBubu{(?5~Xn;7}xSS;pl93`vapW1WA+|M>^QI3KhI zH1A110!hUv!TPbU^FLe79 zf|K4k|JW$1J;^=Ne3=~P>{7{4+SxQF2ndbvSlh?3^dhtcXK&7)X~DBVxm46 zrXyS#algab@UghM4KZpjB6^aa?r(i=SvWSp$+l6*$?VqZFt6U--#@OejP55;ZLF;7 zwC0x=j71Zq^6B|q!Sp84m&>Juxg9w<=7Cw($?5^fHg%1TQmBJfjb%O^2`D6BBU5Ob zM3IQf0T)*{CJz@U{+~e_Npz_PxsGKH(%jtKNCZx^#wmZGU1;kw)t+%=LnJX4 zICGPc3CYZy*Kr=NbKTB3nh_I6UUT#F6g)OQfxu>;01t(yATHhpNZopa_uNYyp)6$v z=hYEI4&r5PEd_~F8#kSpc0vY^22UFxOW^+fGK)#`<_S<|i z&eto-; z(f6a=0kbKQ$9UYO{P$ofcJ;o04$sD?V+rxcK`u|Iup@0^VB4gvl$Q7}J_s$52pKi} zIC@TP%V-`Ta2H|EL^ns#b8RNm0mp71({6v~EhF*zFw{6_dL6fc&I9I+zKA zIM6~%l8+(hn8doa-g%vzGLGFi^o>+Ok3o^g#;4=Ly1krFPZlQXj;C7-udM3o>H^oe z2fGmX74N#$DKp5(3hCD@tqfnELPH{>w<4rz6CH-g#7a*q>S`BMIL>&yB$X5hVDcAv zdyrfPQ~)hs4p1@k;D<p>yK~q}oDe-!b9qH-I$run*Kp`(75fP`$#x*^?WCXdpvWc+U4o(z9OKYnku!R(^ zKl}VyxMQV{_t)u=dt$!E_QOQ8nXCA;c{<0l@U*dUac#DwxFN)xagfS(WuJJj3Jtz-D!Y3m@LZ!Zm#4RbnxGkFt6(BOVH5>t9~RA?5jaK5U#U=ymeWW*-pJvl3^xU!8}M zT7;vJ5eQ7%_DWTG^-%ej3^s*uf=O5fDF?vGfx8W0KxFy!1rAJSIT8!8^tCXb1Fb zz=j_0tpc)t6PJc&{Kxe4*B2G=0LUvbNjv*i8lGcN$ptVDV?hCxHlA?vj3qtSSUp_I zV=^D*PQIAojkcsr?YK5W8SYZe&>a;Gse^0MpW}i@f8`b^s}SYiNN+XE=YAm-yhm+x zQ=EtGCMKh*$6hmz@P5glsIfstVyQbpC)M# z+`0wMP9FQ?MnF(5bo0{*fF%KFpFo*#gh|R{vpT{G_6(cT)6+8XTysCQ(J?Xegugw& z5p-zAk`UVgUgQdMFd^#=Y(*s{@6%m1aFIP5`CbV$ClE;j_H&rHt*xzxM@Qwiynfy8 z0y?0oTXx$#^|N7*0LCaqQQ<$T{r*k7&csa!fA3ORV~L3w8q`#`a;YH4d@y^YnLjMR zBQPE2VQwp&$&4e zBIJ0VpX~+!odTD%loWw(fp(SIkH+Us$Kyp$FXLj}WJ_^gpV*55_21RY3JMC$SCG|% za8<{>F6~4nvX5XL>imh?z1{rbmcu1K;7;8}Dl7Vdvo8{ul&2}ept`!VK{sxmJxqJpv$Gu2xUDTIDS6rlr`1AgfbWd2?_Ut2pMeDk zE}!s5BhzuWQOV)Tu=z`Hogxl_Qk1@b7NgE{Uwss2wu)k4Z3KZn91y6pu{s_LoaHTe zo}(w;1k2WJ)SKWa(|b7 zC?*DX_g&Y`)iqzU?3F%svgclQbE?L>TC~J?6faev z-HotA@gTqAeqLmX7&=d^d8?ySbDN=ki=kbU%L1byCok^=Fmt& z9vIBmA|aOlpcq2LPRqK!1#Iu?sBz~zA&|&`+Na}Lu^x<8qI0wz>HN&hF&y#k?(SrK z4tH2t*9X+}G-?l`;0P@JfdsqZ5}Ch&;$rQcfbQs}`L!Q;KgM$wU_ulzjV?7y66eWU zz1GY>+h{&7l|+LC96o5c&RtDbHnewobyd)Ix+O1fWd;$3lvgD>PiO#5{9Ymwd?7w< z*ETeq8z<*qi!RYEezrIh#+sLtqgm|`q+6KGWvQ9#`x}6T`{>n1LClT2m`HSBrYc(H zl#AD*6W+h}}p!(XB8(o&@(pW~TRMYuwMD zKS9-dxZR_+EkJSodZ|Hcdtl&|RBs1|9k3xmZaZ6BTXI4g5OY-=!xqBCg&G31g}1-9 z5Ms?Z$j?&BzPJCXf70#&6y^yCU##{v3d__rJ)d#oumaSuc?u?6eBihgJ#K!u6$a`G zi^;E$M8JmwjN}TY9uNlBFJ^?>RgF27>os@*z?3;WtkKtZ*RIN7e@!RZvz(HHqqO@< zu9`P!J~1G-c#ioyh5kmT4_%YkzurSx$d-~JV+xUgMs+4tV+5Lxte2OrZIR{&MR*Ux z#LPF?5u1Be`T@}QF;-gC&(@QGfM9KHO$PhVUizqLwUN*VDu2j^Ttp+y&B}Un5~L5L zt;J=`=4;`n&uxNM8#BC+`DkG2c4i`J&!gQy6r(uu`T6)|tg=h+!6IsnLt5_g;z$PB>M1>7E z7E>HWl_lakA(t@rUq~SE@5{#izv<pn|A#;mVgJ|tW0cdtC=4Ra?a>xe{zHKlPzv4dM(TJ(GXJmd zv55VPUrlL7DiqKhwErP~ksr-Lf;@dvX8uDbS6{1Aye~$xgok=AvR~hS@ZcZ1x%$n2 zy`4W$zyI<-zYl*jg$R2^=uRGI$p79f3$@VgP_Te;cTxr z=DmImoj6EsP;<=`IoCMCWHU!ye|{Wp=bFfO7ZbC1_BJ_8&b@V)uuPLV@(U;-Zu*;U z-cl>n#lpsp_@Yg+`1D>ZPpHzJ>*qhkv17Z8prh^wysJrRe~$!|S-}K^{lWMJwe98f z{$=!8MF|o7VLdqDeydbUFIFk)XK}vGlKQv< zt25B{cd~@KaYXZ$K+kr_K7F65O!ZgY)G3hD4q#Z4*#7*ye(B}&K*$`%r|&~N$jHd( zcRePO8TW53zzXD{no3IBpDt6}i3;^TxflDod`1{{)P=ru9HE0J^73zb!2xQES*w1u z)U-eCVQ_|Y?8x*R3(QLobaf{n-$O@7XVIy4IodJJS7l6S|NL3KbYVCyE-nav-PGd^ zfU9)}(^BUG@Q!_=r+B)}%IruViQxtP6>QA$=l=+Uc@uK~4(_kt6(M{Ph7=KlT)3r? zDQJGIYDpcsl~p1I!@otwqzHJU5Eefx#~1<2Ph@Z+fZ&{XWPY#O)h(-RfI-eWR(f#h zu6q99>GA%MHAKThz869=JOGT63B@NOA|fHt%G_*xegVnxYUt|LTTkjzYJzrdYqq1V zww9P;6;489V`I=~?N7MNnGkWBeG4UPFfAK7KbaR+E4(4J`%NirGVLi?oo==>7Jf5z zaCBT9DxCcB>7_<_kYOWC>4M9uxhjQ27^94p)z#IYpseO9Z+)QVUxkNbPjksgLZCD1 z<~y2AHnram`dbo>KcCYag*!7N`Hz}FGGi=QG)9$dOho!x{n>Gyz2p>UE#$`437-_X7dR%I{KP*lHQr3|&Jr>Q< ztdAQ@88RgKs>>xkLZe$1GS~6@rChGup{6eVxRKLa>AWs9I^c0i%)5CM(2m1}ar(6w0Deyt6x`H(Ft>=jL|ASXjFSOX2$U>sJ=_Nv382kCPOG zlv+ZX)Wnt;b$o6A(l~u{EgVPsW|&Nzk)POO<(CV1fl;*Ok+4Ch(pw zP1Nz@LNtCdd2We;j&5gbs{z3^EOr7ivegaKTR=b@edw8(m@vrrEzBH&Yyc(9{G~(- z#8yL|o5*VA|Dpn3b(NIA_SL7B?#CG37O;NuIc1BGmnm~#afdHl!T<*+4ASS(3aD_9 zP7;vF?B87;S`TI&d{t}e32`Sb_9bl6$6_ojED+~qXyzm9O#ConIXMRar>gFQ_DkS+ znGooYMZ)#8?R0U`6v%+vX~60YQS=WDIk&zdz!5%SLljkvI~_x;?zB2G@QfPZhH@(~ z%?o$~SimJINYzWz(xw%mU&5BNt*vR#v00g0zU=zy?BW{BQp^dZW45n8h(|0bG~J=6 zOF3PFwvJBGeOapiSp=}m|5+UdPoi7w2eln5q1mN&kgvwyt4F^8tlHy7vI}0<^Wk(bl{_OTPZ?l3IvL$7Y!VY)cdqQLjpyg zI#?S{M$8Kj4-SqF4$xP@mF?oiix2!UB_2OM0HP%5&b@nPKR<_KKf=CpMZM0Avu7L3 z_io?L!KD)CxcxP!u&ogUnX!v^&(iSQ!~ds~vMcB5Vw%MMA`%mT8hK77i`D zCWQqCW|hcqL#jqlPx+e2KHk$^M5K{W&!ex`Bv&;bYMY=L%ZC_Ui=V>@jg98BdfXE{ zJIEvNTYhQj4xnq4X@(uq1M5yF2Sux+6&PU21g2k7F)Vdk?FLi9XRuFLE>}T?hCdzb z6bXwzreTT~j-fM|_l~Y)Mcm`T5$|YO^eJQ+OdM=&1y-{G113kS`gQWtzvA>;t zQ8gj`pQ&SRQc&#}*7-{@du55w@4wELw^d7lsI%qjKW9smHg8_p%RMA#vbnXRez39P z^cmk?08!SeI>DSzJ?H8LRa3N)kPxK4J}5$vcl7j_Z#5Zq2oVc;RIVD{zyC>2_>`J- ze~F;PX_XLWlUS+w5DSPyp_pnWBi@S7<6xsZk>BDpXaN-JMSew&%dn$V933AEAFtRO zQ1~697C+;lOBk1?5#IUB@0B>&j#p$ zaG*K&sHN2b1E)gsLu2E{joHtjy?FkwO@J*Rg&mBnOwV#8> z0KxQRgSYnssw z{7hvnqo1K+T#_K=g%eLp&r&{5eFcdSRl=J;2+1=dII9VRb*(>Fc|2rrTqGwdP-Yc=GkKh)~3+otO3){~jDYw+|$;k$K$&rK81`mL|QEcfHPLN|X{33t2h=Z3O6nDVqxJhDF7ps{`$qgOr}s^_+d%mkp1W%j@l zgp_KXvw(VdK}}4&<0DA9p>gH**`wSblTQs9bUUk--bLaXm^p*{)qZR+L|baQLy1`Tp{bT`74vfH-F0`yM(eWxU{^E4ENU` z$&J+K-^FZg6+bs0tV6H)Bc}G8sAn5Th;7$pD>iRMn{sQ%p#mIabIQ~(z>kl%I=~Z6 z($C|l&KZZaH#7X|&gLFE<_0Tse*(YvA1D=)?1LsY(jMdYhLp&B&k>czL)A}2i=`B0 zc;LbSrT{KtTl+$XObIChpEc^S!O6*ms5RcGxEZzWU8Bf%0!8m*qs~=SqA&=Bpp=}t z_I1eWxV&7_e8a(XFeEcQ#d*@S?r_ROCG)3~I`UO0{Zzhl9k9kGAWkQK=Hrm@?EsB( z1pzj`&sW4m_7;ML3F9kbw!dIr2B)Ib6# zybLgv0z<16OWM%#sxz&S-Oqg*sR^&`E^m^MHh}ShN&iAiN%RZI7FB-Ux>O9BA;*Xo z`t122;7e~k3uI@})~wqjNuu*lZAA0V9ne(Juw;uHwEVvHqmzD*vr_x(FH0DW~JVX66*8G*$BU_Qx@3pg)vtIlu*}>^n zH}y;F>F#nSt(Dv(biQOap+X7Ds|y!Sg9kpSGRE6ZeckFA6g=;2b~O7X`J4jDr-SqP za)*1HpT(5t)pg|`Q)~4HehAN6!T5w`kSf7hyd6tO_qwKx*Z%jpLiRfUwad;tBar?oSO=?_Gxe*ii_8SoTARHb#NWhwZlzBhb7}f?)R0IF2lt* zVWhkU<{ITI500_-YOuNKnV43fc&=`DumJ<3(CcV16P%@Ae8vF!$&^DkUam@;CGrbw ztps>@_aLN)J$-av8#s6y`ONVtY6w}&NG{=g-|I-SpD)Q%Rb_C*CC*pnI`xRQQ8gJZ z+rQQmSy;Hw9@ewHyGcpuC~^3zhg(@?h*VBUiB3Y>oUOWsk;2_XwQymrTzWOhvjKx> zI=neR`lfTPVm4cBC!K8K_|c3t&4)NDZ|bm`r=_R2mLj&?>B1UJzhCp$XS%UG6z?T1 z(Zv(vT2!1Pe&rS;zuM<(le|tlWKYHKVLs*3+9T&Fe!8F9G#zpBqKRAqckhB3Lyna+ zF`NF_P3yu2%cGP&WjqC+hU8PbK^7LgyF>=J!Bb7Tnhi-9J7C*=Xp%R-qY#4v%vOG3 zGSFE?E7sG$l9`M8#=TG47q03=&$yh8r1i1j1;vQ6#8hkNXS3w#f6uUM^swKf)~&SB zmzBlF9k)e%*Qs%ORlC&ph{BSZH}2iL?+3GwLP??Kran^1qyg3pb{S|zbHd!_| zlA1j3Esvc`gbh=qM5ZjUHy88E`9;b5;n#jkY2QfdxIJ_xtgfnVw<6;7I8S{_8_7$@ zT__7~qrbGYJwB?Lbd81G8?keq7vf@Y1kqINbyP z`=@|_mq+EqFu7`F_75ytNKMv*`F*1a^?Pz_GrI> z#w3K+ULxB+P#jY(CsH$<&c zQGJ4-@K#pkD!(!h5n1cxhfru?+#{?vv%U`20j!zt6HGUG9Dz&=?72oqM`t}=J)K&U*9w^&!@1^^T$VrN5<>vq;uYsz61i^d;Fxmh|%0`9Q%uE!M*_j zO`m*4v28Uqcny(JfUN0qRYR!h>}{@|$neRUHCz@65i+ba0bCbjEd} zsd!#;fG!vvJ%@oQFN$!#hGjHiHe-BzoRG;S%j-D5Vj^c~d6|P4^U`hZJo!5SE9#sx zgo8UhAOYkqKmJJ7JnEM_KZaqol=YaCUVa}!qS>y+v)e{eXiw@4XDE_$N#5Gu*Q>H4 zz=fUNI-bii&wCYWA)oQ9W-9~c>{GI{KB|@ah9o4TGfxV%wnj#?H(%5Gjg@&%PLvBl zFZUz;QS98^!gWqZ@~dVE`2|DGU4gouzuU2qcLc^RQ-uW zf?BMFd%o(9d-*zUR~)iXr?MN=?K6k3OQlgY$0&O9|E|uZG~k_R=j}d@%Ug+x%aJ08 zOL(=_u36V-X4+PX^?H~IDX2XafBDH~IICu~VACwnz$v3c9NSd3e zNOhnC6Zx}yrUvH+Pw5n)PGo->-<@k$02gb4Vtt7LpYNNAg6lVuU?@C4B3(HgJ};mzDQmo8V4#6{ii`>-VRXvN}6Ulcu!PTRuV9( zzVa$WL$h4={}hCfeg8hW=U!Z=DYS@sd9G>Bx7|cD^uO9}H zzpXRA7B!^UXhXt(Bkm}M9<|$;gC`i{j}h!fjbG|=V-j_`$k|HivhTvvL!U~;X>7GV zp5qlg`Sf7CTBn11%xxK2aXjDz>aMr2KMXdE859YepV$NR+|4i6U|E%cY(?D_7}km=tv}Iha{gZm8S|%?H`P5{OKQi#Ja`!xAL)Em|-22-{>z4Q_lXax49<_fVc+5KskoJIVkDl7J zMA85t_-XUp+}x64mMaM7vAa$awXWq#U@B7Uuo!VF6+p$Lv9-53g9yq3i-#B%9grI% z+o3f1VIV?C))yD)cU&P_o9~J@K5qIsKMxVW$O{wZ8}^Ck?6i>*2Zh7h+#%pITJ(Vi zjpt&s{MR364Xb=E&_I_`l6Y*=>(P&iqQ0h28S7reoOW4~1nM?rCi@eU9qnX=gFkxz zKgy8Z-2+cvP*S2_#m4I+M6M7>Spoi0Uz|&EfMYUc`#w~p>+^;hlCg51$~`8|lC>fV zZcXbm@l{@i-W9&$v3bL>Lme@!7_Ng*CH?`W3oXXX!o=t9O#FK;~JF=Kf6c&D#tXgXMXq4 zLnwJ}8~HC{1{4IHSlna1ntfV@d_g?gB%?hGj*JaT$y^jZzXXpJ&);HV=kM&VGq3!*+k!t4& zy>)A~>lzMyFA`R|qi};iH(|_CoTOFOr<(3ocjXRQauFjkGY>sRioW8f_C!X->fg(5 z%j#BT4-VYSV`+g5<)~z3jLE%7Cp`taO>zOvn!>7}I7R_|@LV`_`rT5ODvamH0Hx{i za`Ju>oRia^A+x+9fONd!w$g^f9L$&)BfngC;AZ&o_;Yl0bV?0i-C=@zS%L2}GtXCy zg_W+;Ff*@$R?z)tcmTJ>_aB=sdn<=bEo>mo@n{p0rMn!Bc9Sh45^t`HH@7&4mY4S~ zFa7O>wE@QH5c0%o)`qMVF~7j~A+kCSeXcix4_m%S;@*vwU4QZB?OPstDnVXemrDJu zmL2!CYS)?QuvHpFycTIt^K}1LMPu2X*tp#?{7E{)_rZY!#6%ndp4yrg($WS7X`8bP z;q=01efj1?*5xs86LYL@-k{*8;y!NLo^Pme^L!@OvN0v0C+Pgur7N?wO}v6AYJiyc zF4gIe)aeVC8%$Uw65{T8D2g8~DA3(wW>77Cqqa@Tc%4JQ5Q3*(kNP)>cO+@`noKu0L^r zqOh!E-v+UZBY=X(4IV2+kMtmYXvE&!+?+gUKUnW1V8D!%80-YV=xK7yeccmQ z3BSXFypE2Jr{_z=%pTB8g=*$C37Q%opL`;EkjQp>W)pFacO*pu?j#F|f21IE+;Q3OTyDQwQ5jBu`GL)_u*-VVl>+LEq zaqjVRQz>)ld^LLcMSC27is_Lx`H?-?zwTR~40bN+oKH$xTIL_oJXE;;a8vIlMFM6m zn}6w<2n|IMU>bLj7ti4a<1%H&S!K7ETDNqxyj)--W;w_>5%N}HsCc5GIqoiaBU>^ghxXGs*7&NQ8aPlTp zZ718$gw2I%k(`{f`Iq}UMyPnnX9F269f`Xm@B-*bul)A7c#_5L6g#L{XL=FsCMARr zJ`>m8UBGU?e7qclEB;&Z&?ukD@G9{79HAdnk&8?YS6W3D4RIi6j*@1k*Gt9L=k79* zbl9x_NH~0MzXDOnLb0r&>uI8v-YurzS`D96maj$x;jgw{*+6~#0u}P%t<704OB2}& z|8fci3p_{qYGody$QY zeJ0ZHyee2Go4m)OtYT?qhKDQD&mqIkBKE$ZpxZltx!Rj+hSJ1NX9z48$=zIl^61Y< z9Ob>*A2P}RU4zfY;?Af{1vly6S{zxWe+}b2@`ZV_veAzosZTcs zH~JPd&}@hpoab|~BUWjAMgLTLNzvI_*9sf9-`BwqPHQb<5jfd~kYAKP6&g~|Q;Wd_3LszYJvT>sTuKgHn z2Klu-A=mK>s3HmE2T)SNRNgbF>6pTFzJ=yPZiE!_xT(0ew%lL6|Dic9qs)q1bA*X@ z&v{SJ+3d0@cnrdWJ-NoGpi8!P4XyMO@h4g+@q_6-bYj@D!ij}emtW#);QeaoF+qiz z)7Yl?;PX+X06cvYR>O^%L`me2qM_l2qb9MayYp2vHhJXKI=zp`#K;A$cuwEV_N1?t z-95VfiAc*^Oi|>{OZKM^*SEnze|`uN!J=9dxs-$kb?@57@3!10Xw2`qy*G&!$9~6e zEPF$AU7%9ock^VMxSTtbitqHUuZf_c2_h9+3+`mp23nJ5IzLF>G3@E(T3#)3duK4s zr63{D$ic~(KT_E-+sC)Yb=OmQ??6G~>ZT`w8e7Gvj)n#}ZNg#UZqp6&&2XwmnoQA2 zarnsFf$F6v4?)uAD}~r?Syj2@EYC7Fp@2}vb0(6Q{v#thyXte~S_#&AWqexfQ}gAf zchqO|!snacl;ognb}dRxclBE%v=~%x@M`uxL3p>cwEQ*hc7D1KwIli}DtazctzgT# zZ&Hi;sK7w6k-X3wj~=@E*$Ul7dm@6YW^KxPDPZgF%&+odOn2_)ZG)P{=a2dy3ilV{ zW7VHNG9M$CtE?BS)wL!RUme~ahGM5m)BZLstE#HJ6_;L!S#@YAp+O|FK^I&LH2>b+ zYF^(nP(gZ+_}DB~T2vgz3zrNzILpImEl|d>)U&T}F=JuZ-Db(p8|36upmZ~~pCi;g zn1p6;duvwX)hc2j>t3tdrz4ZzI-V51Ug_*0+g%DJgN3?vBOrJ1*k+c4=6B-a{Vze$G)@zv(#$sRqE@*mEAhkGgH#dWQV`#y1g(@rJDB z$(!?5Xd=SKZ*hZ>%*!<=<4*j`b5=EPm}%EXIjMs#lRw1e^c7yk4)e;=EG+XjQ|(rE zW}>7FH`?9uE73~+YbgqbPuu2KSr|V8iB>ZLq5ENQZQX zba$uHozf}Y-Ffb{eSF{N{LcCB8{_=(?ZMasH}19Wb;pcrUUN>tr{#IoTUCIEFE2X; zg5ujFYEIqv2%J$$nysDcE;r5U4~HiP(-ygpMw43zn*ep_5O`D`r%T%a+60OY9ID0dp7`ul@coxEjP>uMPK~;s& zuh(Z}-XcpCTE?tXT63COTPHvLB{*vb+R$fqzu-C<%orrlDsenj*+^zz2GM0a?>dgvIV|EbWDstM39&Qc}KurG&$<+i9Z_Cb3M&OCKUF^sYuNu6rFWGD=5^e4RN$McX(A*Of}OI1k}Nr2x6f##ScKmR`dmoX)d z>G%f03;H%oZAfBPBd<%y^aN*A-|N*Kx}FW5!!PEY!l@ODG@E?@gXA3SY>c4L(9mHn z|L&y6fK^Wv93Ou*?X;b*dY%taOpZ2cK$HSiSWHZ{Q1ffQ32?nZv5l6}tgI(o2L*7C z^X=;~z{B)k52I2C0*?H$`~q(Ck14r=`t_85tWxneD7V;W0QCM3-i6+63ZJ(P6;A*% z=kUp4PA~p?S>o!lEIv+sB(*zfCId<5eO9xfRyYDjZLYA#)4aKn`wD#Qxm98B@3!yht<;)6VKP3yBex2FLqa6 zUnSTQJwrw9FBy!o`eU8DnMz?oE&`4@bQs?x+vKIE5430pT?nBa=S`2Eji#5X3LCRf z-e)Nt%iZSr{o1u-d5~{#aUXr!Aeowy-tc`6WqnREN#Xc#>~~*p@6*d|g0+fXFf734 z^f%>7b1`OxU^?)E>MG%cvI}hmA8$SjZsN-SUf51Sj8D(G8Xl9kxqCX1O)_jFiAhg6 zx4UXO57Sh2j<#o(#E={+6TE}G9RT<{3yX_3lj%TH#R$)N61eIQWy!YKXlT5drO7|G zeh>>q4}ge?L3mX2qy1=QF`$mt?^ z1AF{2@|31rkyJOarc45v7;SC0|z80GpurmOu1#E~&ut)U>w7vNGjMCReHixL5l|>APA@lb2@d4&17PW$TOFXit zG@mFL`f>>HWI>voge$9Ju>uA;A6_2%Kbj2 zR$KIDV%2t=PP&V%-^RkBIpw`v)?yA*XAYByukwu3r1Z>yfPlifTDX0q$7Ajp368B| zgTXZMZbdoUi+HE4p3csFa_<=zyRb3itG%Z$nrv=CXR?#acvp#+2Fp;+OB3Yny{M+E?sE2+TU(K>^OeBV z0a-(`3JNP`fNKw4`o-{f@4k3}P|D#)g#G76NHw5qQ^NMLg9`Y9Zmy660;_T(+5M5c z>Zq6PO0`}pQ9DgmZtv{qTfO;PmD^x7$(I(SR2dr@Dpl+>1UQ9h-;5Xd9zzK1ew>1r~9i3h@DF@Z;5!K?V{Gb(ESLNZUm^XMQ zud0e}FJm4N7CNfTs%F_vY6dz$fI>Iy4wkz2w1BF~1?@Xn3ZPP-?ZKo|fe&D>Donl9Y?vO2!=xJp@~G8KC!7R|aXgHiwXh0151Xmh#wqmQjs?YxnnBf{}F9FFNw3>nZZ(ch9U4PWn z(lo8L)Tl`Q21+i&mpMib2Nr;>0A6|<;UOW#I-SF6pbN??1($?Cy zF5ELbY7TlM>8CBLZIUc8lA?GE8&QJ+;Eo#mf)9HW@+n;K5r*aLI`ExR(d9bs-wz+V(JUNyWUvOMa-gNsO8`34cj$FT0#RbSIE>)6Kx zavSLeBnfG0SqU2`w$cGN`_VH9lfmN{->-H6<(t~o1-jG#Dq#&eoPa;x2exn^=UJ%N zzoa;ui&|F43Ub=M`Z-z>M6}n8dd_pTy%{~u4kw6V-s2ddPCzw(HWO}= zP;;H{pDZP)Qgo1neZ5=Nxrh~Hl&505RWl|qelo;5JyB?s&qJP@wN{cDl3#uFAcmQN zf~CB5e}NIsW4_`Q&|7x5D1?Z?V9xyq%y_uXD8Fta=czP@ z!VisIi^h-;(Cv_$j_uo(4kjpmfiwAEm8yRWA$-yQOoKtPv|t)Cb`U^8Kbtr!KB#Z#&Qvzm+f1%`L&f*NkzI7 zM{y!pswiy24*Z88y5p)fZOkvpiN8Ig?rPOr*U;-*$0j@S@wWNShh52Y3sl9jlh6Fv zG#@aUH;-~=Donbox^>8@-oNhzBIk`t_r2+PV|YWMw{go!_VX#KgpW3qsjqFBr-F>$jp8 z-#pcv_tQoNi8jNfcSJ>nUioCXaI231$_ngOz`ua7bc7TPgjj5D--)W`q{-BNlVX6PQ+w$ZxkcgZ@T*tSl_@akdMD!$De$Lgf}v3$=btM>G(ppVEJ?z88}5sjzfVodapT0>!-Fh+aM7GyIYGdgJxjx6 zAlD>GRi*-}8+c*8TRwO1)TNd3t9y{9IVx3l-KXfexYSnhm`2k$jOw?M&>)o&X z^r(s0{nM~~XNwe)0(u69hGioTo2jiYGO~QuxtqWCo*JklZ~XcU%#a&2q|D7~#&)k` z5m?U^gQl`P&pDut0`CYK0*LQK4HolUY4^svfZ&1d)|C7UBE~d5K|TdPzhQ0&hHw@w1LuJjew)JjaE=$HJCB@7Pm zC)z&;_@?+zU==<5GZYq)+pv%NQLkK$Eu}sWt~xGHl_GmGQd*2Q-Jid(ZGOg4dG%C${ncjb_2sz{f$8}4Smcxmf9dA@)Mi}hBtEgQ zilXe`80--Meu&VXn@A5aZa@T(yXf=!ehn8%P)y?8GsGBxy8umx50OK0nr~ZKAP;6x zjpE=fiQDnPulpZGU_oDa`5pM7kZ&?L>W1@GVl6$_2t5RxJg~19NXSXMVyxtJWW_8kH7%vy_DyBW zQ{!^6+o-!rj(I;o6~>LLT1ncc#@hD|2_w>Eh@18xp`^5s5^%J%+%a!l@LoNhwApW= zA(#*eU@d1u^K1rAdrxdUBeN0cfhzoTl?a^NyN@LZBCv4LLn0&fonJd@cLc(%*T)ZI zHc55T*Kj?WTYK8N>vk?qxAW8po}$t_+YiY1#xbew8x$7yn+WKX3W#?J@z`ga{P;;X zE`pYx_9!7Vh}%)-{fG1Ry{xLUyQu9^(PK-zP=7xE=R7@X#;h+vX!XqlOJ`FYpMNC&7o}Qk@ttEge7LLmxnIb?)3>|1njkqT!J0CE5psMii+9zv@ z(a`3A;09*MeV5R*)oPvzIwBElnvL!iAgix+jIAEPkgyRtQxS@O{Xl`#ScP?vS538J zE&iTz?SBHz+*XdZbWjzpv*!Uscimp?MW9+egC@Zj0!(m}bj5pnVmZGT73l)4|KZFi zy3F)a&PcmSR-StE61p2a<)u&Iwspm=8-D`1Fvr+GvDSl94!F72N*oA-_TJlG?f&C4 zn^1JoA#{n_-!_W8f&h|1&7{ajf?^3JIPhI5$Tcua46fMz#mYSL8gIx(%pX}Vx$&tr z>#(kWsiGh``6%_$N#x3_W-xH@`uxX5&6DHl1TiyA#5uESZ zv?w`Q+Xw?(BTV+;ml!!xD+<{k3f_O$%6;3a^!2msgXcz=59Bhra7RoYyd>8B-#(?Z z&XEOy>I&>l9<|EacIU~^*+Ic`kHBH;puvRK$UP{&ULrs!p!18~U)yols&ck}JK$Wp zuT?WRAaLNY0l@jbJ;z!QmM|?><|85^0u%{iBBF-pGCa*nUjs@X+{woHXG~S~azGD) z*kRgwEtVGiXTskx{(M=QZu6A>{}}ukD73*PF>>f5Z9{PGv1$5r3k_90U9Zo&-+I>z zFWh|q>$OU1FY{0;$s_4dmZDBD#c@dn(G#1t?5ZshEV#=~rM{(qo*jBh3We0Z!#1VN z|I$Sy?u`cSEdO`HObk^8x|D1lK(qDt8-;S0w-+b4@=KD|Hp4Y0jG9spe;Ct>m5sDb z&@{fM%5wb3j#yj}2b*E@2X@CaAS6Dqa;n8G`Ry6wXg}U4RzzreVxwl}9(An^pgm<7 z*~9JM=%}baztcVcXe3=n@|O$nKh}k-LJpV?U#f#6dE~!;4EwW`hPmAgn!{Pr|5@Ol zFTpIYk+-oO6+DP=O)QkxcC3CAHiij zw;od+W6*VWHWQtW&RfU(1k9d9j1vPfYrp?7^Wd)kzEDc-|Mf8dKv#f1B!)IYB|JA6 za;zI%0eWi!h;)4f{y|P|$((IqV1R)!UB!8qe>wi#f7*!xG5NQ?rmg=*z5%=Y8d0nF zfCX$E>i5?2#l#F%{t{I-A=zW7q_ro-`XE2;x!pwps8~3oSq?VJ`FQDd6$n&q)t%!f z?;y&Zxe-zzOoAtN7@*ncX{VR+Hi%Ng9I^D?92X$*XKj?4dS>gT+it+ho{+O^U6G5AsWC6#1x)~jf% z^46o0#l*Th!svY3-0!2));l|_BLlM8Oyxd0aMg^*KmZ6t zESSgZPrJ?T>Uuo})eIaQ96(#4yqp}B>Lc|CRa+1_>>QNfAtmxja^7~D2@DK;vZ1b^ zP++^Mg!+`nwPA13gcB;411vp?e;dMg2@wl`h`XdvR-}HRlIS(tMcS3->;&8zPl(Gd?qinRaw2X~622wkzu|z(20%7M z4t*16D^nl>ULFXro(gDb&4UDIP)wG4uFzPEVLk! zrLQt+HzRSpIRS9KOHi!~DiV0K!I^vsAW0^MCV3T$=E=(as$EOO%CD#c77LIOxg+9V zZn_b6QV?UI{@K5=!jp}$(M9?l1vtw3%=Lu6g;BKeoL!k;LrJiLK=Z$6*aWT>ge&3o z%`@c8HYpanV|q^GvAJbsEfpF0`K+{{>mPjAR~=^r6gVgtT*U|qMmzVVt9wC|ESL_! zzU%3z5k%bY2Q2ZwbS)eifRibQ(msiK(9ZiLZ2}6(Ute9e*l5km>yh-vahQQ9ADCrbMa40I7Iy(6O00Wo-WMExR-pB!Oa`4tV7sTIFxu>CmoG*v=}v{}9? zUEwwvOC{x`S&_9&(eoT?InDRK*Gu=HQ!M%=v<^D)4bCWl`lSjA6hz>>tBH#G^+$$; zFj_4s3M_*_wh>6?Z>byf_xA^-0IEQA%#kzQu6MX3KQ7dHM3v{ghzNR61gNdnFL>?L z3o?!^r))-+m&CWnMG&ljL>}M&Qh^Hfw3j+|e^6jNAkLB? z*JhZ*xm#SlcyMI5pWlb5UrxGY2*ZrgU!o*H%*)*Au-;WhO_sp9ZTBgSV$njBUi>hV z35Z$jFU4`=+t0T=u~Eei0+k5v*j~*OT-%SR+JP2e!y_ZtC#}Z{)To)6lsY;(0&@1_ zB?dG!G$6Ij+t>FYo=)+YDng$Td;A&-2VpI%$}#t=|(2mYg}|LQek*z?N6WD3}@)0qVn&aSbL7hZ|_t(J0^E7 z4_c$rXTCR1p&UqQnh<3vZr7CJ8{tpMYOY?}mmWIAPgb5?_%0+Y{CYj7*cKdVolf2S zW4)Gu!4|c__V#uM2dkSLf`WqAr@hzv+t*jb$vva<7J!NItQ-`HtO1ej?G7q=96{83 zT%_dW%bX6=QLt<{x z^(l=`A{Mof3u#UnrCwWM^A(omZ%Q}lHtkMcwLzYtPp=-^G_pR6#^a7q9-K(v!~nG( zCICkONQXRCj^&;>c~I2x!-p3{pf{k@&}>`_kYperUMaynwhOWgBO(&~yu8}Lt2>Sx zlp+E7@mjam78Y0x(Wgu!uB%E)puP)nzGw)!dV}bX2hBcB`Ur;d43Y~-#6bUc=}KxP zi~7@W!+mMztc}FW=Jz9N8TQOUIwf+Rz zQwwYv5QsV7X&iJCqf@QQ>AuR!0#eOvYtrG(CMa&rL<<5yPpv_L$X?JDLH0TVREKHv zd&-E0hQ^Lbj!=QOME)TJ1Ml!BBD~R%WF;&ZE9onr$Hnow0qxt3D|RvLe2L>T>vF;U zYX+t9P82F4RIJ1C;t=JUxQ8ve#WX4vANYV4nmcslxiJ6Yr48ywX=~rM@<$V5ySph}@3mJS;%-M0DH{ST%4N$tB2 z8RmoMX{MrHzMd|6!MFuJ@yv?zq>5FI{Jl}!D=sF683fAUijLS6kJae+vwk_#*4B2O z#|4>3pb`bJB9=ic>q;3gCHiNsY^(fE?(v+wnH6q z412Ms+sPYPS%f8z{eGxX;w4nl9UAqF*~&U}C41Ek5pav(6M*ErVkHl_nFz*?CnAR8$-+ENMA8UGQhSAYX&7V)iEd z`t0n?6aTE%3rMTu8F+m^o2kkfs19*5Y1PZj!g94`SARK}a5eeW?T^~*{cjZD3!;UW zI|!_+{YISS=)1k*PJ<`Ehwsd5nRVhyuJ2$aSjJ}@<8drnJJ_I7=GuCTJ z0-zMCYGH3TR-{vZ(hR4#kxmPUTJVg#JglH?z_0==t^M*D>p?LBP%|4UEVMDYJFIM` zYSD~2<&F1eU;KE1kf4`P@Hp*m?7NM_Rkeit#FEOyk}+ec;Cbq&jk(?|`>A8z0-iaj za;TJ9oV9@mKVP=~He4Q0s#?1%5#y~g6e~H3gg@kS#t3 zid_2m_#op^WMyVT#lGC{KOIrA5|fjY`zR6E$_P5i1Cms;HiIhs^6{r~>ToY8Knr#d zI%;4|Ymaf*<~cDO0g&Oz83y zQ%Ollc|e~q+7>HNmrq)XIN22!6@3owKs@&&vaBTiDz!-U(74Xt`agUZSRcWM^|8hx zPr(3O{4rd{KwSK&DGo+N$R9uq1fzdw$gqQZ5Szz1baP1sNy(Qszg{$4$ z(6xCh_{VdIq$Jf)0m<~^EP@f?3M%3C#>7Wi96uV2^;0O$q!L8BM;{aV`e~tE?<=w1 zt`%>9VnAOmPf1Dh^7=!+ef0yP!t!4Ki~?r9WT>~+pew^bwUOw;^(MF4rvH&WbArUk z!3Vt$nDkK?zKUWjtm+}p&;4VAYX5e=fE_R(3^^X)c!dl3o=)EPUKSVPR8lP8_eH$g-GX;CmQB%NPCK=Cd3_$D5hSoFj!ojNEssfr%7@F#99n*WhWTm~alLwe z=z8wmAnw2Sorl|@s77KxpF3e&LW1+Uk0625_JXgEMr_b_W3m`Tut8M#79!w3bNSo5 z;g;t)gE)vN_x@|gB_@C+m*xMn?Y#cLXDlXTl8Uk+A4t-#slccL&Umv*b_!7hC)ZESym$`;DFm0Y`bo3<6-?2vh35 zuc{LmV+A#a4QP}kmN@p^J*>-as)rQtfIqA-4%Q?oyKkxbgkrDoQO%EHRA z$HSvwKUwUcf@ zeQIW<4j=>&F|e8~x!7GR8+Pol5V#C?;qeExv@8V=6a}aQ$PkD9-y7&pYy!Sy4q1q2 zZvo+hIFOr^sN8jXd@`2uQ#lmgT{?~6Uzo0jzhW^-@p86kx(d8@s!l8GnQ7JazvyA^ z)LYO!1s>4(!X?}UFN}RII2F70Bya=_-Al!45h%cJmVuW1y4~pjRj13>2C}m0VsXw> zrShXWL2n8xx#GzaPR~ER6ZDfQNfJ5=6=t2W_HVuz;*ps&?d90cojB{Kg;yy_l2BRA z9h+8+KYR7tjNsHaZ+)irD(g814~Kn*0w0I~rAu&JI>G$1A1k9MyYKMx(*a%fH-w1KMc@tBf>6`yk+^iv(@-ynk?ib zP0B<>en;0%n^T({-210Yva*8ycp3~*Ok$u@(+~LdX;e$vQlhY$>tP%SB7$`L6AOd> zeiqe;&q;Lg@ZVuSxZw_G%`_8st5pib#4RcQOfh0rw18Z3y-L?xp6BmscIZg_4vzpE z7Z-igjE>V>XoO#~$v)L1a%Cl9#!j#Gsb(XHCHuvmJ#_+=mk5del|^)kDA4l=>Hk*| zb=oDPP3CkylGl{|y2WR>Vq`W7&c+lE*=)NqrV=*uPtU@~!KtSZ5f&@No>1K+2)_Y) zhlLQDR{zWcJBPe1CWs@016NgNR~za(FiCcU~%vlKrn>T5a2hLu;E_5+*cYVeWG8CB<92Z3@gq)w3437VC8 zwFPN9^|?odw5Z#4?iDa?QTSl+7T@3&JfIF^In z0?+jsS+$gx8TM+c+~1dxywN+xJiWa~+Y3?|(9bOBG;VTr%T3W^UJFeMj27qxiF7h6bu=@M@ z7OboA#P!w(y(9#gb2gPUh~w~vhU}=4{u5V+-NanN)>+p5$^pVhptU?8TY#=J|Bu4y zKgDDrPWKB$x9UeokUGbC3xbGUt78Q4B0V6F45S&4x2RBpo(>ih6HyCzdbFg;tHCAs z1_aXMLs+dk7QF8_&Jht&-$Taa**)m^fFH!dK2*V`s1`=AVI1gwc{To|o=qx=qQhZ% zNk=S-37ij#7!WXS#fRsOsy#d71hGS+fyucQB^(-L!C6jdYrzm4QyhhiFE^mTHKS3Q z*9INiigo3_e{GtcOX-Mp|EI>5=EpON7SokhfY$Kzbber<2xVD9{C&w-4v{9^eeoH& zk=Qe1v!8%k7Uw_aWq4LXDNm~&%%!6#Y8{%D1-9{qHh=n(x4$=5(9#kunYaHZpCmrP zuS!tihP$;wF|vFC&uQ*|r-MCmg#xj!K0uYO|6hjDJpw|1g#zdec6k$$8;__FtD}U$ zzo#pP9b`fPe+fm?@5Li=l3FEM?Cw!*J6A357lmMQ*ls!;z~gf>a!l=70fPwy!EIsOjAZO+mC8F+&CBWRN!d8%MV% zXZ-&VN0-<#{_&75629X*2W*a^b(Va!@-cn9g!rX>1Hg@N=a!Ju^=MCdmYgQGX^qoT zT!q2|OpGQ3vWwt;YY0GL-0ma5DO| zatyE*uBV39iHV6ko=BXnRfe<}anrD{RCQ;73wS?W1uQ)RwiA9Q;kjR-ze5-X1Op#T zv+ij-$f~-0@mdh=Hnw^Jp0h}Xzu&s{RXvF9P)!!XcyS=x`Uh`{J?FG}D==Db{BKFy zCS!H`rL6L;O?;PI5j>JZ;<44c)6tJXUcZ=_@Kv*mo$cu=$eUP;zyLYh&G7FOK9VJy z^{+fA-tU~Y=7KgJy!QXj+I9n_6DExKv{&~?q2SLeAy9C`E(;Fo!mh-Dve+C%%wl}O zAs#l|B|qOuvV}nMm(*&%=e>}ZnV}&iyFJN)WOUm0m2tfnwQ5NLE0ivMO9}!;KztCo zU;_f-x=ZoLZZ1^ll2@q`;+s~jRbvg%LH>F4yxPccolGaCH#UAME*)OrcscAp@dN{J zqlW~tnT3VLP*Ecqp8|LE#UsB#FaP@cAQ%rrRK|Zq!waj?5%%O33s~3SOYb?+a>(;vfseory0eQzOMs`=TEqxr> zS!W4PrU!|8YwcJ0Cs^SdmTb>yXneAAT3Hu(s~> z{weTRM2R8VgqNQMw*S!jA$y!@v%LC*2}|>05%XxB$_GtRF`AcTF9H4)PJ%0K{Fuck zH4NmuY`Rku53gH8Q-qR?1hf&1D93z`El#}LxxItT^PNSd@#Dw7SfQW|&I3Xr@Ni!D z)9^x?jV(qug7A@G?%veI;w))=j^KYUKxXj}&VN=qnx|9NN z_+T%x+88tm5v>85XkAhuN5qX_@i9lT`CJ8MgakucS_fB|hX?I`QbofNR5q`QFGl|0IC9nRn2&6-UzebYenJCKMVLE)C&5oP>y-a_=A!?1s4m z_P9CAu(0sKfm^&6K{M}CAw0R_jS!d_6nSu8&+fbm6~iZnb$d|p$iyK8hT5}2%mt0* zwz{On@03pRr_%lm_~E6^qz~U~%Nv4kJ^;^NI*JUN^L8dc1ci3p5i3@)*#Bj`wjdHp zy7=4^s#O$8gB-&JE$<5`^zL@@4sqk_l{dgDfF;@>?naA>c8eE}UB-cA;2muC-Fqo_ z`Lk~h=86WDr2{W#29c(8}T1HuuTeVm#3iCUTcYiPY;u#8^}n$sXyj{?_6BzzGLY9?@H7)0KO zx!Km9AECb8m?&mNJSzf@ViSOzQJCw()ISRdEeN;~qFINRnLDsSU^{$>dw_HvZ@^G&5R)L10&=lTNISh&$#27W@eq>Xhr!9l zn_9EpX>F|s2~R=W#5ehal(h1S2CvmKZlY8#MDY1%wg^4B4X~mIgwVK{c30lyf~D7> zmxTU_!V0ZLBG(IO^YC_Nd~HUKnc{l6Bn@xW=I9q|&ZLX+}>3=Q{2b}#92_Q*O8Z?Rp!Fu&QgVw809FZ&NHk^Eq8M-ALvHV+L z`N4INe&PT6=V)z8yS|BUHkMS$g9fZ5>UtP5vIDMouOtQs9E;R6Do~`#-BXsPcZbU>y%HIx zmsjGOpys!FYEjZH%cPZ8@-e*R?|ag1XsUV72PP*~Ky#*VUDHuzAI6w zD|EyG@s&QV|Nj47vZ08j(^tpf2OxUM#Phwhew@0uH%bdelkb&MwqnG6l|zlqj%-vh z_(^Th`uF`mzo3#9lT2Lmf#*2rk`?{ngvMq#6_U7%0$ z_W;82rI*oQY5pt_c<0y7J78C(mP6bB&rE_FC{Pjyc>JF?_&5!=_Bi&jnUTPgZTw$v zy}Yoo1eaC#$(R0F1+W&?NIhYt1JA0%nsz)D2LdVwh~|nhsC1h8U&+(ER}Oqee1>@U zK|`E%FD!f@Z9d}dW&r6Vw=z{8_#ZtRE0BY*VrGdDr|;J;EpT1h#Us@{o`mE{F1jks&zSr&O^&2ny{{qCJj$I#S;dr%ghfybtHws zHnE@_oLQ!T1U84+kEz|dy&WB0n^W)72Mu`2^YS!C;%f~uTPC@=(9y;B8 zlUu(|%X^(h`-~u|a?BxRDMOsRsM@xFwP_cfygU3-*xMWYT``WMl3>-G37^Zh;bWiO z=D?UseJ3g4y;rCtiOA``{ru*<_}#(I${U(NO~((ANzC0o(zWLl4v8nkoV$V&XK<$N zedZZsDU688!BUasr>rVd)q3TF@zs`xXG2CT_+@gHDsPd3BENl`NMBx*CO=x8YCNBf z9@ir1QY}c<46M@YbE{+RtsonUv>d-TUcEg#bA4KcGWT22uI_NF#^oMzwp|>f<>-*S zICC0FXH4pZZO!F0JUAFF0lxO;m1bQy3C-g?DVAJv@p zv>LMIHdC27S8V_Ijl62$a()n+JXK)OnVt|o451|DlyE{y zngBwKVTgnB36D+TwQ*Y_NZO)KepfY^>3n=yRz7pRFuEI(UwhiGYB#;Ho#QhV{)9)3 z!X1a{#j(e#yzScP&qXw+qpgJfH7A?H$8^`{afJ6)>HDC!Yh|rztm{{lIiYUJ&gonD z>3(?J+ENx8Qc_7MM5LsrdJ(Fe_8)i%o62L#4GA_=yyrcxQO;AJNz>LJXcD@2E@tJh z4~*aEjivZwbkPrGU|}hDfyAW2^3XE{no}T&r2phUkM-gk@+x=u-sZP(GIusb z`R)BskGslN#nMPY@!?x8W{!^0D1Z;cejIoIzDMYSb}#XuIM+dYNF702j5rqZt#M$@ zK=zZ<6GO-7XWJ68%?Ij11I7wQb=ZYuQb>pA*n60 zLqpTYaFYP;2r};vzk6W|JQpPeglP?H zLWf{jHx9@um>r3>iFs+eEo;Ue*Yjk_mCU_w-3EW?$Cl&q7NPrSO{#Hc+4HUQ}ldxw+~3s7m*AdnYq%RJY9Z-(^sB$wBq5P z>U8`ZLdLJhAeJ!Gg-S}D4)ZklC#kRS9i zu5~(Ovq;)nA6n;XFn&@0LV}hslXikESyL;kKU6CibX83Z`r|J0m}uXG{XyM!coBP` z%6jjs`i0$CQZrT49Ohb2vEZZH41Hs_Vkv)0yGh5!88zocAhNDi&ko@cuDR1TO9hO1 zXJZ(F^FSB3`oGrO9gyAUz8rA59+DSb9VwUJiT6i&JIQw00k6MuefMSaGLT!^E`WWx z%Z$aDVDjEu=Whf_?O6{MWsT{TwJC8D@_||OLJlF?YY@7a;O9Qh}p%FQ?X8cWB+KSRra<2!FeIMS zy)Zhqn=nlw0|Nu!06GeaKGz*U^)0q?d_Y*GZDV-aH|Hsih*-k<(5AUE7Spf6uI07^ zTa>pXC|_ReHQ8g3P2*;ZzzSgc^nv)ld^&ZE3i_cP?p8YRo|NA0m9#wZ(lIb~{_GK0 zNxYp#p`XtxaIildZcVfzJP6=M@m9~*i37L%&nbi@=4#`s$SIlTmSk~dPy+uN#(O(c zr>TbM4h%Mp@O|B8fkF>)xzK)Ms8~gq3;`5aa8P$1Xd}fgtmZ|Z16~wv+AAyK2f#eM z_;MTN(wbcr7*r)32y0iP0XP1r5#cWs$|*s^;Z-5}^bSL*%>HZV;=sD~SM*jvM{1b4 z<1}3K6ypvOGb9~gFGzlIDqvv^f+rkjtQAkMP|-JH+W;oSd zH~XZK)dlP?L|iDb(*`3P)(-dQSKJ28D^(~3v=FevKoU>s1r4|a0`Pf7h~HuW?MF8+ zj?LCSnk?KUMkDvZJyI%@$B%Oa9=-6;r^Fbx=Ok_B6+t|;dth%ll_mMMcB0h&EHD_L zm7Wto81Th%O8XD@h`6v$Fki?IqCKJVL5sGyLG#0~38v|FyiHX*ZFf)QnE2Ag{jK<; z5+sCctpFOxZBnf##z{Z)1xSttSO=!Relythu6M+}E~m1&aLCHv35A)Bsj178yW`mP zD_j-c@Y4On^T^`5aqT%ycKuA7_op(1_kn~SXh&IN@JRri$ge2iLT z>NhOhMkB*OGrHl9m(bq2olu`PSud3z{HgF*^;5E@r6w;iw2|F*BsoORovIrqa8Bm? zzbFA`r9m@E6X2_-cO0;a`3Y;h-_!Uw)!G@VWQ;B=@)iKrCK4fSh4~|s-WzfBmRVG(pVG`-ReyW>*KKlbO9m z68pS08}Ed~qF6`)vgY=2?___&vidJp)1yQ#C(VF!iiCP>D~xYYcpQT@-NNg8@wo=L zS(Jlho$oa(^>+az+;PVb_;_T{kt&c=0!Bt`ykMhR_)~!7y|=%XOzY+UDcf>NfeH?fmuR6-k)G# zLS7(0&*DT|C4}t|5_2`zMvmfcEB6T);@1H97ILIe6x>q1xJ-jlz^^7XR+?}J@CdU{ z5Ig52LpAO4k_QC?OlFri5P@zKL4ULXS}Vm8>iy~&84Uh(Rz6dGAR8;NA3H5HO&8L; z{XpE7vil(a*5}@N6OYz`hBXi8ka?kYiwV(!~Bl^gn{gTSf1U8`iN znE<4^&70A`fF5KiyG#E)Z zJUUVo*oa5*wDSXoKV4v~f(SjmBEzgdG(r?YdKBwvGm=So-$ky(1|8v19krPZiY_=B zMW4lv62pD5&@pepW*Oh6L7Bw;EgT1=cke3s{Qd3&nhtL+EJ84em${;NiRQ1FdheeU z-Yf0$h7QDcAkiSD{IS*7NsjVJmS`RjiFbd|(LjU% zHp2y54Yu$8%4JM%DzlchdUclqgI~c2!^pqLT6&+K7+qJK;qGD)P)R7qO9NY-Xy~_Y z(YytwoQ2mn93;ww(t3Q0e8K(#L5;$Cfx%Ez;yEBBfRYEmsjD3fbA?P+8ez zWoPd_j&bl@hkpP6?|EL&_wM!*r|0V*O6Hh}=V~5Xty9%qn7Nv*4 z%kD}^yy|6{f_8gcK|x+&y_)(eed;(@jmu;AzZU~znk2N^Pg?GPT8q0lNM2B2{`oMp zOKH!ENX%*WAD`LL){g4frxtqS8yid7Ep2@C-1Bg(ECsZ0s&6N~=?L`JcyRn-C(jZ_ zeG`!z>8TlU+GW1F-RDXjNDGn3>)v;sn-p1(8JQ(TDI}8hCQc-mVns>aaI^Wyv7AD5Xett+wfEbT! z3F@f}8WvGJt0OAw!tv+L@kEZso)-H)T0!VFoaTZ5P1tZkP4G5S{y%nf*ykCzS;v*H zpv~Jh(%zeWCdNb8n~U!@v31>72>f(7Osp)$z^2nzXdyH4hxQVg)azE`im5)HzP>&p zR1}-;-k6lV3kX9erwU!ZbrJs*93|eowV8C+KDr+Uy14Vz=)2@s`@W~smJq=u6bWI& zB=tn}ve^Vbw%1SjjecKxxp-OC7iqY0(MPDBE~HgPr!?o0F!H()pzt|SI%h!QJwR2%>o$gV<`KxyJ_C7+gh;Iu2=>7Y?kg@}oGaM3cd++&eQ;>!u zZ&*1~9J_FZT?;Q>HLI#=wSBD}>n?Zj>nYH(EticV6D!lmt8_M(iLaS~uEp4@12roJ z^fR-M0UN37Upe{mmW5mk_paYkZbM^Q_hwT`Ll_yWu~V->oIB~w1;i?u8U9fDo%e)P zkB^PixZioC5}s!*_FnY16PrkOpaZR69j+))OTIGH=be%)GnNY{00I_DIq78{2d`-Sl6M=uAu0?d`Rdi z309bP@&cpL1T^^gF#F%)|FmddcbS@q+m3g!dQy z)dRP-wh}$p8?k3Lm`UlP+WEP-Hd@ANOxh~ZAie{QXt55X==GlgbS^j9+4ZfC%-iBF zGe;4j_O=%=wPXq)&^cD5t`EIkkzEdJA0K(`b)|OYR;cOedt_1QyN`il&AY)q{i9%+ z&+?bW1ULWq7;jjyiuWv#tW;t2t|Wq@a%G;?uokKoq@$)1#0f~7lFy+)r$^6gLT;&D zk#G$b@*+p99}*fH5<$#`mb9{SHeP#ni|_mu?!_7C893ZEtQ_^p(kU}& zTkN+h)_2+J85kJwYK=jIcIxhIe}Df5h5NMU8ps}krCX|CVw$`odrSEU@3dsRzMdo@>dL#%rPl^a-Hdm=gxXf)D_OGlEd24*KCtde>{tHT9A0)l2~yUjFX^QV zIU=A#UAiI&G6w5L&1xP8Tb5iV3HR1ng6pnh5(PEWXzgb^P-XhLJd5!1nWF=dm<#2~ z3My9xU`^#4+EwiRyik@8vg|82#Ap5_`;3sLEpt#3&&#vI80YudnPE>8oL?`^{QP-3 z)M>N`+Ga5Ejuq&vti?mkg8J7~`;*EFswyf!Ee;=Uh5bp$x!pv>jy;$tvlrSPT-pYa zYm47`V?&CmK%tz`-K~Og3+<&T9*u-RCkOH9H^43dJ*2($$ z%Pxxkjl9`SnqSlg*8C{D0+O_LiVyNqEK5 z%Ia`NhFz}uaPJ!hpY8Zg*1pNEtitL2HF_Ioec}a)VEyuvWj5><=I) zQkPWgvMZ>OsSu+!e|rypC{pzqEzPvb@5E$}i_4WPQ%Ax@0`Ue@#h)`9d_TVbJ!L1f z(b8om78e#~TNA<~|Nc(0Omq&kS?-Sh`m|EHu?-zSBOy6)WQ-RaoNmjy($^R7vxc^&S~z%##nd&gn^)65&{dls{gH$cabJ1g;M4qILx zE*ALeReUb!`dqEv4QUUy7{SIPud}lsRc^Fh;e25#DJiL}oQgDn-dE^ysuH}#$T(Nx zG~&`ouN)#d0gd|H)`!dSC7^R=8OuU*WY?L(oKwlUDuE|D*qHwQ8)e6ddy(twZHgSa zL=|{toTwgPI1?b z5T42Hn3@1!f?UzszUlEu0e4Y|rCUaYzYA9wyxc!(znPX|FcYoMrer})t)q{zb}H$S)P z)UWP7QW<&{R%M`A^1KR!d>z7bo=FFPI!Mn2@;|8agFiUnj$qd3I!-}+@9l39h8M-4 z?hWyK8pZd4@v32{_spkC2j)QDQa@wRX=GR69!iabihnbwVx`wEL)*GS&dv1;ERV*0 zlVOvRL8HRmoe70fc1jv!R*yH!U2f3ToN*zS31oqQHMG5@g}81`-|L`=f=eF1$ zD&#oW5+j^TjIg{wA1~tlUJ{q4?YLE*Xx15&OhbV@4RDQDx!oNdNS3TLwtRGV@gKHHr@k2zOMaJQ8KTt<=yPQ1?$o=$;1ZP z7`sQ((yxXNU8kfI8+7hi#kD)J0Vj`QiOBAx$Or*3C9lnR?RXGba6@RCHavdku`~Ba zsyxbkE7kYOj%bz=JbT`6gJKr#%D5i}2wPdl7`59k&r+(0%9a(9fko z9>AiHJ~fFOb8{VZ-UDv;NlECN4v|c4H{5jf7_B;(-2V6Uz6Y)Sc7KBE52b$xz1}#% zVlSyKo2aIz2bbfp{wxCZ>y^lC=PCnjAbu7O=M%VkHc$)edD<$4q$!8J^tnG0323>DOhYGKwL%cUMyw@K4viQ#a&R1TocW4?Y zGS~N7x>u$P%pyG^M=l+7DG#*vXe_y7TaCj9eF#X-*-TQI#9i3z(q)U@8|^d5myNRt ztM2O2z`(J^7Tvs05?sxY5F0CC)I=Xl?t!wl0#gp`+OBBNKi~cZOG?S**rI6?-#SQ# zK#gx;E_+8E=lKW;M$m|X#)r=R<5R~aZ(KI^nXDEu!c@H_@)hzr*NvNKL}-~`ZCRPe zX`22L{p!cW*Sx`37urjcy7V9nZ#!#$g>(Zk&^Bh1K6sV?*+0CTtgNk1?(oL9T}$wU z!qoJ%@z&?4Q~GILn)>?D2_s{LQquhK%l{J4$;!*??)#bdRv-Yjp*e+-nR94JauFCP;2@}~` z?C0tZr99_~7A~-#`EWzZ7w0Py+dSLdUQbF)fGZ-R8;1cZeKJ&PlHgLTsvt%d-gp`WCxfod0jNN#J9AD(R=)ZaXQH<0~7K?)$z=-kQ45=z!_%rfn zZ!%n=3TqcObU4NC!frcZQh@B4JpA=*Wnq5)2NynK=|1n#_0wx1kQfdWf2M-) zcBan^0DG4msn+kUMR@-yhd1Tlf{XC11xA{+&DbOrDW>TAsY$a+KSS8n%&%`*ce6y{ zylUDM7&B1NQddP*5e)Zmb zU&#=4Z39V5|Duyhw%Us^zvk$~J?=qqQsJF}1?%^+w?Yk#+dW-|%N>$=4wu;AaXNET|O5f3Vvq;yo#$J9EB#Ii7@q z_|a?}?Rwzm*Jd_wy3#mlbPHU!7IM*!8@XOfp5K6ZCWE}7*l#W%Pnj?u!mS11bg8XP zWGP&H!B6Ko`+a`i3?*S;FdnWT1YTo9vB>iCDeU9eL0oXQHXh6%cW%5+SxIRbZQVQU zbZ~zpEB&`iYkRKnMhjPkW2+-C4QNh)C`On^@|2{F0^L0`74YUr!G!z%hPrHW((f|=ua*f&$cj{qzdCA(Ww5T=9>~m8&n-9o;agZ(=)dO=t^|m!71{bKL4*%6 zy@v<-*b(Kyj#C)c$0dG<;&$D15%(v)Al4EiM8pg5-|?sRVC^1yVTSfgW)j06RRRj2of*8_Ig^Bg;rQyJ zev9uyLL3}xhd?p3WWD_M%s8lNfQtM0>V+GUuPLA2p=2R%K8j*fjXt+LP_!@=tzh1% zT(SNov-^AH&NvZ}u2XRsOukJPUszaRW^(l0`5xflz%Ye=K%qA~d>gV05;VBz72(aB zOinIf;aE5I4&ZMxX1@#&of%G}!sG>Y$JkK9pqv<2$e5Ni^;gFxMwGTf^g4CAXzv^d zQUthQtnMuh!X#`j4GxobENqnwbK7q(Mk@^W7JWU~QdQSI07inQC3wN|V2L{6(>phR zzIEQ3SDg==nlk#gX6~AQZTKxY@RD&`oAJhPdKq#e6fwSdo|plu_OiN8XMt2G-_eHx z^iV-Q79=C>XI7RWOY%5So{qzymTtLx->>>8vE}_0YU*By;EcD-LTiA#;&APkNQk6i za6;eCnjhHj$fL11`58!-x#zrbUrtxV4B1-;=YTi$!v~idgWBiX@_ji-s9r-P60PCa zbqM1=@|Q}+7pS!_>$i4Av#Z3+%;t{{4*X0KT|R%Dlv#P;0`C3Zz6dtitz@~tq{Jbe z=U`r)JlLr2)wR31=Tll*S_tS|XV3H}4VOaX^^-Y0*)-9Jq*H8m3>%ZJwn zh4e67`WwRZ1efD?{Jj{jHQH~mEpN3A~Jf|@SO2`YmgJ2qp-**n{TP< zvmbwG@0Z$v4YRI3ctak~d7O4>D4vagAp(`{Wwr|JI=OBd;1RY1t>STR*?f27!8CAl zxv$kxxY1>-@#(qF8I1^#Uga+|Zp|2JyCL;v5rT_$dmgLb=~hk)BSIQ!6E=BO?^5)Tbu6^VV7MBb4|c4s)fHlVjMf-;0?7Iy zDNoGF_H_Wwkk7juLsZkpP9yIFptRa=yyr&W=QhUt#;PvPev+tQ$y~gM zE-nIT*be-U6tjSU;xiT3D|WWFcDi@b(I$5&fhw#6_Q@Wyio)WXUnt&AD$@DoTEAt92t)VcP!FivTZaJOo3-42kXra6aIj z7nq5&r-i5$WAx6)4#G^)W8@uxnFSz(ds+Z!g#n)r5X3!wdt4;bh@$LdVHNWD=8b13 z0Q}#-YA$e2k!W`eN)Eei?lj08VX+(SeO?%L zq~M+y+X?toDOX~I_qB9>JC)((gf99N5L>e-T^Wu ztMjyF5ZMm#=&(hn+@AN37(p?uE7sn9p!lY@m!J9y7Z+DZJvl{m$=VA<;Asa2g;}qp zE-3cLm!l|d*o?kSb7oiSs5c@)nt@mjD}Pl|lIZ?-;QniCn`88yeb4~Jxq&I1r=k6V¥oCnbGnT^pUll zBI=@9uv;{BwT|kRaf^}gs-6@;=pUQ~r$F@PJsw;c2-wNbp}-@CiNPV>h0@b&{tISL z5pDko3Y&bkgKm!-rda$4j~fegvs!Q%@-^vkjTmNFZl*O0x0l{$-o~}(1m1k0NPXqX zl4C&SqXL%1_w-BenOTp&E{!|q{0F_$l&-<;Z~5S5pK)`p2{#`^w?Kqh*$8m$h zF^YbmD6wpP`M%|`OUNpeKG@Du^tUr&vt9MLVPua~A@#q}-J<*NPcty6bG*=dfBTf9Ta~o$9HaK@I&`3GNvfFdK5^@1SjQ@34iS#f zw=A=8eUWk7Zq1cTW5Jk`|J`I;o*_z53r;)+?i;cIi!#E5f2Pf&IU2>~dI z!V)1TS60QH%&_EfpaQrI`~k`Sw4g!?S2r9NKVHpGHDPy0#Fo@onQ2c5XLijIkNQv( zr<63_aeVkobtcVsMG^0s=P{}YA^JM(|LIs8fAsfrrgV&@HgRptZ_}JJLIYii4*-^$J=aaAxP&h^0iy2JSoZ=m@YGJv;jN z@y;#ckXD1P<$6-}0St|uFjwyKO35?dm3+Kww11z+LfP$ca~vS^MQH$klL37t60t7k zi<~IW?a!}m>T8sgUQ!D2K8L%xjq>KtytN;p7Vt>JOJX(si5m~ZT2gvxUV5obQ(+?~ zEhL+%Xio=TM&r8NPBOCyghFQVy>qCXtI-z^eKjH@uEBRH=kXC8W+#!dsE#60u#oF9 zk_h2l{M*Fg97=Lq;nm_D_{hg0J7`12n`T6o1ZL`WnUa zbrKQx48(T0!wwz54}fJuRrCTdg8xC@A*TSvtO-(Du)PCM?3*YSK79qdI>3*w@gK*n z$1CyD(&yt{j|SX23lm$*QuM-zk83BE9bh98DDzME03`(%vgGfk+NK9rV>>Sr*0mN3 zQ83^!2L?}F`0vvn_j23dlO;63cwFLrK+cXsAd5Y1G;Y|7m#ZM<3r$e*MaEp+JgB(R zb~S?6*4gg3_tks*xB(5$X6Q1E@~it=3?B+y=?fC*A4g6UEv zdmT;vke_p4gF_gB&1=DUi~vsv&eo$x>w*7&0~8Vm*@9ua%$QJ83@Kw zH$bJ$8kkWb`~tN=O-)VUb%zVwMt}kchgw@$urM(Lbwp6P|pZ95x7~!0zpvq8LqT94Z@tzYTw$CEDi!(?zDN3{sU4&Gsqi< z3h$+N&fYLrq%(rp8$_raz1IL**ODlrPuN*t(r!CZ?-IA%4hoe(u}i)hbSkXS#>b-* zzkv#U(bY-l@=NN?l-$NUr6vX`>P+P}KInc%$xVgXt{$$Bzg_amEW7{}um2|b8V`qT zDk(ZG!A+CUc66A_ZTsihK_5W0S08O`eVP9tV1-6m+jwCrEi3&yTX+{a z=PA1TW_YFY=+UUK&`SA6pJxu0H8_S_TSz<0e_ zd#|Wr#MNme0ZrhGJg{?f@vVK8Qi&PEZjDBLacgng4mL8EJWppgvB@mRlpGryf8=>^ z03AC$LYFI8GkdKnx94S~61l{NUD|%WjLfX+FTi};3yGDA=MucDKq1kG*~j~ zb#!nzU$6C1IZfUNpJd%7TI&6mzJ(snA;=vT^k>~}0@-Jg!<~spW_5`1?Oj~5kIH_- z-UV(=+^#Iytc4IeTeCpYie0C)!{lKW|BKlUsCi-i^Y?`eXn!@Y%Iw(^I|B0%*z5h}t)HV!POEkavR z(pm+USIEI^H!>t%A}3FP@^m(3XaMNhV&Es;b}-qW-GtVT>&hdQPrkpr%&ex7-CF^6 z57hJ4*@AkP6M+^Libk+=a>sttMMgy2<>6^w0%>nGRn=W{ZPwOUo_76*I$B!gVD%6) z!rKo@&kli6KgI(5HuTureBQb0J6(Yn>}X|QiALjQk~&_kKtqv>~r|DuV- z0$HPsy#n|?)TXZVsnRMEyK*edJa<74L?9s;RMiSq(F)~_PpJ02{28ww%C?l2pdRkn7Z5&bueo2+Z~!n_c<6A=1W%Y<{lNlXdgK( z*Xo&=p`vfiJY1e?vCkHJ#O}*S6B6Y?z+*A+b+EF1dDUW^)i?c1j$-1^H8O`$5B*y; zx;;@}at2!*Bm78 z?!~r7#bGCmqSY*Oi62oNzf|rn30KbFPi}w4oJCq5Ydvn0{ z`Y9{RVr{$*j9BQtedjW$Dz{_Nl($S*q%JI=$uWP=gGdY8p!0%qVPf+`|OE{rjfb$5DdMj>0z{@aL2|(>#+5)d)fAsZ^b$;-X%gobzZ>!cgBp{ z;!fjf0#YKV)6dQbBMhb20d&W0w}bR5h<^Xxd8F({`7={bP@Ep4<_SDUUI^kc!feGq~t>iky~+BD{* zFW`!D2vp7LEHr^l!>>-X0~g!75}Kli1LDR6^C5Y|MvMJH#7-mPIW#c>YUkmNAiB-q zVh;rSU%yLEV-9}*6NQTGkG1vXBX^~v#Z z$nAqkt$5ybE5roH(@oe};k1IqVjqRyI*Zy~><3q8dSwTAoTUjlybiI(xn@7wh4-`a z^EsHAGt<-49Te~aA2bH>qa?y}U;n1B9TmwO)aktGTk7!2cBtbTi|QbBzL&)1hy|`x zjZWJ}4}}}fUnd*?x>Rwk56;^zL$lzB=aI;H zDMJG>F=OL~TDmCbqt%Ia_1wY97fi0`Dz_2~pYKVxn=snNB1b=}6?%KzbS8buh?<-h z~8^>R*gI#=ppB{0KJ@V-f%=xOG)zlu3?i2~Wg7JTh- zv64kI^^aa|zh#*y?nWRb(ieXh^&c^rLci}X3QW24m8r1rE zx}yf|mGVBx+@_)1WaD{k$;l`YuZOo+Ruu!p(^bT(lo)UF?#waJn%zW21+82yX-nXC zNNHYEPHS%@$QMe9^r;`eh>~be;9&D9`WH`8_l7UBZibzgZly~=jZjhFTEI?r^d}9X zkc8n2w+`6Dkc4~|%<1>6G?{pnb1 z`D0L&f}Gq|sYh<7X#^=SipB0pfNeQ*<_xU@?vQ<Ho=bM|B7hEnq#A&xf_ipcay;mR z?Lo*%j(T9?TRzeU)U1JdIg5g0sOw?%0<6t!wFSG!pOo%Z5pi}TTou-4_k9@HQ<~`N zWw=!A;~*9|m}YA7#_piay4YNbWZ$^QpUytCyKfMSWfQjcKrcOBzTl!pt65^3S%U&4 zC0e)Hk-$|pvB2vSen$s=0aKSFE52?E@R&u(rTO?c9622QzzbTVZmm02NfKK%8si{J z$YDElkEg8^IvKTCdWYQ#s&|;WG<3dtNcFy+i*~>M@u&0C??W+U^?IpF8E`;~>@8-V zCfM+75m8gqV`Q+IOh5cMmW3Smf4w=U%g~Z9_6~9| z1&;_XP+b`FeAM@I6*5dQ*O^nwCFcwOy;bn(YgwXDY(Dxkms*=q=4K%84ivS~Ol zmg3?HU|M^#H097{;6U&U+4$_Q?QYD7Y;`N^jXZ} z^*aF(5s|*jR3xZ=C$Fu!Z7(viu|=UjPldgH{hD4TDoeX$d98sCqY*F+Ro=O-v|JsK z8(g1iL#;t*+#bi5s+Kv^8e0O($v?ps&NAB2T>a`1my+j6sXM}bemMk1*s5O-I%gYi~Q-U!U_D&NrLaaCVl%Y6f;y(CC^7nS^a#kq;DM@ zw0!HdmgY0T?SZkFg_3y^lSRh>t!%rhC6DS#w7yu}Mp%{yfgkep#r?KeF6Hy*iHYCy zvMsg5yo4VYY)_T?)*AluSD=Z$MYq4d-&*Zr>?og5gOX2Vd}}{DlD#m#zr4QDMf~t} zbj_(#*fd#M%y4f!dGMC+$e)WBNf+t7X0q*v)J4fTq$K@IoLT*l26Gh5!LMihkVMlX zNm-B96!7x0zB$4?17`ub{<>qfQqa%9ReL{447EAJGSm@tfgx$9_Ey-^u6~ht=5K?4 zhoJ46hcA*mygxYKH0`=r>~3_4u|6ddxmth4x)L@WAj}LOKYq)i+uGdhwp7#yG7!5^ z(q+66$*IQI67Ve8g8j@t|NP7jyuw(sC8?z~0ElunUk$Wt(NJvJg|B@tU<-raIC()+ zNk>sJA8N|}Ia+y7{>3%FAAmlB%6T^ZDjnE9#a z8e8-0)F=s+QrF(19KRpZVl2KpPR7|Ivo2lZ#)JzO`SZM9BCqfNY1d7in)vr%BGPOC z_9o+_AT}zK(JEUz!zT-$tnc0Dw-m$D<7M|7L`HrEb>6btMyC<(nUHEA=T|tYH!?Wb z*Z(SR6W#Il?b|0P30SS`4<#ftq?JAgc~i z@13b!ts(d5fjzYpW}`c-M}{fVtQ+u=k|BE_VzL6|MW~rS0k{l6gd8glR=kcXs;jGq z*m%a%dXoSS#m2U5=Y+0}U{r>P$R#w58_=N*B8U0OnSu%Hl&p6r!P$c(k62)obvU*| zoD{&m`?W!2?C8y3kmHGO&Wi8p?X~)ycb}X?paK2;`**P10HsI}?B7r*fu@lAKKN5W zI;vlV#Z(`mEv}<22f67*tZJbwmh#W9bCJ^Yr|pvBnR%_ftm7M}o~V@WeX0c}wkqdo>%Oal8>uDvoi*{cPJG@LPtlId|fky%Y+DkH^`KHp2qyZFXFv9Qe|&FUCt^N$kBH` z(dD^nVN13j@{`e2=dkW*luR?zv!{j4YuDoC8DHT4(LTw$d-tY+^~+&`I^iF5ME5hF zk#EJdSG5KZM_JhEOO>BVOvS7QkX~L#kfS4|6?7i^;U|J!t$}dUtYi>eDMoPvdNLtT ztg|?Wi8%O3)L%p#M<4a=evxc*WfT$77oP3S)+VyP4Et<6n0>?pGYqLf5N7xRxSl!N z-w;j#0K^Xo;1Mu!pPKt2K2=vy@%3|=EnpIOJ~KU?qlexDynC-%5e{-YY!wcB46aChdBe{mI0l`T57uMvZEbN+1!@iZ25K<| zkeuBvX7<^$W5xTDX+C;j)1Ob47DtMYkN?{uA$0*i@nJT(81G{1Zq_>kFURE>{hX++ zrjk~tVbg1z*IP#0b91Vy3bTd|27djrL;1QnTR8ppLw@+2#pTU>(wC30`Seqemz`l$ z<5`4aKCUj3^l05~y+5kdr`%KHkBN4R9P6!R>d#f48q5^0apigdA`TW?3fN5s3ch>$ z7D-L5rK%bk9o^TV3PalvN=E^B1;Ntz?!4s@wDv7JoE}B<3ip$5-gUtgN9{-0b_jJu zj1)qx=NH|A(BaUjMwvP65NzpE^^n?s2 zeSV~Du(@648Ju?*y((ZU{YgoVIx#A)TO`NH;;nq9TBg3&hKOqo)yfJPH8qQGdWJ*x z{I=d<7#W@CUS!P}CGWMdgbHihxinS*nimV?nvXSfl$7S}n#Pb6(#^ie;bEq$)3)&4UfFJxYvcQ18?*_{ zMz10413jT?!Gw^|&}g}LQ_-HDo{)&75VII8UV`9Z!?`gNa?0OMlVU-)rJ&z}mY^^n zPy}MV>u)GIQj(WDC-U?Sp1L*A_tp$GE2i1>g4}0 z1GkK~w>N0;HD(7wLc;YByZi0iH#oEMRgKTTIDaVLy5+^iobL8fi*T(|Qh9l(o3c9VZ1!E@0~ci z5Nq~`h^6*6p$NCa4Ev}8YW|zgN8+g9gh743Z&oy5ToBKEmwQe)8TDN(@WTgV|5^rx zc#oNbPu#Jr9Lenm)#byZ?e^cFz9_kks?8DVX9GZ-(b4VZtO_T4q9AkGCfsnJl=G58 zZY$=?2Fn%Q@)^l;xlATSzfznXij7kkO=j8tCOEO8#CNkDR(Ub&qvEoQ1x&;ekWQomf`pDqR!K zmRGBXHK{%3cT+@4f;>=WOj~1r=XnzMCTD4=< z{N{68|NL_`Fh7>2X_NjxFBR{&KT>A6MwB}0IYX9N#8#z{`=Gi?IRot&{o%uPNY;F@ z^@L%!RGpSYNj!Y`cH?F9Qk0BzlP~hSZ>^}naWgg)v|^R0<*fCp>r~9Y3eYsY!Br>Y z>gq5Yvn1dL^^x5VW=Xhk(^n(bsBH1)>q1e!QkT#2@q6=`xxL-pAbWMg9^#HWPsMa} zbU@n@lpz5uaXZ>-Q5^ZkiNu|>?jZKQP3lkcZy&bPps-SiXC4uCWR$hjs9Ip0ox1Fej_RV)4? zaLg+R?@ku93om#|tDOy{;;J|N3i$U3$b9#k72!^W?*w$4&KuC!6G|)O3eBy4etu$s zXNbt1xjw08s=2U4!f~~lbl<14>~zQdtFl6jbVStAO0{rSHehz~=`o{4Gv3YKIbj!kl<|Ky+z24iDtT%i zwubd@6%ecNeM2J*JiCr{B?~grke%vF3w1jCo+!AjuLp+N74+@5F5{UxoSf>2(YIl% z(_7jsw+if47HMvt7G=nDmV9hLDtBX_%!x#KR9JKH2Q<3!4})yn1|Yf!^=9@GHNzPIi8d)cae`d( zmc7|CGtL;nH;dqXakUF_0q#sKCbR(ZX6{D^7)}ax8pt7$Yc#)l>V!g}z*ruEU=t@7 z7X`Z>OY_&^R;aP{I+9Y~{n_likH(B_IAassmQgn)(|DNW+V)T#Zx9N`# z(?1rv*Ug7C!3va3g{QPo}QxSsN8pMf-*M(NaYhJ z_uhEyweX=N-pUWMSCwFtPuckWE$z5J3VLO9+DsY3RDiQz!)$QRk~r=LSiZ37BPFVY z)85}grnj@4>`d`jrV8Zs>ng+SYT?}9s{PjTQlPN}r*sDM1^t!DP;2Sw?oRKShdR!A z@SZ_$4JT|zm8+BifaS2Yi3YE|xim<99{a_R(12Pf?BAntifTeaLL1t|h=^we{gB7F z@+9>AuD-l%R>Tjo%l!O%P-UeOa(y9wf6>U# z-@i$~yeOf>-E%%8?Mr56tFisc2!%b7&-Xh9wS*y(ToB^;Te*ST5FH&&gaZ9714F~9 zf>K$#AE3m_AfJ_!Qw0H6d}u>m9h`u6(8IDEZt*Zz4_)b5=S@od3RBQeqbrWya~k;h zN6Lf%ZI*dYb5W{oK>Ev~-bfw_GCcKq0x4i3RaBm-^btlEKxEiaVAAqE4r_k~3}S8q zM^~EsItIghn}uA=^e#nWxq08u!-GA^2W5I}Bo7z&cWN+JBgI`d^A>X1jGTg5>bdX7 z#0f~HmsbR{ysE3j2(x>)NUE(9^^#Gju&}W8<^-V>ul=7M3*1)s;`r6o6sc&*`C0Bw zl{ZdQwGL5IB0I9R`w|mNyefx{NlCVbN;ES`WEcA|KNSzgZZuGz3o=Zp*jni;Ft08*r$>#g1f6eJ=j7-SUbrwaRlaG9OQ6^>{}V01mUfe# zd;fq&lv%0Ks}SM+CTB-1W}TP&vxD#~aC&xZLsQR`Op1Zd6lb;O4d2<=4j%fVd;1G| zel%8^6?PKBHoP=#q!3sPTdC7NY|>v9_$Q!7QA>*&U{#c}mZx+T6`vOL^Vc@B+`i50 z+>t3K?(_2HEx8XbrcD0Pxolkh_?EB68bQ0}Tn}IPw#DF^EcU7<;Ht0P2@RPG@ zP+z`e9vmDvSlusuR;-cexg1%(x2MmlbH_kqWB++MhBi zRxU;R7YdpA`1ru7`|x4@;17T-mpRbBNJZV6SmB~J@lPoyI#(SDrN`h`b*>`m2lN~t zq&_J*EDiJo|0@C+lnR5c=SGk`aRJ;anyMdqumJ|6p2qR~2DjCSqMBM5WH)3qHzJdi zg4jTi)iciHPDxIcaIqBTX$GUZD<`7zn8+IF_?#mC(nhKC8HR;P8+_Arc6L5G)H_2f zC)b3R)U}GiRP279Tk+i4Es41TRWA>10c~~tYU2YSbHm1(eCbhHJGFG`uWxgpbPm{r zvM!NJHAsZVjF}-RMjMiNAl0O=@Q2g3$b(skD3?2pQx*cK)ZKS&k>+|7fimO?&FQn8n z*awF82=a-5k4u7xKl^uT$&RNMh@$v&j)UbVPQ32$BnXL6!Yz^Ig|AqG4S1gCsjbH5 z6=}Vu5;}#(*d&BmAYPK{-!p)p6@#M$E@JM*pD%>zv4LPuSt;B@hzN*Yu=!f=v=Fni zf0LB)aWbT4ke|E+z@Aj`$C)Z8*eKHneDyFrC%5U$0Tp@C{j{AA(4blaRR4Dc&k67 zjf)PqX5j?%EAAW~kd!w2df)nqw!tXV?eaehj0l8HQ5Sw*C{qXj-&>i!jOd<(35OeI zu|_G{zK|^xJfm~gEV60YW^!tB!xA^_Nq+x%^?8_LeDrsXXCDdfb!gVrOYFJ=idJU{ zsJlIc&LoAOpc4^EyZ>W$B`A{|^?RnErSvosduC!edU-w&q42aw;525?rf%e;;u`42Cc3 z@Wsmgt5?b7p5Cs!klyvdOs@X=jnO-}7iyh!0U*4&r$&4Rl6WYCUvDPW8$D={xbok# z_WSSPt>V$+;B1>o{utaNgI$3~HvId@-=bm8gBvYi?s4SJHM++$x4OlGd({hPGkpcz zH=x=8)`rX2WcKBYW8s0A(JUNCpP$yJi()Wc`>xk7^i4_fpo87(6rp4v?_!~BVpx8|fF}wJmNp^xqXj8>KC=wLXL+@MjdaSF! z^W@H)LX^I`6xDxJUb*+|{uTckk^KHzFj0-Cy1+JsZ-~1}bf7LHwEiMn5lay*J{xxH zArB;R1_`HDfUs1!RlAn|Vepoj)Fu(3X^q8qE%xoiJhowDVa+G|V5Yo=`aUfsTtV=& z|7RA8V0mhC&5!u-!{)X7l__^TGLuyQXId1gpd`Yd);cpkJn1I4;ILrjKeFxTJ95r!GloTac(m zjLI9rSCrZ0@jo~L)`$k&{C`U_os4JZ3qNaxLtUo879l#A*YBG(wZ+PZcLjLL3-Jf! za4=v*$G@MB&!w6&f+E@Ax%BAJcEaU;mPcZ$GhWHMWd7t=484hNmm){N8zPx8w$MdqU`f4mOzMD^hqLlhEL zOTlBCG2j+))w7a8U_R-$2-BZ`!LQ$*E@bfkeY?BQBZu4`!=M<3tuet6F;=IZa?n!F zZq#^Yu_Zm;R-(34>u=EvnM(T0jx#>=m$%=Yb5rvih98tD^j^GtS~N}Nu?CB8;PQxS zYG>iphMH=RifW&)!&#E^+iRqoMD#{#=DHnykjzx{hU{M|eM^H&FQ^+hn> zmkS)ouU_>4u8774fd2?NEywM9qs0AR0Iqb*uS}3H0RBQu#aTPy4GcFpk*0;-C5X2% zyng_&b%n=D&|$uNqg`aQgRXX}z&9WO>U;%jyYBl-C7@Z2hljT@)9zGOnJg1WanAHX z=JtCojK(|H{W^yV%9m!{*M+sX@}jG&wVBGzLd!;O`C~Q~x>^A!<(ebBB8x^|M@fbc z^i_u#drcio^#B@{flj|W^ZzjR7Eo2~QMfN2j{+(oC>?I;79^x`t01jNry||mr8J0O z(B0kLDj?n6(hVZrZ*I;x_ug^eeQ(@7&KO4>16ZuJ{`Jk@_f37NXu-@*y_4pPIyINN z`T9+*2!Y?94-~2=<4O+_MoqA9;*iEg%~~a*>UX^Mr(QFMm`~uu1Y79CLm@+|SENqD z`PtbQZel~0wTUG;_j4o-T&AbK(0v#(Vmlh?KMoqV(<6_F5;EHlikqgma)|0^@hyD7 zsI*U#`rbW{PC9}A^E+3f6ACju6p&(pgWN*B5lTOF@wL6lhSp;ap(4Jgrz|J;`Ii3$ z=oI57+y_^nqy(uK`89t6$Y^7Etrvl}!$*D~<>{iK9$MQ&NlCyjhEf5)18-U^*(nqw z>{j}k&Q!Qf$99*5FDi?QEBrDb*hjLQ`(_x-*if+^t`|~IV zz8+NESpa}Zs$}Qp$?j88fi_4&OuVxFj76i0lZGZWG2{>`gQ&z8*qf(_W9fcFU6r~o z#3q3&kc*Sp^ZRcQEnAwq5QPUfuazWSU7M%239}u6enH zhqo5A*X=5=Tjci}cTZ~z(=<8LUQ?}XgHcF}<56Y3BvM^Y_56EtyKEdElh^a*@o^ez zyGM;h3rzOwWBFvfj;re*HBRjH$(0>WI&a^8!wOx)RnLVbrL$uc()U za~CJ+GZSAD?Pc1DiDJj%P6?JvzKqbT1fb0k%0l%it_se1W;6AS+fy4E2y6~B} z=%TcorsjDAj&MseP=H4C)QZ`IcGZ(0r-79=Sm4j|C-x^Gq%C-U6Tz8gnPR)sQ{}UF z5dQeBV^Hny_noSAcBjfprg?6QLm?ib?kPm=8*DD>Z;Sly<*puie`miJsN^X`}T%_c;`|kQDpV7~1xkm>u*F0~zNwkla)EHQFz_~5a38F}F7W(O zmP*jUH>=iI!}V*{@~UI>$NivW4K-!SE-~nQzXuGUlAZ)(7a|eO3{IH*p zC86-z+u~6nl``$_It`&&5X7YN;d~*t9Fs->9SgsxNJ(wu2%CNm_ z;`HsapF8ZmlVf`1V?_V%BMt>FJvC^!|5naG8gNb5l}DYfRg7*W5OeFONiee3ix&ib zzH#ESPJ4&4oV8Td6&#>4@j@EcBZF|x;XcAjN99#9<3sAU9jNsvJrpK$s59f!N?kf` zCo75fjPq+1l^}fC0Tjm=eC<(-Kau)zpL$o)BTF&tRdA|po+55Xz|)gmW>Qi$uz%F3b@M z;rF@k&yz|qoca0K`WLHHiOEActQ?vl8(%l;q$1P)ppPsE-1$}G!9v*bzFcmv2L`IQg`x$CdjQ{SKX z#-fw8@dNG3>r!BqQkAAii(+krw@2;=vu|wAIU&3hNe|bwCKuJ&1^7%4BT8-~{cK6g zd6f|E7Y&+-r{x(fm~Y+9blI7?coB8wxZ2Hmu68HgH%W%GeN8ye-fn*S8IBn25+c8bhPGmPnpdr)L2Vgqlns0r?@G7Y2|hcS?DoQ~ zcVB;3p(*Hv+blyQq;f1RZdl17nvPX7H^kcz<5plcUgx|w)>+6tX&%G7=P4Toga@KA zcgKuKc%Our)ct%ROT)P+R0=`|afms8b&Z=18EQ=b%(boMp%_m^(;hX#|>+ae`6e5#>x5==ID6M=jJ zb*IWhr*@{St~jPU$X(f*l)-u#Qit*PDpWpLMh1HyDR?jt#|LY3TSF&2yqOm`)d$fX zVJKAY3e=I>3tWx|{7E`#w!C4io$OvgT8qQHIH5$xP$c@HrmkwX>6xAHoZE9)~ z+#h+%(clK*hBY-3cD^5h1`!u;ZE3+p#t1lau(9PQZr>ze4w1E7A7KxF<>=`6=Lhh3 zJSPfhrnEg;G+r4yUvKk!9X7YEuD=CZ@ z27k7up?Ugr>4YYAfiV=&HDjHp5x75e%4$w#mQJuwm#s`4pDp;tyBQWLpPiioO?m3> z)US5jR1PhF%SYFtc2Xv04>=!z?ojZZ9`KxNpn6e!=@!yLDbtGCL0kPykCc}z{9tO` zs;lpC>`=Qnem(wC5Zkxs`t{w-!G>dn&LY!03_uSc-QHh5lOCGdaZ zVK*L|1G76)Bdr)s9@w$r0I$#Zcw_E_&g_TAJUFN;0WzOl-Z1T?hn1<)}JlCy}eM! z!@_!Hqzo}|zs@@0*c4?Mnp9IWVZd2M8SLlxThF)w>_Tnmuq8A8Sik9{JlWi*5zZ-fQ)#WYi34G7Ob4a zs(|+kzgfe2?!%f5Uv883WG&B0U_M7s6V152PG>~irpp&VX|Gx9%MdvSNu)w`>|(h% zA2!t|>=()^j;S%hp>hFS{pW%60NcG1rw=K^M4_;mKekMG5 zs{PzVz?~iK;|C0TVzN6a{nxJp05ZEnc%A0rRAw;UUN({^JS+@-&qR+O9WD1Vst+c5 zPSI9Rp8RPf&Z-?f8(sXzeuJ{Mwl+ZVk4Cf$iEjCz9vK`_fh{(#ul;lr{9Qyix5ys# z994{(u(Gh&66XvWLHdI??w?BFpP|+li!KjE#eoXNNpD|Y^lR66DOHMKuTCR5LAMhQ z(=(iU?IV5uRGDzAJ%0Qhl6sh)peT=BSg1dRF9yLG*OY!E#Ullb^MWl3|8`}wxb8*<}E{Ya6McE%Zto{l#e{m!x z6VXW!D#2wm|H5(J=L5suURlJl8An3sv!8GC$>S>BvZpM5n%QRzGA@lz9kh?1&@}Q= zwvO4qz+k^uxK+dZaN*atZyx@0eO)mOEo1f%jI};WJ{O=hJ#4fjJlNbjIEZ%N=~#~u z@O=^^^y=PHPp#T;)4}AzQaW)65u5L|zYk?21vaa5`B@0oFl)clP*aQupLeM(Rpfgx zYk&M;(`m+*?XLafKzN-cKjYMH?`O1GL>8Gaoc4yc?Ax2$+&Y#5*;)vOHiJqRiV5!! zkOmQ|WAk<6dUXG*#G~ayafpL1?B7hK_{-Wtx&H0M)z1No1AmKol}%z>n%6?Or+Mvy zRZn-fIjr6G6P99Bgl&1fNWX%D2TOCtLpet~@fYhHk62iw^}_xzhD&fcyEAf8!tDH2VV1J2@D%ra4>@{Tt|mgYVj_t9vyntaM}Yr6ucSz z1BTUbjtWrREK_zH3#iQ*bIbV_=3E4I)w_9dwzheL z=a}%wbtq3?V)_^3oYTKjNR3j&)Q;WTAf@BEUN$snc-5V?8mG^2LA2&Zagz}<>uux* z%J4-zx@pJp7=H7ekjecm_g3uh8ptPbz|77v3h0g}ybm<`?b`&mXO8zXux>3A+;!Yn ze~E%?Gru#$sd30F4PzkQyN_`;I>SEQvi7ZPJcIJZtAwOJEM}Q{uZ!Hro>RT=3D`;;7l)mF* zd08k6db>Pcx$K!XSLaRt6UWu|F}(q!Jz&(FM+w?$owYh7Z``<%lunn*JAZPz!*j(Y1-X6}Tgq&roKb6B^*YAq) zc~6|_ee)Wp?03f#=QSx-^yD9dY?I_;f*lR@+q*O8JRkam_RJJCY8{ROqlUL`Vqu%a z2DhYWJQTQS@qgkd21#?#8`}9RCB{DdI7}RI-CeExiNe6x=yIq?=@l=e(+@mzP$mrT zR*vCSyJtVYF-kQyN3)q!Emlc}iQT85F!(4&XiNA1pRXmRL+28t2Grf*_dBhcqwa(5 zIHtC)R`^*;G8)3V>Hr? z)Pw1to;xkdq938cJ_;N2H={+m6wM|R6@_(mnwx$(CILAkHICNLo_RYPmU04g2jQ}Zu(tzD z7)YOG;Igo;d#dKf@!Olt;qSO5dJJ*pTr`!ll}b=IX87)I%WiH^j(k%aYvA!Q7?|BO zth&fyCzf2#&(CmO47+~t*!=VVHL4pPulVU})l|r@g!Z7bmGf|>h5Woc*MkP#W|^GIFpm#SjS6!0v)l6cIwK^5p88T-Y4H{!hFTwX*(q?@95u34kMUh8yd zLj~#A6B4-9(K}g%i6Fy%si@h@!q|62Yiqlwpa4Yt=A^o{C2uw@3zrAeWg8sSDjM&6 z+1F#`_|3a`Q+A%xhzjF~(A?^IOOw1F9BaY3y#QNMFr0P5DB{-4CV|iG z+rTT7>2*d$n*mZnls)JKA?m1D!z6*Er|qCq*MhJipt%QrVK_J}Wk-FVp4M{I0EAvb zR(86^aR()ofK!oCFkX$7o_-n9ylM`noMEZ{-6zRryEfG6+5xNU!qrNqV&rHgD#x;X z!YZ)&Il-cgDzZKRi?g-wEs_Y=2TB<1CI%8B-S<5pHrfeIwil5CnHD6yTF)BUYWuqr}67xU!hN5i46-b?M!!jDPZnOA_`I2Pf@RBfK zZXxPGgaF+gXp?Yq_XaFDNkNt$&{w}KzX5rwGcjK8?(Rq}B&0b+?+!x;ut$4c0J1)6f)XK(Cts0p==e!h^O%m6*sREf7lVp;=~NVe-Od_YDab}8C7erl zMw0<(Y>B26c5{KlZ`XUX7*vVq<9%vWO<=fo5fpw<&7lBHvd zV+MN=;ya5S&&4HfVPbzaS6{jjBwZEEyq}wCX!YFXFvD%9EVlYUr#fD>+AR?GN4M-3 zTzB@{I5+xQ8COlm?W=E19iOp|s*j~k+JF=5yzBm36EvaA{*y2~RK+BWtd2`sA1-zD zoRw(B;@w^1H8u8Cq;^&+s@WL&m-!`#0p>SMw{a7O!d*&AHZKQ|iWr%gNGV-F;sd|a zGmLGpUsDLVj4Z6f86e(qkr?q508r6j3KpC!`7AL?S9y$=5H&JBeO{K6)3K0+(*D25 z3f)FZMRKGj(sp$Al$&c+ify5M_8a5Fs^y`;A=dd+S?lZR@sVMJ!VR4-s^U9QXUh4- zMMy&QF%yFB5&)Tk*B-3;Idye)@O;{||B4m0NI3R){O(5braVw7Z20RiXS!@rpx0qV zM&50`VqIW0rD(0ei`M8BUyU&^=s0pSp(@SpV9HZ-N-Gr)mV9qqV{Q``4$T)I)pBi7 zV(T>Y6xu3VH&~6yjngZ2U7GT-lQ8qUGQy}W5LeXuW^HDE`k5}qtFsU z|DlSYPA`~Yc~@0{>N!Rsce~Z-Ct|~QRs}e_Tex%ou>hHat01l|m)D<}0tbvjMj-)& zhoeF=oHdYu#R>Yo0OJR$OdN#IzXduGz*z`pfzB}Cn=4Re=0IZKI6krsB4VGdTFV=U z=xSITwordGt2Tw471dmBVPRn=CS$PHp|+LxoGLu$l&XovgsFZ5PH6QJzX^8i;!#^q3l$mx*os7^RY!2>wzad>o0O_5C&M|@_fJUEgGd%# z7_*EU8!d-T#tWN4ianuZ3rAnj`!?lU1cILXS@wAN=}9t(y?3*D7@HLs*Wgo#SiBdu7Qd;mE-3?=cvtudzO_ zrew{|$T_!as$x^hc_njnaw2H46+81L++`}&Sm%&!GwNUyJw7~4f%)h_tSfzSS|C|= zOF8_cU5OohfRf(qXj(JzmmCXPMG@3WBk|iQ28w973@vZ|ht`z3-QMVgi8Y^+paL=e z*|uc!>AT#VHdZe`426Th!4$0D^?2JB({-+3z#A<$pZ2*9KKU$|-JtlfnU3ljz%%Xg z%8XIK{oDnXO@n1nc)f8UrbzcUjA5`lLfOj`c8st()#T-MO;6WD3=Kpn;|Tl~0q@Y{ z7MP`B+DL@FVmK!(LLv%m_bnfhJ%0Qc6d_V*Zvr)%tAG6^&{dW=JX_;z2_@Z5JxM7k z8%jHPq+tFU8XSa3Q64I4Go2=#pe^W31X)#Zz!K1YKm;kniMC2ZdO-yqBFvH8(7UAdT2PHsg=MqfD+gEv?S3tmJzU7nZ22HfGbNveuCXzc_`o+9e zK2D|8x>TN&vE;j1xkEaIl)T1FnMKY3R6m6|%X!0Zc8BKW%TF&gG$fS>J8Bd4yCXb# zkCtwHK>C4GDNQzZerrn!0Eam_-kkq1W5+xf@{o~(ZQm^@rl!vfj7F)x(s5AE*JN+- z#isb`(pp~%IPwidiRy6qahRwT*8nY9>40PS0O0NZKJmschqe6_}40`b@vS$WJ-FP|-s zI%!%KF92$|d%aX5lzrSf^FFmNGtG|8|Arq^7^}^o*3XYAIu4V z*r`C&RS^HHin3g}=JKPvJ0A)f(zT5A^etf2<=%nqi4*T1s7-=Z``|9beU?Q1Z$xtO zflU>y%Ola`UN2uRg0sz0lx*bIkwHM)h^Bh`U#jo-6@U$!bnHH&s3iISNW)?l_8#h~ z15NB(CWPb9u6l%@o}HQ186JaI{oHi%=hu{{@k*TpckXVD#^c{fsYbjFFxUU<8}WbN zllVU#6rd`TCy*%Z!TlQ~7!;mpk$Q_Vio-(Wy{7x|x9>H#8?GPOaTpJq_hxVvn^myV zx7faQMiYAa537ad!VHqX;`cU+0jqv!Ov-l2Y4xvsNxwAz2KZ7OdgrHG^a|EjG!4H( z`}H5{X21r}90y$*dMQuR+axR}&wTyMNIfb9;9=h$`Zmm<=*yl@hc4L7ZL{LH8vRt3$(f<{ygly&-@&I_!nA&=Zr5yo)^G>`dyKU zCx$0{D$4(sBK=wW(L9(~SXHUKM%;g_jvKaMwzQ&D9lC(?IVYd$iA~SOs zZ=syBi^8)eK-NIu=rS*W=U{TK%p%T$W`hm%n@3kiw6MjO8&`o_R(DHriYMkL!?w~c ziRnZ2b5(kSWAqa%UOe7EG(V=N5am8+V77yC<$p8q>c1#k%BA7pn=VQq z<%<^O&@i8UQ$6XXyLb&vWNH4~Qz2JT%TDJBs*9~^I5a0gG>+XhzG^$xMR*r5f0+0F zCxDFVEsGW_F^6gSM86itCzJ6y+OML)hAv^{$*XS{d>MGz$3FZg{n*z*Z!P#-S`n57 zNF*#s8Sj}hkt4 zfkvNxI$4;wHYy7qU7|^cPSD;3xL`EYRqD^-Ve-2hmXCY)9Gbz%7I{R7RXyLhS;+oN zja4vX1KU>)VuGQ!(Z6UXzr>_gQ$>!}lldt92kTyAT{Pei53wfe`BO7IMLL@^U@1fkgRtdS+b3~(mE zEFBx*g8H%`lz+koxp|u-hzUlM`NR_~?bkL>;jtB!RX|KPSPjxsXiqFqlsWWYN8QEd z{ujiBC%17n(vI5Ug{oVeG8?P48F~9!D&WJu)a4)qYJBqNn_Sg@Ek7c219frm^|>9X zb0yAK;d*~rpVo-#hyK5aWMieAP>sxS(FBxxnSgkFofx&2*gQfZ^n6#t>)D(IYzyNe zR;kd?T%?aav9$YC5s`=gH~7jQyrb`$ zQT=H5dp~$EJpr%@o;AoJQgaKQnGf%hhWHuLM8tgW!ELZ1OT+gzj8SG5DS-(tA*gi_ zC*mDZueQUT9tR66xqy@x$R|x+xYjfTCx_6Yu%)fFLzEW2E<*A#Z}CcaRL7~}E$j=i zWJKgqRENvxQFPnF!yG}f2SK!G9>n+H{YAt1k~SEqSB|VSM9mT&L05QKh(6$Q*ia|F zL2!OFR`wk0oWz1w#b^e?23{lGUwEuD=3BQmy@3D4?XE_eYnFF?Us48?l?Zg z&kb*@H=P;0Z!>yC2ZI{#c~SG?7y{otLHa@$efgW{2XZ0HXlJVZgfZ_T5q| z2j%b(S@=_-CnSdJUH;yv*8+ZdZy8CK<3nO^sV;+36D%V;ROGpF+b07I#O*6T%E(2Z zdW3|i^1%-?pw)rVA%gx=>-cuVgEW5lYLT_K7p%qZnQLokzv)TY?G6tO=WLB%@CX!+ zAerxbJ-1wVb&?sXN366us zZF{)8{g5N`gJ__kcAdhB_I!H9Vuf)4jR;-5y+DfYjxLCyCpypL)a3p%6Xf$L_&}yG6-vSCL_PfJ<_nJLmDn;-;vQB`+E32EfPTCF&Z!QC;B` zhrexLNl0Cd@P31}3fC!~2(~%|L2C*J{Hc@Chui-CETbSneKP@7T$mdF`8ZS*TI2OMYDcL_G3fj$BPE=$zW=yz>qAY+=UxqE)z+_y z9F`1OuKM%EK7qKfHCPV$b29+)0P|!7DDt_2)igBJG+r6&8^6`1G3%ToVPs%YVpL#d z{rH!VwjA09h8yAv+ONbn5m=|+PG3{Qnu1p1jw8juz3#!R<8fTz-*h7~=EywGJW9Xg z`sc*G4ZqK#%?wgD^tn29kCQl=eGy>fV(7o9Bcr6u`m8B&@aFN0X(Bjz* zRn~AyL8n~^eFg4@B#4$C45g1A2?z+>yZ5c2c*elCva&LWge#y7nv+CB^Nf*1z^U`{ z^7gbMe2fkYMg!9%>F@MO|NYlH|75VbQ}9|*^YAF$|D>#{>g3?C3zZ7+4~K<`ZiWCr zZ@?J^l?KRoAemH?l9IBsD=RNQ09a(CkR0?OP%2)fNxgBO&@QZi9|H9JA=S+w+xeo? zW!+~5Jk!motQc)`c)~oIA>ksgaqkLRspJiXXkIz(Vm+Sm9}uv$%8SCC-Rioz!?nl% z$KvSs+=f@kLO$E}>im=u`k(=4ylZ84M#XMZ(+=|ern9Fu5$&c#k~(X7arxR^f~@jH zMM*2Goy`>eN%8Ki&^7@<^|Mnxv3>09t=Pw@au=ld_HJvZfZ}I_EKG!juuF})^!&x3ga=1H3#{7uTdsNw|i|jdy+<86iPAn<00__lX1k< z{GMUlEw4it^;0QP`!d%EsH^3Zs%z-z{r9#}`9-}3xA-j{#ejHiGF204mkb?0%1uU* z7{r_gA(|nNsHoloUJK&c`e)$N!ByVd1x-5N<>uzjyhOz~Tmq7ecC8T0v(ih*tX!be zipLtBJziheEBEHj8<1S-Lm0%{wPcdeTCDrYvK53DxA)#{{Wgup!k|6}KCvv^a{l@Q|mj zwR`UyRatm{x5u*=Zm)mtSg(fvhtKA9M(;&7PSi!nNWu9a#TVy*j-jUYak^P=jKiPz$Tz6(`90z;4)rn4DeT97>nAi zVm((Emx;-s`56eXduHyRocz|p;(=-Q;3~voF2WRW=S~e!(WrC+cp0SQ!(U2M*f}^n zB2~%H$Y|;4_}Z!ifkH}#hASZ2>%6htYSw^Sr$KL0Obpra7ZQFu6ZnxSa)!{u4G>Vc zbW}>z?CdOx+k0~=>geRu*Td2908o3V2;^pE9Ro72z&JSD6xeBd$H8Xz)}0=ax6fE! zy-0eQ^}GX1A*e=guzv{or4U2>TCtsZuGC}ZXE6thDwX^e(tAGWnK@bhLY85;9Ovit zhv9Sil&5=s>Sm`nU%=aS^lN6M;%}s1b+n{%`8{LO?17SwXa=})6}58rdZQyIm-AYW zdb!N8^S!vDKN8r!4>R1o^7CaMKc0znpEEK_;jbI5o()Rn=C(C=E#n^YyuK@ByEvQg z{**<9Gu^wJ0*&MbYCvCpJU(``SHCh)#$e%-)@M#j)E-DMyLQsW=_IoZtAq4SJSgI8 z94tUYmiy5HAyi0e7o7CEVUjlvqFd=Ot@4&kz#eQGxdzlEWf1Js6~K{u}d)Ag*w!$YW9t7OWXP1gy+<#5W^ zBTf1tK1tf%1l9>Cc}qWkeh8%4$=(_v1%+8y@;55&R-J9HsROey)oaCTUhDG;y0hei z=7hQreFyki%tF7a?7XslhDCKJgsV9BO#OD5LEcS5w)VRc`gFcsLblz`_=WvpW6$uT|>1M^^GePI2k6)@D{Stq_2fG70Jd#f? zCYtK3_FXZ&&}^Q{3!DQ@HV_`dvEcE{rwWqiOFQEC9fGRXgKH8~j%7B?2 zq82a^PRIiJj236%;X7du1XxwJzbg3I7vFCL2J(fR96dv?!S1vhu}@<5 zSBbmTFS2P;b?*+Fr)f$`u(C2djLDQ@{6Z)pVZf{F^M<3yicLcXd2$E##hRHJ+J~|2(V}V2qNdF$<-8KG>o4YHZZhjc_I$Xm0l;h^n*PDO-{J z8!h*G@I0t5RDbuQHkKvm5g|kgSl&L~UOwIh4+;7W*rl26tnHJtzp2m4DPS$@x-_KG z;73cMx=oPthrvEdPA-Z6VIc+Ic0qhRC6*?MYOT{Au#zcqD?s*u-4p`>6TQukUktFL ziRBx#z*kd)*%=Bf)nT~MXDNm7qiGt%_w zHOxLMuFT4xPtr+zOdiR9iA$fcXfX~}+1tEtvUoc%K$6FFR2;+6?R!VT=J%>>v$Pb~ zT4PGYe5Wqkp@SnXvahe$ZR|qKQ03aV7;{m?+3s!}pF37}Z2hkNuA!{`a4Wvp#qWN$ z8{VIB|Lo#_xsJ0g+3*wHT@cTi_g#>#%bWK~z>QG{~J1$Ul&W|@2KlA;mcnO#5w zBJumX?oM~rgoiwpa+!QC^N00>ti{u>Egw-+XSBa}lnt=4t1#;)RL$KVt$e6GH*21* z2X+#ZoFC2uvF6Wo_aTej;%e_>ACTd<;o%v6`zA+nx*og8D&1@weAiyCJ{6 z-3*`AwR-0E_;%2h>;WSZW=b9>`e1u4zS)#*s`8D)$C>h>p-PYVc?;UB?Y5a%8LH!4 zoOfHk?*&~!Q^n`FRMt=v03R*MuI>jxCSL9J-~Rn{ek@HU+LnrOQ^73!tU(3dn2gXmW-SnmAp9La@!7jG9rS==M4q|!hW*NyTQ&X z2<=j!EY?AaNc+Ot@8iG2kwxPp%~rJeyenQ<7GwM1;9z?@)0~M9Emx5gAHP52mBAf8 z#(i!y1MHhuAZwC?fNBWu&uBasrXeG+BP|ak$zHnBYSh-vxm)=P1Rni?9Fx8Gv1N7 z62JB9HQnKswE0D1y|(ro7l}{%f|FCl9|r-SH+cEe$2x1ngIuLf{G;~2E!sA0K0Tiw z9&K-v$Qk?$OK-IuWzWV=lag>uU}erA-Y4&#@pjMdCBstcqoLUDx)I$7QrG!qaS}vX zacN;;nRYncw8Wqhrtg#GNVy?J#jTpXB7d01yl8pUh@2($cUS`?+gC}NbZrvDyI#_> zbyuZZP(P!oYOk#o#6(1@*r)F4zq~49j6_KhmmfH#E?5xda`Q4Y8H7FHr-R{~R@uZ^ zNYsCqN6XPR*;ty#`blvQPzV=iJc!25&Y&=I?yQ6a5AG^(iT#dUT7q2+lOkYpPw%s< zZ~prALMuUa80K*1o|>Gc64TPrIL zL<%4bEH)SpDHB!Mv|fkKe{Lyl2r&qTXWaMdAYq6aNw*9(i|EOh?H2wRQvvgmYVI3j z+U&uoH6HW+Qk&MoFZ!jbv%g-KuP-c-t(#&Zygz?tt3NJGDq`ej)hvJUkcH1wm&VO< zyCzTLbR=gKKmELx$UX?Plo!+B3-fQf?~Qa^k@%4~{ZS%7qPw|KnurH!PY2(Hjxzp{ z&TB1fUkH0XSU=fN)&3Lowd79#BcI23CNbPkOvIO;!9*}r3W3dj&^vD0`~%gxn&RgX4&yA%iK?odAdygdI|PS{mpqol&~l4WB<({T6{N z;7zMabxlRZv|$x$JYGINJmhj=PHyhXhngHX7>}u*E*j{$g0mz4zWWoJ(QJm5PZ<)WfbWDfx<34y~ynl6rlj^2_2%5g}OC z-qSKgW8+ma-NFssKe_yGCfBAC*WeO{_Mx4DSCTEf}t z@VZ`8EEw>nHeDi?aOde=`tI)dyT9DE!rUhYpCX?RL)M^F{BwRaWei{JfR4`2;OZzM zdoFRd=K}YNhI<~{S|8l_`;<6XTI#vPkde5InGm^Wc3^+s($b$J)azrwllbC+)nS5r zm!>@Z*A-$Fr^mAiBK5qovOG0^ar%$;z7)1Q5qYO^dQoeUnpCu!UoR^Y$r+^)7wLMi zULS4{9UEL(k>M3qz|EW^9UQ0ig8I|fWtIVQ1X>C2|9p~s%r^_4(}phS=<(>(z2TOZ znrq~IP3HP&49SelsK6vMsYnf#ZpE}KFsvn>MYV4L*EZ4|5#Ox^Mg;KfN}D{Vih>5~ z{QQ#(zzl#>V0ev;m{{Di+J>oC8Uq1`nU^*;B~w4(M8a($qu!9Y*$wfNn-jbZaWHIQ zgJsYnWXoke0?-Nf1Vp7b07FKKCwCB-DL2^p+SG;dcTq%sT#%(6VS*KYS&PVOh2$vymQ8-y!OUiO`}9 zwcDOQs*a>neWP!Te`h(~ieC`1eVvqA=6TKIwNv#E@?--W?ivzNcu5!*wntE-lT1Xl#=+vB||u{I20rBWMk7xC`q#2-I^n z2uV%V71bL$x~VjOgJz>4E=w-+z8A*deexz$BS zM{h3TOi0}S^x#(Vc>k_q&IM#f_otI$)^er{aU$jcL{qDAU=JQ%ZXfS>LpnKQKkX`3 zoXvoajy{&~P;eMVx(`9r&}11e#Cs|=cwfDK>ktq@5(kAV%12Ed2hF?bMRsLNW)4F? z2fJF>+Wj=&i@QE$vQx+&(3wjdlZ`JgEPBvQ#qr72Idwv&J}yRrNyeDdwlt0EWGb9e zi=IK|xr9u2OI}V^SnC~*O7(3mdimhm*xacG`bzs9m_A|!CO(*Hw{#AwnEQvOR#a7m z#cqZRi-BRlp zuZT#BKuHRX@2|=3ewY<|*bPcm8#*4K8RYa7gn;;&CQccFg&+9Rv+Cvj>Kn6iQJ*)7 zM+Y6W6(u3Pa}-W|2F{RY4zIHgQCdR6uXm5NDdVe(vf*exm@6q@I>u?|>lFgH_zd92 zii(O=Y5))Z_<@}4>)fgb_rO-Y?6^d#)#2|MiPX=N0W^1RJWIMRt>3(1BnEFB!6T9VzYVI_{;# zuWXV?-L4=+Gy+31JRDy3?EXt-Wo2-9Sy)(nnVbIYw??ddu-~+R>uzrr-1Q#U{M|D_ zbHjY8vPH|*;a>G{I$pQ=#HfqF6mlT`6C`e zLZqLfWE&HsM$DG8$+J{BC8c3Jq`JoLbmnr#Z|ti*GM7b=%Z06Hn}#lxGi9~}L4qSA zBk}2u#0bg2b7nI?c0rc?*iSs%kt3}i`P0%r9g(Z~DKqP(x5oko2^giSx_S+WaEVWR zhm@XvEh;Ruw6LIOV+#%lh~m%<=oOMk{R3UXyh2zts(NTl3}6j(0)kz%yvlO!I+jt9 z8rM6Kd#}5*0sDfc?~kB)6bj7*fuVSN%*S}K0A$3`?=_Go3mF-5^ml-Kry+fv27!`r zu>As2vGR;-?X#)FU+jh^-Ju~Lh>YjS4ifkB+}7U$=0lPhOBAUHcHB#9>IuvxWApfeKl)rHdJRjDJI|J;hDqP z&&T(Mx2?G`v~fds<3(biBwXPlC5vHJ?ENxi;x9#`pW#|(BOy;<%bOkqR@mCgfavwD zw-+g?`qqzP-gr_yd4JWlMiBNHtl=jsWm|AQC9MN{F%$B6uDoz|^}9yxUJtk1SC5}9 zqxwRo?xpgd40Nt+E~rJSs499oa(m)4q9VZ!WWDB&_+uxidB_y=p z7~`7M(N=`ki$T#4V%|ps!PSY0iO|{tpg&j048{6lhKYbqNU2zM*^ACAso`C5g4w+W zW#F~bZ4bMY4B(sCLI^pmoA4>t>(pMNruF(j#`_0y_`&1?i>X$%UG7X7ESE>1B4HvZ zC@88(q0RUT=#&uUAR1_)qoeaRJiOm5NYK(FjH>5|Z~h`pwN6o4L;mMFFT>5%#;wJ- zJ_XO}g=$$ftL)AeO)HU+PmQGyDi63rt+Mm-PHIMjovW&g+O4g3NGja*>BI_^slT)q z7g>Xf3w~YR%A>#DC5Z9NSzjOa{m57&_8Y&SuHYsYiL~asl$R%ot#zfPLD+cH5}ow) zUzG!z&W`s7)b6^T-a>eFFCCo>#^$7^j*PVxr71dXo~HKvI-&T;dt%Pa!kVjOrpg%F z+fBv!0SnC(Tl@?j4pz}a)v{Iel}v}yloFNO+DMEBTyX|#M$KCI$F5~sphHtnfkRSM+?5} z*y~5diTBHLY8yeNCvWG^-d^e#V$_BNX6T^FiJAt@Dw|A^dp$;_@ovYNn}2$f#rnT4 zz+{b@=$+JhpS0_6AX^~0!bT~G_@U3x**U<}yXRWq&s2lt3o{-MyOy^1Cqwv(E);Kw zijKm^0>&0yH;@>J#_8_n(bmVTN@VA2U;($cD%qNKXEU5KaNLhlT}fr~$C?oKeFFOl zOvFpNE&>UOkp+oVqh=oG`f2Os_=oI48C_a6a??_+dx?){lVZupa2n?)AG)K0de{mG!SJ{1S?pf~Pz3zmP}K2AY+QL z9=FY3q^Kw8@zcuMzP+tQu4P_tf-YK63bjs-d8OhrVT{6{`l`9}50cG6s^{uY1hGv@7w6uUcD<{6_!7WN% zsND(h^CRLi0<&W}G)6%6p9A6EZf!`K8V@E%z@cOLYy)t$_4S*5xNSA6Y+4Nrng<7M z8~tvl$(lk#7^r!LwlBc*T!DZb68;TWYoFZa{{9_kBX|nCQHW)L8XUjL$b%|%`a2M& z?F24Mk{~_sm_WS-$oT-cDs@&_XcP%8T;1G+5Gn2#=T*f*_(;8u2utv0La%g`Pz?GJ z0Khnyn2BoSP_Cn+^TmaWRfjMJ(O3=<;~Ts4{jL5?S&^OfLwV8q4W<)Y8A8~;Pxmo} zoz^>!0-+7SC)Xdg?kk?2s~lPfVK7vI%dRYlew}QzT7O_0NY&73mwNLyuTPUm&qIN!jgk+-7eZjyym4)A>jGa` zZy=xNC)%)2M<>>t7!W~7MHFUn%9d4dV9+FRKetc(A*srq%r zPP9Ew-$0$L!d!~*nnOe)A7{MSUjhzSC~CeV-pihcXm$6je+XkCX5(&teR(QXy^DoO zI+Oqkq4C(A7V^G6%rGbc(Js>270q>#uo{h%X%9i#)Q^S+e0+SE&vyr2$wGBCa`_bg zFgiXCcG|kKGCQ#8IT^!pi?YVHv^=j}0GS5Fk6Bu3WNHY9(HPuvTlI+;)s9vy9cvz$=3q|K_oFuyT% za#Q0u-BmM(7eoD%w!QCF#_OkEsVZ6?Zhcv|!uI_@DhQ{LIU{L&r07Q?8-A8#P=A-V zi^YP66$%b^_WBGWVkS;~w&j`it7MG__ov*0;eeHNnm*}d9+6+`( z(sG+&X3%@VR2xq4`fX6j0N>a}nr8pGm_ctvzJ}S7VO^jiE^?>?FPa9K)86az|8Vt{ z0aFx%DPLXbDN$C!i?vgI)?(TQcU;W?n;a=|r4nF7X zJ$q)&nzablo48}Xw9{z)yg2DDi@uY>`c)|#)X+Dk%R?GSrl$CUq#qk%u$!yNN>n9= zhRQ1{&H)d8sDUvKU{ykkvo%(+Z(6Jj<@a_ zQmRd;^_x%jfxfe88J@<$@ju6R?%bW~YQwm9+c-icPeD0cd7`VWL!xIFaoa5UV;M`s z6-DE02LCAD@9v}_&3V%vay3$AG%tyc8B|eKa}HxZ7P*va86<3;5hc9 zOoV9z6dv9KK}6e<#Djat$V42Lk2hix6T6r88h-vn$`nU__)uD0+{(r#tYhIo9UB9m z+Y|!_Lgwh$7*M~#HU^H;Nt6?aov}PlMPg)BG&F$cq#q@}rr8`X2QQ+ddgxj0o`{%Z zy?@SNKEVO_0QjI}DXAzbHY`}fr~=qNmqFx)&bq?4dcVROxTROQ@u*BD6+7iW2r7*Y zxMhUieF+=*SkKAWSLuM`u8*c`(e*56=x`tBwF>S@je#>+6%B1uM8r!{L~S``j|Or3 zYA@T;@^WpKK!q&yH?M7Xws;&CbramUu&^RHILAZ}A1+x{dVbn%H_lR8Tv-`QRV|ny ziGQ>q`0-Z6HJ`m7eRgpc6Te??*Rv1WjeQrn&Xl|zZGCmhJ?4>-M-RD=%2X=&h|5Pw0cR%5BpeK0B-M{&_F?_u-}b0uyKb;M7|6Bl8!* z$Rexbdne~`!ltJ$yoyXQCu#oJ*hH=eu4dyMPxdc+?jR%EySexb|M~?;ZZ(Jj5t|P9A&z^W)v1Sw#u7vbO9P#;UHp_7C@PUCDP5;fEDx(4>MvS4!vz) zvQoOGUtwg+5Gktn&c&?Ak>n$9?U6frX1XS((+6XCwA@(V5+^jb*IOe`d$$|-cb^ny z%cHrHVU!1NIc%JQ6qwQ3DGE?rVLkJ@f#lkG{qa^yOQIYctV4QWSAqde&xU zfPRdRyIj5hc#QJ!cb~kXdea5U#T_5B-zJ3bu+iN(0J?Cya8sqMjskFaOz@#`F?^Ko zWPPGBDtUDD^bWSRZxwY31YlZ!VRxv$2D0Xd>ztrC7Akh7`STSnz_oPL)Wrn_hU@#C zkTJOOX_mLMKWCO!Rh7BNd2T+geBYe^iBV5?QzPN&#@Eu)g;R&CmIXFO$`=%2CMB3jr22Q}2gN|Lj?o`~~b6 z{3%Y@x`UGesI%*Dr;n5E9z8;CzgAR@wtW&u;)#TeRx&|y$LAj8LCf#IV1c~O2eTwD zr`hCt=ni7gD*6J6u9&&GIW+9$c}QzOOoECJ%lrY)#C-pot%5P`7sU8`?crfz!&wW4{Z z1EP@*AzEPt+6g)jQO{Jj((=(RK$NY&Z@9(c0c(|6;2O#J9zJXBjoLpt*y)vF*OxGG ztJDAvNMiSj_hm^g=AqgvWj?~t3W9n<@X?_=7w9B_d~1!4#tPLqQt>vs6gEEK77njko+Da zK%`ssHbQUN?VFdnip?9RJHItWWxek>813bbp8o`!O7Y0ZS@Aj1vJEkW=Qw{DAsGx% zW^Ai6h^Up@WYo6!ibEE4KA2O&>(oDAMkF)PrCA_Hr>s_u&F{GVX(fagqQBGAbTZ0; zxF5xenG{>lWVpy%phkW)LXYj2=w{DX=K!y5`Rj-ygEWnPi3(dGZnBP{Di{))tdT%V zGF)RO`GxugGm~{bIfdx{`cT;{mXDuRfptpciDuDj2gR(dECQmsyf1NcQs_6KI5=31r-C8 z*;CxS7eS4GXc6yTdH?;{b4vkv&GcnDjft$#pErJdct!MCA|2v&2_!p~mWE?Q;SzfC z^1xw))+NA9i$q$8I=G84auXbkJT%0%IHvvPO^h1d(fm0WY#(zz3n>A;FdrgSugC8uTe}vMiyRHKUTT7J_>w9evhkzHFn?#oK#!X zOG`-f8<%>1vYKnDKihOEHt0$_GYC02SyF|O^t)q;%m$ljZDCM8zRK@x6|-oSt5lns zN^)nKs{~Z^@?ba#kb|nQ$PBrsKuI(=zoA~WAFzJZ7>MU=^#gj&ZY53_B%Td3u4M-{ zI5;>}=M$C1+z#ts8qOBSE%ca1q1|D3KEhb3CIh*&7vu<%ku)2VnJ63dVQq)!#|@x7 zvA4H}=RLD`qF!ykF;sg7tiFkfiG7h{k8gDfYHCvVVpyDZ<2h}Bf9L=$wUvP@OiavP zNvL^&4)&>XZxJX;tTPY7!^1&kV*9vI7dDTrk1OBw@bFP#3vm*Ic8VC8@oIW)&ys3+ zbF&7S=lsG#ANh%s)BfROPhX)F_35pQ4etqM&rcE5(`}utk3EqHxlG&o)17_5^NCXC z5x?!Wma}t}N?wK!_&y{ZbuvS@l@d<1E1C*R&cFc>SL0B16%4hDEE5Jj7n7z(< zxZF3Fm6-%d32Mvla+NJ?U{ReJF9iePsy7v)+@;mA>|_G_c?OVF84&;7z9=`NC?slV z1n$or1VbO!2}htiAKU6(eG%a&G85TZk>HAEdvwi}{j;au;YqDf;tuC*PLt7+j0(rb zamL;O%VeRbQ}15b-AqfbJ*mIybBv&~7lbY)T>qHiM1}W?$jFpw_q?*4Z`CCB*bi06 z`lbFfRE!M0%_};(*q|rZ>4;ZZS=s)jp71~1GBHxkaL!R1L_wth)-L{W?B)d~p!)+! zou=DCgcl-2;=YuUl3!P~J9mQ46hImcmNO^k$*dRRxWl@wz5NiOtcbq8k@$T8NS;<& z&7Jx$bb#cpE%&cqN}|GqLBYY!8>M=(e1jEP&S1J-=03><=})y>-YYQMUP|qX^BAi> z3ZqT32mMF+GZrd({+?~oPP&;Do{q^+Xa1<46J9#>+`eN!sQMDrS)1276ciQhaNs-! z|HpFWTwE5zZEqBQDM`uH9>H+TsAvZpZ|ySki}l_gADufEtWOp|wMMh<(df5n?}x-H zDk`h_gSB4m9Uaq-7YFh2rVYjBKcykGXR}c=~^|c%$iyNa6DfR!wIPA zbdr7={8~-z{LmlE8BJ&M0xX0g-T1F2wv%Dx+Hk{VYem@CZkFzT%)hw+3rkD%?(XvX z`uYt=Z-|i@51z1f=9=xgy?pbg^7us9nM6aKk#ULtWPT2NOf6>+aE5jUfn4g%{`7Ro z$Y_plcEql7nw}M2<|@~4Ov?Snz*pl#x3ZKCU$+8Xl2Rt=IkVp={x6muIOfNC1K*SK z%WCM?E#__I=vYu2jV`@PRSFv!Z!fR=*4{aM?6j+o#3EZs17PW+Kg)6W}m%*-`~(B~U5>lpyvgq+5E>qSe37pX*skp$te-n5c(WTDlb@)! zo634huiOT?4SRnlH+Ube43rn8%mPan!UP#NcO0sMy86Y8H;i4LX2%EzD(7j?M%2By z_omqSrGf%F^yf!U#<%SIN?q$hQ6ecesWJGbt4e|KQ1;KwADwTC!Qf_nW#x^cq75yd zI{*_#Mp+(2d=mn3S0Krnd3EB$o7EPUDRj6toa%BE*xA_;HFtS@5fU3~EFcCO%K-qh zSxI`lNpXRntO8I$~JWfR^!rfdYRzJ3xi#gMsJXA^?97!Q-S>=mNt&8+1s7t>>B#!YE|}3Ayv-abARg zMr&y)D~TsrTDv>2?v(DPu9_ng~EB6b)rLLA)8qN+c&S9-rR96#B4nk96%H1G+ z%0(q$ssw@Xeggx3kT>6LL4K0up_04V4IaTvPttZ#f;VMRCk>3Dqh4)QQ`C2xD$AtX}GTF)q0VJ8UlkBhb~%ERaNp{ zr6q|7Rm7^aQ%)u*?D6p>mbo0Q6uUN!I}^soC-7gs?U-G{>HFD4Da*nxHo+29`_0y3 z1(rY|i3V^D20;w5Xt~l0=z{vw=hGfn>3%;wKb;_A7oh39655)wpw+C?zHk=s6-niU zUR~~+Vx*&iZ<<#Qukx8_eoR`k(>}io&f@6`&)h`QlTcB~*!G{g1~cKPQAc_~ccq=o z>+>m--}q9g4n}}ceDoh)qsh{vt9!{=i@pPgGG*M6xY~uflFf(|I|IMdUqSh_I4AeOXhx0(lrzBa4O4>7DRSaV@ z=felrvF(FBfC~@o5Ruv3CE0EEU>^^7hlGT@rePcN=~w`d$brMj>PnHb{gu=ta;egZ z>-1;a7njkVgMfA@;kpF9uJqoCyttmvGn7=Li^?M>#eBd$KaUZE4xTxNG5nQP2iKsQ z)IQR>4wa|Wy~5&R_UVdMwHimOVTb9y!;!+dMAy}4dz+BlJIpqUi-ufEYc{uBUqB57 zaTj=@5Ak`vI&ATY6mt@{4h;?2-qldtdU^E@0YmV?z2%=}kVHytWtZ%0_+%!=%WJYv zBD{TLl;wwNCuz9uMjqB?Xk{uOCO4c<;e6*Vf!_eTjSbAPUCQX`=>_5OX4iF%=BQ|B z=dXes*;M0NS^{z3cyApqgsHkCdGOtF$P)=*JT? zf~tQC%}k}dR#$GP1ELog2c)#DLE}R`!TfBI)U3QbxRH~V?5cK+(T%a&7oJ}Rjk&k~ z`>7-7{(T6G80@(%=VK92=@eue85az~I$WNGPRE%KR$#bV{QzrK&0xRmaTiVy0Po{Zam6 z(}MN1>sp>cq8l4nvv5&SQBtZpD_NXl6Wv>cdO8DI0YW1qIS&V^CqD1|KX6>RnXjo(O!qC$=aQc^ek~Ec+!lR>6iHSWf&rkY4qgNfm z3%B_8y3nm97@JV=R(fV;-0Qf6h$ay_=)_2GwYRk~(n6-1n`ag*A{=KEZU(!Ri-W5_ zN!{2u+b{81xk6`MB^>Z_Pr>NZ6PSrU=3^z5;*@=lQTnd;FkxVb=F;`FrqtBXEh98j zok9VM;fGil0^T8bLqn+$vtT=m$}K`i^owx#E9Ny62}B880kI@4clIP~Cx(u`Hb1kt zV7@7FN#??#8t8tOfBl+Tasj$1$aqX?gUB1Jt6ch>BMTkMkND1)R#{C(4A8?qql0ji ziWx2i)MEveKfU{znMMvjer)XSE`b(ELQ<4L9b;J`(PZ_22<%PB3{%}oR5n&TE{*)U z5-!gYAedF}=vw$6dJF(u#bbLttm{7J2*bMGK1O(}UmN-iWa7=%l3`K8i^BPXcSXNY zh+N#FZm<9L^vYk+oSW3eN_6A1S?P)s5|D^us%-@VlvIEU?dS*;t$zF6t~gfKL6K_l z^nR1!K+%p^kv#d~^3}y40#*LA@Ukao2dew$I?P1nGmyn!8=XqWN@+47*}w<~8=fDL zw>Ge7l%~fdqjW~?UprQ;sC%v1R!oT)WXcb_P_m5rhCUfR%#NTYNB7fCBIGV>X>P`+ zv$$NG;dSZv79xE{M`wP%9hW}|xe`L}k6x+)sg9^fBb&dIu*Z!n7=~Y?MFK)D(d8vr^E;9g$Juk`4`Djx; zb+XK@&h9Wb22d(j$b?H{bgCwmF=5}`z3ONGj-s=B)P%ww&Keb06`4Q9S%`N0(3 zKo-%A&wBo8S?z9u1LgxSZQ^u=ld-E>iEpb_D13&;S-dsuv@n3q02m|c=lHj8cfFAP z9%5ZZy{1V|o@J)&EoIW(Y)CqvFvklnl6jz0CGc?bp8Qg*BJ>XkKq0>T=mul7!`2^- z!lCSRm~ubs*cca1ae21r(BIEyHqlxa&t*4uYirn|q@+4pBX z&sWGg+#D^onC3gR1_vb9Iw!uf%IeBO9}cT?NQ&pWic}#Ttu1wJTIjwNSwPOO;-urZ-1fsC;u0>HzrsM_c1PL(@cz)@kD&R$YgW$;nT4 zS~6r4nVKfM5<2E6$jK9UtUgHZ+1c16)oZ;FJltE&xx#VT(E(_Tgp8hkt8iribk1P~ z!|MghC;|+dL}~iPy_249i(Wp0l&~oFaiGW?f%xk0xw$8p50-fM0^b}?*Lz%D)t(GN z^_C&a57bo$`uko#gW}lgPun|~VmfQ$5O04dpMG<)D@osUQa_=lWR8nWbWek^quydZw{w~xjE=WO z5chN>JG4`Mcr5qM1?K*NiG74GgT9_mLlL!z{)Pu&n?-~(0hovo;+)dh6{((N@do-KqP?ZdK68yT!y`E9xp3js`lyF)!(1!Ynw|!Ny+(sab6s| znSXFFQ~cwhrly4}4VSKg2Ah`PMP$1lKfU0U8uY{_JC?rLcB~2EyF3ozcb`5ySrTEE zaN?QT3&81#J3`9Lva%|k+dCoPn>YrkE+T&WPRpAs@7}#D-KyW(BJSNL1f$H84TtLM zs0{*oeaMql4w_Z_O`v|a`0$($85x;cIZx&22N&B$DLJ4nIDlaSE<3O_5sE49$dg!I z?ehG2)R!-h$A0YFwswu414n2dxO$+O+TGK`t_J9*XfS^1ycc;Q409f)5j9_Lf3C#8 zbm8XTsjW|Zv12p(bjt47?I*Pe>Fo5%e|&i6QpH`~!~l=!cOt0dLR;wh-MT7Z>w~`3p>x?Z@;d@m#BqmS05K zaH>g4K6%+FOxm~CL$6(42+@R54caAR^J(Ft1+yjc5V%1B;>cJ|GMz%--1qeWmx;%N%7|&aqAiOllOgE&OS;d}8V`KKan{HKW zjn(Y_%`Qrh)_{%}d9-u#IRfR9vO@FpG#a(n?Bp8;s{A2=fu?;8tU_Y#umBh7 z@bFNdP#zHo$kxyfpdGz9C9o(NWFYc4NlZ%em=RDnTY{y1x|@B)XZ`l#)UKp|@qzpl3^9i_u_U_{kEb_Yu@3czHv^(?@;x(b>=I{RUck z&;`;;OW`9(mXL-(bsylEq~9tDO5JKYsksG@sVy*s?G; z-yF(I>SGIO8R)_?UtEN@5pwTO?@0Usd5FxXiW$RKjCn6;BZk`36!KoV%x;BYllJ|h zq$odFzJ7HGowu&%&EMTsWghsvh(YX^7qA{HNj;D1@H$%f+d*kNtAm{#A#nZxEt0-h z36cK8$zDq^0RjFPEg6|-%N!QwJTbrhIqb*Kj)jtI8xVChwa!vbSPD;mc1_Y!KanH} zD?fNXR+^l8$E0+unDQ!OXb4^4Et8RI$h9sN!gtd@4N$}DhxEkRP)1XEs#V{=1JB#& zH2?o_ox@I+INe{f6c?cQG_X#;Cc;ALU`=9nnBu=We-hSqkA#zRblQDS+Km_F2rjou z^%f@|L3HW+@jI*f4lb85Lj!|>>_v{AW##p6{WABb-u{yOfotaj7s=YN=@4YC-aE0) z@KSRdoXB`+h9>}3=t-<4V9NzugS{^ghPbwJ)$}-|t(n>3{X1g9S^8G*ZRx>NA__qf zB;%ovDc83~*g*P&)P&roHd(u7#+T*gnW>`q^!m=FVk|#g?{E4sm54e!Gt=kY;OLuQ zX^<&1UU$3;)Oi1D+h>s8u(OMTG0Z*y+T2>--}UJ>BjK}Dc(MKYm6n{3L~^8MBz~0q z)s6^x{D<}e<>fW)CA`>7)?tZ_P*SoFU#USis@EGF1Y4U#1TuF0qag{ZV{40F_VdjvNle1=fz(hp@H_Q)LRJouD#3vA~>{>rU{yXRT zWfDjfGTqW1=jkzVP$t@n@wq1yGZkSnIjN%D{`2I<5X$r5WB#8j@9y;q{d2t&MNaUK zW_%V^G$^TKNVnGc;J#)Iv$E+h?|IQuzrabi{rR2|=67~CYD;CUqiqpK? z-Q|+*rd}U!f){mk7=+9JTtvS!_xNG@JSw0L51%L3j4AGZ>b3S#2Sezh~nODz@T-XK!;Fx%LuB`m8w)Tp0qP%$wpZSWepQDONG zL+DMB-WJ-Sm_YMZi0%OHO>Yu;M_%HikcK7KZqBWE4V~7rn(3si#EU*NPi1Z4&8?+S z2w1oXVt+M|;ID@yYI>tkAnTXIW6&3~;IQG1^nKOiXy4o4u!`jLVURiT<+bY4g(ulH zz;S(jXK2#KC2g1L=>PL0C{MHK9;e(V!q~?`a4}+o7q?7F_Md@E`2OE)^VWE(eL3F{ zMkI^10r2D}5&!#op$#sD_uwBPC56|yThFiWji4p>zh4~X9$Dl5yKs7M{r9Hc&#s>H zlPWxoI7R;Z#XDG^|85qzLj#VFw(nDBTFL46fA0AGH)F5EpFjQiM}vUvZfL;h(k@jI zzKW30TO;F*g=O|pht&_Cgin$G_yKt1{LinV$6+5%`H=GYr%u@c%LGYZo2Y&K6qRxI zOJYpcLH}4|dV{7dj@kpim>!asNC*sMFO`N+Lu(UQqdEvX*UCytN=wRO;$rX#5by3) zsSo<$r}fLIs!}K3uvS2DKsy(JQ~2*S@j_F`!e4@;x8b(D;>ME4C1p9O$;OBBJBR(y zLwxq^83hFej2doMm6w$rf{-#OWB{K_XdbMhS5_)vV2Yc&IzJ4GG{`MlW-LJ!`73@& zVId4#-~lb$XW;1N1`L2uhqVFp#0>iAc^J??0y2Q9nHh{_x#wZT#>Rru6Oh8szIj}9 zfb*wM4s7+#B>ws($6%08?(hG(vpONwLX_(gHO~Te{4Uih4R<=DenO$4P zna39p8eMpkMcyXjnkt+u$+FmrwJRv@Vc(uEwEz29&-GRVH}Qei@%%uSP>K5_Vz&(P zM|=57waW#EaT8-n?k1N>5cPmm@{|^ zpM?_T@kLYqMd+WkFF0hz+(@-v^1cg06ToMnFGIDBFRtSyumjRV>tm&o zygTq7pk=Uf&8biJAyR<}7m+DAK6rsjw$90#ucNKK1DSImF@K%K3wQ9XvaXzHZ#Rbi zC*bhvlu*Q9{}~K?CdolTClwAg8`3do7lElxrd>Cvp!@o2M}PnR+`wRI^TRVLs<8s? zTh)1qWcnZ1H^0LFhehemahfiWyllQTD^5K^wzEKhG?veiu}8zC=u`1O4Ai{<*h$wnMkdPSrwj$=PLClwl?6 zMmbf2xZ_WG|KO z1iRNN?m>`18GV>X36fP-mc0u1mRD9reF|=Plif9;RJA$FpUY=Ub>_3|XtNka+PO$Z z)QFwjpntZUl2v&y(#Am=%k5HidK_W0;&LSF*My!Y`YfWI|B$FEK3YHsrpex?x=Bd| z+3Y=FE&_k5fV#2sz53yLW36q67SpT}o4p}Tc&fPGF-%tLU*+k51Z>bl4hzlWYR#pvosDm~BH2%-A{!WZiQ#w3JIU&74t!6fp3`` zgM)*?dMCB^VtOTh7+i~C{uSg$Yk{W>oPHt~slA+Y7&Wn&sE|LmR6sMv!mCKqA6eZg8v+2}2 ze!rT>`Uji*2qBZK-{Q1gFvhRuDhicr#m30#=%CkUZoI5ZG)Z*!?7G|I8*R(UcoI_U zBXkgCwL~|~uIq1n=_RRbY%DBPvj3iQAhU=X?RJp0-HB04;$q}bSY{8c+wU!hxk8fn zncW&7CkcY!b_L~x@n;AKylZDVB>#@I47V#PD$(V|wF~QR;Tgla^o=!kaS8vL#m05g zn&9W_`?a?A0*vm+Nt5M{!CyApz}F8NONYfhe9TNtq$vtN7gsTkq`twxO_49*Av z87*zyVR{#a%u{lum@WE>fet{ryR)N>L!j@m{z6w*7v>;_)G1}+4M9w5aPY>LE3l0^ zY3Tdr`?QyL|0CAb%%N_OO;$;9c2$0Ml?>gmscW9x(-CzAqfBGDEHZgFU1t$Zfl3a?vLO*DIBGit3;-}&CY*^jH##~RMCPihxCjUc^7f!L0>J^?+D8M6ykS6w z6rWK?2pS1Vn)eX)B@}){Ty0E$QKI5aaAoH^_28lV#&3|5g>|vDM=Mmam=*E*QHQpE9`a&yfEx4xn#LlJ(`YZ|?hHK6Czt z+dU*)EKO*CyXc>pu$E$r7Z5kp>prKni^F6oV`?q2>d6`i%j}9+~jFO zs3R_{_b~3(W_a7k+mrY=Xlk3ZTO){z-RFyEgbGFmZi1;nrRl~K;{$nR`Fy<021A|h zr3nb{-e0TN{=MF52dVqN?%Tqb>;0r0%G!E&br#xI>mo39?O+3gz9Y8ZjIB|)xVhn= zloWza$eOGcaBYD}N)B|u5||5m(~?T3Sx1BJ2XPSyY0x*Jp{2!oAPkz04tvVEuYYKe z($mZ8Dg(ZvuTOyn6owjp`r8P$QK~U}Kk^T(D?8hU>;|&wy6MJEB$nzsD8hNI^ z;ku3}Xq}88S?M1JS=3#@vDV7)*1*I}&qzjyP^axM!Q~r;<}gQDVV2Vp@fkV#v!3tY z`}^aMld46|u zvq1Xi>!op3Bb1L?Ole)mcK^7W)n0u*EcrS z$;I#K?0!k*r|s*OPJ^L4=P>1*?t{dLR@l_#Jyx}ogD?Ecf7QbL*HH1tZewL_PVOnS z>e{Ld88_E?F;P+~8Y+{mI)8kGI~Y`uY->3}09a)J$P!`m@eSf0>RS_b97w5hRLlcK z=Y#E;Uy>l%{pk9I4yHm!blxyC7jG`&;^6@S6Ou?f+OM>bU_N`Mjd4d>S{gidp{3l> z*$L_6n{sn?eqGfn+ZEtg0)wm^@BttZfmg}&A*%iTnNOck%VM2|a^JvD1Ox{IhE+Or z0#s$7Wn8Nb1_pvN`&Cw50PPb%ArE(VS2wMLf`S08Rd1oCr7fkiMnd5IYWUUhY}mtA zY|L-Dn6i$Qy$JWyfDA5yMgIm zcxW$Q4uj8IcXP&Khu`oo0@h;b%%C8z>qK&VK>8)C%kHmANxwEBOh&!)pH4^jy>9vm z#eNDe+IlW!7qoot)#zlS|1~KsyUM(&|3r~8!~i#=Sn+Tz|5jU8%y988K7QONg0(f` zgMfBl!yglPHu}O0NKYc{n-e)b_J!1(^z5vRD$l-#u2NzSvdbVLYH(OYR>ri^&ZXQT zEh;QSfACNC3%V#>ZS%3cygf^YtgqRX@^bxl%EEesnw0f1Wcm5EF$iTvzs^w%-jZSB z?lz$tNt;a?*4IzVtBZ0KUb+4nX2<8`+<%0GfOPY}8NfT=uT|Oj;&r>CtAs)j!2U;bFs{R0CqIdKXie8oBR81j;m9pG4}l-@i! z3XXH299bRgvD-!o_OKon8c)A}D&3mRujm}W{}|c)YK!(t%q=aebfzoe8(S$GDq2(r zD^_$b0v-;&BL~GRnoxcH>3c{OCw!kG!sxLjalPRSjlne0+=>)o+#};f_f^VT9d|uH zMPpd~Bb*NUU=5Y&9+$C{#5~;OL8WWR-p* z&hw0hOsBo8)WwPC;{r;ztayhKRaRQbhoB$bL#IuwAq2RVwY-xfMy`Aoi~G+q=jg=A z`p-X*29XtpM2+jB(62b(9U;}H&e5QptmT>PQI|3{HReMntTy~XGw7&o^=6{-w!La8 zk&T%&;rP!g6J4{zRjdeBn7_sUxQcf3-PE5Vwjm(30m0P_s%M0YKGp>U1m~sMMHMAE zvKMD9H}s5*X^pct#@{)KuqzlEu3pr4<-U1YGoKahanHrK>o>jepmhRq(GP6R=zh_AU4N*RoXMi~occ@j$9 z=B*36SA?fMXViQ|pAb5`q}*yIimi7Q6~5(sZhV$CJer%G9s4T6_a(b=IAS$JK}oTq z*0F;N!jt58>b}atG@-ACeSM$0N<@orofJk$5~*g9JFG3(bv*b1*)0V{GcBzdYiX%a z9n{}O=0-;OR^i`2S(xa)e5ElsHkMyV{ay2Hjt@XJp|_@FzpBdbLS9JsgisP>R^s_I z6)7KVA2Bj9Q9Y&M@?aFF2`#cw0zJ?TGEyShGyMMb?B;&eD>SqRx39Tp?L%x`!#5F} z-~R`{K=}TB&w77N_47!_4)TzV>YFzaQBl(sR;f!y>z`}k;o#2!g=VV4>PdB;5*2g| zVMFnstrtT=4-F`gC$ZaKPQKJgv7J6%80Z{ul z=bLRE9XUlsOk7;2#8=06qdGR6F+qW-#&HK?DHtZd6rTm*Rg6BkO_ii$0nF>M1(=yk8smQ8KPZjJZ!Ir#~@G>r7U>|VAPAkd%pdLDG zdC5J~h=-g|BY14==8vn1$TqIzq?0 zKP&yr9;ky)TUZ+sQi%sU{2PpFmBOkU7y^NHnUJ6kj#Q9rK%sJ7$Xr1olc!ov2jgwI zxu!6&0SRIsS7uEO4<~2L;d}$TUIfR3$_mq-?-Q)gBTOkUjqLqDQ^woOp|&)2k{(lgT}Xj+x(i{;EdA@ye#n#e|CZo6*)-S_H=aOOu9l*? z+^BQ==)_{!`b%l4+gUfS(@E3nM{jS}N;Q|&s&Pas4xuz9Ls{CRPO+zs8%>mo{Y+3) znqy+_ow#PA9nL5=yEs)eCCHMIDu%^%k*%AV`H5wvc{fw2#;GPgU0n|^Eh#|@O~_m1 zCpA^7tr|qITzdT9)Vom|*GnNLCelmK?`}KbUfePk8)l4!rIj1-AGfeRrbvF)S|2F{ zuddLxx6q@6)4b1F$=;rA+|U{Rf0bI($Os1u3kw|dyu5CYQz$^E7@XdgV+<0ra&pFD zE5X(s<}}4)M7!@Lkh~rFfwL?d@Z_p$vcj;ifkr6hDqVS zkVqdO?=J0GlszysH=4x26BYtg38O*4wU(Ebw)5Ck!`}Rd1ONK31@vwi_Gvm8_+}lV z+ALn4%iP-y!1Ip5CR$utTVU$%6MOpGl0(yVPw|C(*5(o;D=S4x*tt1rn3VCL4ba443jD9hIXG)1NSb*iIEvYbCTIw=z?UIF>n8(;XhYyJ&g zBYaAhdoDEia_|)St(cR}qRmIkduyNwaS7+@=mV_?F!~Jo@Ec&c0)lJVF_-Gdmn7rk z<6y}uXzv2Z2$x0zZ$!)+4@GV&coj~d(c-rZ^av1N-sB>opz!vE@3DdN8>8GAY7YQqUGZQEF_44{aV5W|Hiu?KiC>I`OVpOrDXg5q5Y8QWkpGeTFc(}YgI_1NU5lXq^5`b z@blB(rWwp1vgw23A!A)fWar+|0ShDBtR;taWlc?y!xc+$Q=OSvtqi)s5_Y^_eYhog z`dYp&WjA{0`y0cP8~P#>tzxaOLp!LDZJj1Se1-9lOn>k!X zO-`Pkn;R&N?l9SbERo(140(HpT~J{S#ASq8UcgpfQG^9O;{2=*5v68k(8Nib)LaoFfUsx#qXL z+*Xmj$~9P_G=sOjnJ#aLgfD>^*e!ACnebIy`|g(5y! zjE}I7xB-3&f3M#ZG?F~Ohv*ywd{fYoC+ru0-j^J0b7OWthbM9jM-v>5aLkFN_k$1< z%xA++Lw=Q12?K(Sjg1h=2Qp>90r?!{fj2idg~F4P>On&Y=C#U>9pRW%|7Jbg=m#`* zdsT|C!QtV|%uLNHTR*@NUN~$doPn~NJDB8NTSUIjJz2jwZZg){X}UY#rjBB3ZH>>Y zF9oijwzm64dSk;P3zi&!$)16j5VX%mU;4+4)8a@7-M@cd#YPfZ#@Xm*2+IV6vyJr$ zmqsNvza2vgF5`o=pPH)4b?y`|(W{fE6j;4jt)H#qOI&Zx=k>`Dm9n-hpry^>Ky%55 zO?)It8n? za*`|3_ti-^k6xK3Gsz>~d0E*aUG$gAp>0S6)M7EKUk2Py$zSK*jl-6`-2|BUK+c?}`D!HZG9 zD;kKq1B{cxw#6Isv$I8@W(oqrLT+*|NqJ0$Xl~uw(6oR&hMkue0&U*4D2?!$buxHA zeh1N!-qBGbL&FNk9Ub*qP6$HRmb$@r@7y63N9vWmj_yQb$ERgW5ZygUuP){S>c?Y^ z-h`+*%n5!BPUPs|JHuq#j5N?Q&82;k5g1Z$nf^8HJZhSB^o2d6)?`>wFtU9Q-$CnQ zUcLV+0g7--b~B#$VyFMT?W0Yz@5}~LZEe(s#^ynsXgD^c!GyL13p7#G5)#z5{2@sp zkcVl9;4@d@Xj@jsQxdDzxh*ATEDl}x1aI-%s+9(`Fgsqxw9s@~yixo<)~<6f*HN}L zqB`AM0szfwbtFGWk6oHR{e#1o$BvaIO1QV))%{jR``3;_@b%?D`Y18W{q{v`%0DCl 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%0Dc^C!l>rPUxWZD$Lv=7%z;-$r^TT4`O{G&BMN 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

1i?{Da5c{25grwGCWtQ)&XRg-0E>f0 zx!E`eBLdYSRxmf8q|$cHKnfDqb`%ZkFqMTcmZAPy19Ba!@KrtA@EbutgzmR6E^ZJS zs>cr=oYjmWr1IR8s>Fl>v~xXZeo+7}Om>+RFg%KZiU>>ill1X1n_X+vo1uh2l!x4j0M~DU-v?1re1wEv(t> zT6Qa+D1ve?#tKJj_WDmiF;30f(=)nMW47zL(aJbo7A2R4Qc}HP91-`F~rk)Td0MLXGyY+L` z6(V>(s5`-X;H<=-^V{)2qnE>DSw=@k2M97gbsi9t1aLm?@XdVxD+uXvCP^7YujCB5 z+kq`^kv=2K;fbSRnVGwRd^qq?FP+C(Z{jE9g?=!jciu13t1-K4LUu$pbNMiqKLMc0{7pE>M@taDV z{2TWUsqmSR*Tl?CjfrX695z-~nR)#WjED>se&d<@yvme@`2++6czBvV(Qf<~Xp=V+ zIFV7%2q7kKpR#JEo)ogiaD}3P)U7Oig_{Hey72I@4YTzkDbcYJCKuq)jC%f@AWMUX zcMpSdiu!eBWmG@&;D55Xj)SFlzwph4RfJsZ^lFEU(zT@zuXU{o-REbg!-P#nX&9X5=+4e<08bRk5%j9Xd2i)hTof*n# zWVDt>JIvJhmshTOVQ8|DZPZ5TCFAzxw35~C^755Zk0SF{aU3L~-5PA(OC>%E^Y4?w zD@o347;}mx=Z!h0klj6J<@Iyo_b8(C8kt!+)6)yba0*UL8ryfvnH%0iejORmGYut# zr?sbF_reEe*DgNoeHhB4Zz2`Wcp%>=zu=z0E11;mc4shlmIN+iA2L?up&aH@yBhti?DOYc3>emiJQ|uLW#I6% zfA824I+6mcXk~>!ABfuGm{n2=AbSIivRrMJ8X7N?w4w#jKCra`Q4%FLP)p zrsiegy~O-45rQR42W&i(U4=em5ZOw^gV~VYVBz4H`tpnkm^&^E7S<{DqxawCE5FCI<3Fa}a$p`cX)5o(XGSbp0=>h@+eaKL^AsU7=@jXH9 zoTNf?NL@`0V=@35x80UVNJ|UZ4~uBcwT7saBrYw=%#WwF;0Plm;<>HNooz69gsvyJ?8bnVOZA9d_(I9K5u=Ou~?@Zl>lGYcjU6{PgetikQtuY)^3* zl;)xgDSJpY zZf=f=UXy#cr)`mY?&Im{S)K^z)dc}dlzG;}_%*>L9uUUX0F^x<%nURZx!C((pZ4$> z9>lH?66!qoP?U{qtX^s}=1HpDz54i*GResY0i+n)MbziWZJuYU%hcX|{W5c44-cFS7_eDRG zM%a72oOD~Zgp>Xrud=F{T(y98`G3nf3|)XOL4jvt&Y1ytsj>GC9$F5r|6d=8*^&v< zc87;g&=NfOgiaOhJq}DB3|2HTG2z+=$P|t68>IyKC=s)`Fx%#~ws+Wn67c{$=EX~v z`W``3j%g_C*oFiP+8!2CB14WijenBHMmvnyiPSHfU!eUBb`jO^I(>Q@ULTXv+TJb# zv|r2@BIb~D&d zc7{fF2h5oed6GArGY?PC$siYNY%xhpG}&97b||FN_XiP^{C>8_rUm@u6g=g$|I%}q z`=X_czAve+CF<;`_ut4^zq$^9;p_ymLo$t04LEx9@HkjAgnwx=c<1EIW0u9YpY_AAVhBew7wn08$vIxopGq46>((1A%{s&9ymHYV19IvB|ca}s-r5AXVAdP^_se$<=) zv^qLZG{np0o{>K)EMyJYA)mX%;Fp0J2*P(U;K)GtWl(H=^JXuw6IO4GzaY?cMb37{ z#>VKxjExBik1%SyRZj9DLr}+8O^2?gpY?!bQ7@5+y#z*s!qv<{j;Q_z6iT}ps;XJ} z%)26kBb_vx%EU!$COSWU)FR24s91`wPynnH7ge&s0cd?TKIe)@w#w63)mU){|RaE7!aST_;h+Z5!=}l?>Ezk zpBUw|M_0fxCF9gvY^5tnYErsEAGKa@FvbVRkwxcBh5dH%or|Kuwu$>Bw^4I*w>leG zPs~3YOo^NP!AQ^dVd0+KsQo3BAoY3V@LHlZgZHCHS3${RN1Gj@qvJMI#2aGp zBP%Pst&aGVV!mZ%Wk9R;Fkm&Ke(@%QKwKh-P_07E3vP@+KY$5WdJQiLt^#`Zr5~?e z{S&=%yTIDY$_X_60SXFlG6eTS`k{2s3NlR~y}DEs)YPMslifKcDl)0<5Ghi3tVUtuTFH^pWBy;6JNr|?co38{Tp@1wd8bWN; z_Di3o*E6F04GL>xk2D(F>FdX4{~Zc#IQ+@k(bv6Xd$@;XVzx)f8#umd7{L^Tjq`nz zlkQ%GdBNPT|6>P@QO9I&5Gn!gH8oX!bN|QY<|U0|3AZJilNYbh9$l=mcKB7)*HBh- z1u92nXNGC+_o9nz4xqE2b`*5Hb-EjBHf* zKSYh{5gW0ORD46L4ltQfp@dZpP*KDUJ7+jYp8e!JGZRSF`qY^Ca%{rb5LbWfh`A{; zfMVZex*52A7q0~OYpFeP@lnpq%NuIhNmPG!TBOQ)#ic1z7M<(Y9}8k6rRS`4WtqzA z|9wZ`JoEz|XA{4L9QY&uXHB50 zIW?q=KMx(uTZ9#Hco<_EwYKzCo(p9xHd}vW>tDaMd$yPL7} z>Oa*b;cNeY!Q%gOg2n%-^mF|GuA1>CdG61$3E7VB1Lwn^ZBX(!T)maO>o?vD*nJC4 z=RAGw)OA3SddAMKpNcCFNRXexR#4S)e+KfYTKu)*i!?Q@D^BeFNRY~y$Wo5a&e{Zdl$;*VLp8h45zJHNkz&s9F$SdZv3ol=;K~3wxZAU{TVD+t9#1~Z6{{6hh-7nyc zs(|S={n*KqBY@N}xR#%vAKon#zPm_CNq6na7=O4)@ZbUMXHGhK@F9R=sfzCdG?78o z1Nb`NbT+oO+{Py~!+z@?7osQ)w&?@SJA{O`8i*J5>^OHWT4z!kq%FGV)r=XRAKM zd432m23i|@w+vU;Yl&zlsJ!Mb^np1|Rm+kfu>xxff-7|7Cn}Z>_`t~efI2o3KbWtf zX@FYH?Wro{Rg@I*CQH0nvFn1gw)okyMR-&Ir4#+JjIecY-Yr}5N(9_)9 zwiu54T{GZv$l#A3x2TU1*j-T+3z?amZomM-8tVW$@n8brPL3G0l+3KG8tpFlhv#2+ z-qutJpF8bMvS*LW+&9UJ$47MISc&pTQk1W%sU06UFlH;pY@#*$uOb8KZQ+M?Mi)-=ok{OsY#1GuwegT;zdv@UPpV>g%Ga!<-mVxVqFHXR+_vIWjNvSC@YtgBw!e_ zYrh}g{-fs*K9UZ!5F2g}OJ}?See?y$eXX!=fcl^|Yn3epf+-3I%SwoPiaoG1lYL<4cDU|Xhg0735v z3_G~<>7Z7~fG~l7Ux~C6=z{!H{^hw0`-yfCj>xZac5xvn4T9&kzoMPrHTjLDwbOZN z@DeHg_qg9#^F(<^zF#CFI>U1Is_=HVgMn48N1 z$7S~F7KxG2(1a7fGg!~j;d6IZ<>{xG58A28GY6un2llFl);T=`JrpLGvtZSkHHmss zUJVKY;LjX&&TZNF$q2m-#ESo)2^{q+%gjDiRK&l1dxDj9Y-ze*{NCaCM;LE$jDthr zwIZyl+Q@Ix7r$(BkoDkhY})9n$Rhw_iaNKlG9gx_ULCe10I`4t5u|rdaEV&!R1lD5 zDzQ$qw;=S-ru2@Fzc+2;G%^`#Y`k`ot}|1aZ?-_4Sx9INXfq*E7dm46KzWeP&pUqf zd{$#qlSSJ?!!uo$?Dihr(g@+MC|6Sn6%TE<2(}I#h4=`6YN4C8rKH1qs##C*Rg9mD za#Euw-9OpnR+~)RtuZZSma>x3Mk*n(R_yd~CMc@8u~B5@7n&GSGO-&qRqMn3^J2by z!S8=H<#k}5;k?`a{iG4o^==FHxnjP$QYU*h9YWj#Hg6QGlMwiqwfLdV=QRHkSM4Q~ zJuhr{1gDt!Ob>{NhyXjM5;N_{t(i-{c9@oy?fCm#S+s;cWWcKl2e@()kH8qNtceM; z0;VastI%c8Vk9Y#^3bmzrQg3o?@E)z<@l?&mGDw^OU8<6e|Ws5Ne3~lK?^T=v$T0U z!*7m}X54?W5!W!{>L$?9)+YU+Br2^(Raxs++;vU2b;uK-8q)@TeBvOQ5r8H`TpS(+ z?$M#^J080h-?CAbFAj_rUo_wNUcZGI_Ah=&&kDQ>VDV8_^c?4h9=;DfElF9igs4!w zJT-{7J|+`eq7xTQZV-ewM}LdDpgFwvu@{Os2);-d;N0IpXf$7IF2Jkw^!n^h1OuRV z5_Ht*^QZ?qYPy~r@x_@&CAG8P8!=!C$iDgdOLYd+crh7`#{!C z?|E{uYu8(nJJL_i_BECDvrI+V`UeJzFL^%wwT+>K?%c|j2E3!BcJ8&KWb}YYJb}DETED8@BL46=A`J^STtUWPp^mH#9U{2C9O<$#60;iIYr! zVe)@rbRx&v))u;buG15}(1JkmTCCg>e+>NMMzeV4v?)K#i zI!0_(L=&+P2lr>u2T(X2+P(bhSAAb!K)$yd?08^d-W8nVYdtmp)rsL^*$4MP=EuR( zrl!diPw12r-@Er(2Kb089OYAbNVbPTpsho$$m0_+y&IO{?uQQ}ULI{9UfEhN=T`}R zmM1?uyS#L4qW(UM54K?!z``I^gC3eAoUZ3Ax`lki7`)#mC3O_L6+V~f%oN$D4UKGh zS=o3;9tUhKP|&HT}TD{AN7!}a!5jcgQ zI!`;!oVRPRZ1m)qgk1T7xTqL2XN~4WDT&40z1CLhf3yHRElqlLw_iy8AFRCvRFrG` zH##O3ilUf^2!fQTloFyNDuRfj(xE8QAtBPJfFJ?}Aq@f|-JMEFNlB+jii9*$=l9@t zzwh_`&-vClYn@s9-RoT&W|(>Ae(w9aesyK9H>lgP_p8ctsv*iefs@DX1X3Lq2oL&r z=s|?kE#>Phc6&}f3aW|73Ry3Aka6n|Gp}#usO3(2R}U7N?5c8I`LqU?2+Ni2X{ZV1 zX&6Uny426b?AL}%8~b|(_Ay{IsDl`Wc~A&wsjI`OFypN`0Al`b$yVg-D(c--)z%ik z9kG#`2{B)xVPVkxK>EN3c_zk=Kr}=QN(PJ2`a98wX$4vB?5T!Qf}ra`M`%CNeDBr# zTiP5iOh~0(Y*IVv9TTI=z_E0DDO0{KsRJU|GEQhzv`b{-84?e%7hrFx} z8vrWBmF>`Cn)@z81&sxwET_G{6b7xhuBgewii*E z;jXS~A8B*Sj;8fb6DxC$m0J+<1*Hu&(Yh~IC7=GQ{G%d^4t{!%RU7tHKBZ;grd19m zJwnQv)NpX+`y%}*iQZE4JPPvl{q^aS1O$anC*{VHlqwK=vw_HE^6)SLZwi^~z-E1_ z48i=#XDKgJ^!ir{nF(`qj8g-Rewa#!o-bq zmKt6&6KbEfL#P_D`_&8@11ipOzlfxB3=V25(@T*b#_9o*he{>THy|Y;;n&0jH90*q zGu1GtFEJ&B2*b6bLYqY7Td<)oQj*V{1JxW8t#j(MKF+7wbGV)4>mogTOq4i~&q>6& zvb<(I60}BxQ?A+U;*mM~3no)ICvA^Wj;d8hyvYfF=1X<>&Wv@+Z?{XAe+=;mbnAb9 zx3zZtFv+%0?26c2cNB;Ca>DrXQm4Z5%yNhPZ#NGW%akK0Si*ApfgadyeYPT7bOh#S z{wTC}3g~dhVa>uFEc&xx1K_RDj^b9$P$X<(K0ZF!N@=|BWkcn#qKtQO;ZLQCuNM;zVl+*-;-c1J+s3%(rBUz~ zwaEnq1+A$zq)J1^Um|@)QNO(~XQ-}$mb@WDv7x9cD?z%3!C?cv;lyTPg}h(J@(z*F zhdG2MCMbQ6DSgpv9lWd%4@E7EBf70zq_$>sEuKqvdrf`Q0ih z+mSm-$F`9Wxp&=9Tro}>?0@^esReUQUeF0Ig}9x0{pmR%I6|8LMgl-ws!nrlR|F=( zitlfLh?<9&7bCbp4JO!2x5j+ ziSu>!0yhyhWdplIvFC^M>02rPp9B}V@Up+29S z*0A8Yj89BoQj>>&xcd+FUNi*3I9cn(KG0nzoU2gwOl>dFmX+kSS=SQdwYR!{W_ zc$VdB91r^U&UuQt-iQ#BX$TLl~y5@bzY&?i*TgKTC_%F^Y6l|(h_bk?b5eD_i zTR%Tb`28v>uD~2b%%B=>HN^p6YjpE9(u|1+)Y4cSWrsL!gqn_ZeZ)K~h#HrX+H!)O zlbzlDjKy{2#=R5eHPA5KoW1sx-tkuJq$`hiaGl50bO!jsS;SxWIlH<7?pOvD2ld1KFMcqz>%Sit z7|l+?W{fbW$oTj%h=S8cRqoYiu{Hby}nRF~3o8!c)pLdili!}y`V&id4#Jo4&|mr)QrU28# z#Kb_WMaw0%EQ|j}HeHPdHz7`x`S^R-7dQ1N3VaU@$P6tm2T1|5Dof+_;>a4DAVp!U} z+>EK#zWQaEbMK=)Y3>>s8EJeF^YLnH(x4)*rC3+0d^95XBNgppCq?tOZ%UzF*kg9& zx!3YF-`2oU!|nCqCjG=g{|q&YZ}!j2rzk0fx^<)P0El2~G0HEslT8c>N|%WXh+Q;z z#TLemrjg76~)2G_Hy53$Ig?Eh1%)T~v>!`y_^OF#YF`Vvla z0Hy>)hpl-xc@ozs6E{#@c3C_iTd-#X{906J<~&E?g!k(rtDbHbaT`MdUZTWEOe_?if(sT=wfLtd+G zdGr^#6K2DDryC9@UW##5P8|D{n)@<^z=jBp)^^nzA{{X46R|^kdU_(^cRIjgaCc|i1<}z}U*2I9)nvz#gBCi)FZTA?_(?F; zJ)^Te%8|)sKB+PIWwwTO$BqhjsByd@0U4+0w(<@V$0%W>f4X$VpP$gg!sVKuZ8o7J zZD3tkt$9l^a!c1UQPP5arHwN-2R}^(Df4XT=Hl`WwsLg*j(%!F7q;8QImPcvZN$-? z6-1uI2qEXuKllSFi+-d`F^o3@P8`$IuTxeU6=E#khwjH(2)rKvo8x2%Qhii9znrH_htNK3Jk0~@{eo0~o^eDUIigfH(c>>1M1Wk|L?q$>y< zI1tWOMi3XhG|gFr<}lrRrcJ{`@T_>wh}S)O_M=FP9fh!D(O5>0Ysm+E;~ zR#D-;6)vx@4KJIrtrCkoD{3i+u%x0elDA5d8&@CXFZNL%v|!lBY;ev|?&l@OhTgsr zwK2gv`4`3M1PLMvG(QRS2DQHFD$;5dk;XCg0Lz}NY#TcE(+?%Wy>@iVN?b9Nipv_^ zM@L)4tx6hulGM-2pB|irByVwwt*K8lV08fSAGSBM_F{Ge+(5)u#mU0X&b`lt?5LoZ zQPkmq(HRNY3?CYI#$t@xV7|Ss9(L)l&a27^szoqUail&(upu5=lAWPxmP0XV4t93H zh`&L9K-`7cIM0*GEk<%A(rU@egAbUgRZCr;bim=b4TV zQ@S{4H%{UVR{A6xyo!{4IXr5x%R#TEwl)z_kn|#$Sq#}ijuy6J_pOBAaG?lPyP}2~ zYPS^BKMGz@Tv(h-X+wp>MvKECErt_9KVowp#aL*}`2{2J1rpfO(#w*P`s%#wu(;^J za!94R($&K!#G#OSC9ZK4w-$_{!x@GGm$qoUiPTaG2`+8T84J30OEj9P#pyXoFe)fM zJw{@jSC^SF^&$!iJXRfHzgf@FF;W{uB7Gj5`W+d`I+%_Y6@nkj<;5A)iS%}}?W5NH zL3bcmFn;Od!^)|zq49Eh#AEa3&2OfEc__0%*+EZ#PWfDTU427?eDZTACs2J}UeP#e zfQmKvn*lb4S6aP>a$;T!?;I@!K2zw+M>q!IpD-M2_(}W<A~Q6XO>!N+GaUOO5V&mzPE|ihDJ&Ej40`wOFaq2OvGTxfF@m0rV(S4kq9r)sT`qp#ub;gg*n{ipVrxiUo6{NA3^1;z{Am;4^H!TusG7F*AokwNZ z$C*?+;B-*tm(6~ZP?<+z1p7mnr4x?n9 zob+VE3%X_f(`=8;82?f+$_5W8GxMblzmSjO5B*)Qb_*AV#as`$xRD-~!;XejaFgh) zJzRP67S6W4G2v&@hyfLNM^d(ni=4w9IRb)$if8I!G!VIHm(_5bpTD-<>X*ge zaA@00_uTe8EdRjXKh$}WHHsm8yF%7bY*uKt5j14xIUNsr!E#=_aA6}o#zEmgyZ_)J zP%)gxI|&$%3(*m*H;?Xw8Je0rF!dXNDr8K~(gd1)iO!1AZVyUZV`Ja`JFRd0{MdCX zvtSm)qRB9@2UvqK1Mtf%W1J^VI6xd;{SJ1!&t+N#fhi)JY!T9noF(?F5jR;iR~uVf z=hkQ#2Un3uYXbs=GVwWBPJDEd_up)RZDzF}IrzBum2m<(GU?NGY7X3UHs9B9Muff) zr-!t5$4OT4$e$O)!VFPQpkHkqnu2keh`2s*6X%f+a$2x>(fV>@N5YlweDvJ~TgEAX z9w_9n9X2*K9lh|m_JwRIFn9f&sRr!&L}1Qiii2-a{=mEpUK`p_X+_0swQ?GI5s(jn z*rQc`q4RL7G>L~y;8<_O)(YI`oSbpN!FN!&0tMF`q8vzV)w2N90#z%W#m@wsu-LB9 zj&+#lNaZ?ZZL`LT0W z^GtNY-^wm-A)3;&rVIN~+2rfXhG)n0<*qG&uqs!Yu1-Pbko@{CmG^Bz3FxiNd{ebk z_eUa7;c3p@!-1q5=Q zfq}vZbRM5Yi5D%O@&o+O68?w8CPo}c27+W z3>nI8R6OZ4JJSAKfWfCyn>RVbRH3UCCVgrCGX+N=`OkSvB0Z9xcD#d4V%PBLYvz-M z%|^TUm0Xhfl%-TZ1wCFA`t|iBpkPQTV4zX9$3Bd}qLFsp=w{F|*~FyymYa`&tdbqU zUSYZv3tP$uKVM$~#5fQoZ*=reSTNB^U^~NHWFUn^@B#4lsVT5_eP8^2Jv^px4go~R z;q~R-Nz~ypXh<;v3_BF;G`tqtqdA{VIj@R~Ya^|-F)q*~1=!LIG&XouY>+zIqU-nhK7mrg!dKP0wB+USp^Kzc0JhT#o9hk6%i@lsG%XXa!%_S`UFSFS;R2i z+_fX>L96m0iII1igO!ElL7Ub@gVOvo-3goh=#kE;4bcjPqDOLhkka`0Z18P(B!alU zJw8by<=P6IB(06aV#8Ftl$2YTu4-A=y){Xr<}kB1mLl(o6W5ht|J1qq8XG(9?Y5>G z@&>c3I&LQ9EnLL8diE7BeQL(86Z6zKT?KUd3i~t*I|jCsg6bYvm#V$rjQ^@#>_SUR z*V+DYsZh&gvhdg4rsqwoi09^?4F6P{bRV4-$@2O4bz!SWW65Z53tN>?WWka^@lD!t2^r=-u;$ehR7ZR!McQ!uZhK0!!0}1{9-X825!owH#5ht$y>=f9ooe zt?2__d`>(gYvt9?FBvqO(~2JyB0gNSe&yDY@5E0@`8mXomnwhLCl)c>pOdc>e1HtU z-Lh@|v?T>?xhpShkab%h*`l_**N*OFdJ_@j(Mxe~zAk+g(U2K5Ill}u9E4g}Oy3wI{TA3+4_Ev;OjnpsJZ0hQ)R4(zf9hwE zWHL`p$H=-iTY$k-*Luw2i)85SCX!y_b6jd}+wo+Wp7{N>>u-tPUT@&={yv%@J?ghM zmsXMdpFidKz#;7?&FXP|`yKLz5TGFY!qzy)F}=C0by)bN%A3%=r0P;RyL1|b&A!`^ z>M|uWm-Z!^N8&b7!spYW{5djasCxcJ(l>dRG>cVf8bNVc~9j}%m$_ZBqVch$IIITiI?E|_MbR;bvYn(>l{csj{IfLO6X2?6}rd9E?H7Au71;JP$5-wMlhW+alr@*=$u%18!Y@3Z zeQc;noa?@F2+R6OWkw#&gSev5JI+o;(=O(ot!=Ca=97=VK#Z4)o`~2=O2eod_`V>pmgazBl-79}|E71vVI6Ce5HZ?$2NBKdE z-_J5;YTwPYl7~`H`!>HB-9{pf8dyF}3J}tZ&eY!oMkt3Q0LzpomSVM()3*wU)j`TzZ=vd9><81bnQOpG1-F~6&OF# zM8waX&DBisu5@{h8DSMV!(t`Df3*M|)VO8>Zae4K1E1fE<-uKqo z$Go|%p7>3Ach-8+p_l2YN0W0Ny|vq8D@>d)!^fcfjOSlXGB!k+YHQnjh?!S7oEHI} zzBFl>yApDsJvV0Es%Jk<Qb4Njf@maT=caYN=_%_cz@jbesgQWIGaXUIKGz zGBTPqxHr+ei>bj3AQhogwN9bZNq2ot%+*7wa!Cd0R_?3!-0j_RvDEB^$HX2sb>o2R zTI!_eYe)I3WK$?YdIXEh%wFumOIBYd{%m#xN+mdhZXe%34;eGml($%K3B(PaVty!S z(bV@wJFe>Bbm7r;ti@$}U%bW(_Medcvf=A>t>tLdA^ykw(j!GjPrD@;9=uB|AmbfG z-=KeTpUe8EIF7IXP6br1m!T7NI$_~-$^=hN(Y=$P2YGn+Rp?g^DUHj$@Zj2Wgm}rj z(S9|WmrCt-HlGiqTD|3eywBBdz7KwC;IX<>d>k8JsKoY1%%$ZkSAA^z%HM=qu5W7S zCaXs)pP8ICU z8(G%~sF2B_c&QN>-)P{W)=mR{pE&Tr;;O_7_?Q9c)>6 zbP^lXKz5I0)W#QB&jL^Gto&z7YGT0Zr@k9Hs&_H{(@ylyZ`%;RZL2Fdxbm9#&9#hS zX#y+H`17xUQ!5DZKmVoeeME%ZQq0Hddm7`)`fY{X5+6IaWBL4j=o#gtDsQ->AtkE) zyP<(cBaY`$){T75e?cDno?eiY{pZ~I(XXuly*`vF2pJ}+7uU z;K+R}%&v|b-0EM(d`kQGHE4tXAGo!ugC{ClmGEZH_WxHUUlXwAPvc?+?O>gj35B?8 zuWrkk2Mc@N0lrkbg#d5Dt#v;{E!N zbj6vd4Rw?im6X7y7*qQTT_j>zZ!6t_EC_&Hn(*<5U^9@J5n8VWg0rHu^n}boK0QT9 z6{4e3l9Md~-c?psqFqI+9Zl5@v*NnxwLioHgqa7CgsX1qn@y~4u~tpY7R2Qi;Q zIgZcE-P2PnZt0ou!X2PMsEd?4mGzIHU+Q}~OV|tl2bs&O+nKvaAV*R4AXAYUf_57c zY2dtum;jdtZ*N0A5n8Oti1{$5BYME_%mQe%r;^IJ@C=kubLT=IU*C=iIy)is`izY0 z1sQp-hKP#4GE~J{Sw)nMD~~ZpV3&SZ9FGWWdTufGW3VN&4l2WOedIwX$;s`dq45j| zIL7Jb>gs6&VTj+$mxpK@>+69DLB`_g?p}e;44^Ato59oXyM-)g#{!oZ`iC0hkT^R% zEC9aAa7+9HJ& zhxQwA2reR;MulueLNGZwaHrkHdXe2|AL3m-J(*|`lSw$W-@WT5#zCF=JpioHYy+^&zDaz|7 zx|IHqkPrVy+p^y*`T2h?d$vTYl16t6S0ylxvb@FC&ZTJx-KPiZO5U}70#Y!V(SL9P zkw?GYNS=|)wVVSXM5J%Pq9LHs+SHT|X%h>*_0NP7g5*|K{7vMBU=gGvAr$0b?o@*U zp>{~)$45md8puwuFmpt=flSgnGX;w#cRf?Of| zK(+rv_z`ke1eN(^INk#mI&L3?v?tKs_EZ43Xobv^O%|c>2Vy`#qPSdy`x{vE+*dFl zP;g`J1hGn;?bDG>opb~N=5G*h;y4p7Dvr%_s zS!84xs~Nqm>&^f0e#8Bg`;z4mcP$`m$gW>E4g7{`mq3v5yMKIbwy7*FMwqailHws9 zMKJ1ILR$V(5!rvl2Ap8fUcnmUMg#3ZzL{caguHknN4&%YvUf`3?RNc_6 zy^gzqEObJF`e%g9iIf&hH;%>^xrzSA&`?-P`l?x1_hf5o=FWiEo`=JV%sP9#y!#&zFQID-W+Ttb$oVMx>9D`kOe?-?i_ZYQ6%metU}k@^*Etk)ubMm{XtqJ7i%-z#{hD)BTy^uPzxlW0;Xv}hkb6tb~-w`?c49f5n(4`Pr>a9*gVjb z*HuvPCcrwZ?}lC=LCV)ShzNHv3W6T^R^J+m`wEj+K4U1XGNQ9enFFJtsU(7}1+Y8y zbB^QG7@R^@N&Fqps+2gz3{_eam?$(8^hmUlV)o_bWtA$;QG3Q-BBeT&x3o3c|7FuY zqUs?Y)tCQ|%vrG6ksokVn3FwxoW<^LblSz9j=tPT_w6b&&CaZsr$ zP`Goh&pgK(0eXT(%_!!nT0`o8 zfL}kvociw}oUUSQ4EvE$OfW^}-PS*pnZCl#^%N9H^ihWvZ|gp>sEeEFA&2kng@l$k zSTFQON3*lEdC1FgBQ9dPMH|kVk-&LeJA*}M@y#_5nElYzlH$UF9QrCeVCR6k9>(;Chb`oihOQ zThfm3Y203ZdZgqZRuV^mBti}O2|Y8R77wWb+g&7mo```Fr$8*R*XUa!eS2b^JrzLR zF(?-+sPBYHqw>OL_d4!W4mdAOO*PGf!w@}yJDQr5^AJ-P^+zdcitrw_bZQd6#&{OgVRh-CdkvaztX11A8nsNT6GueFo$!f3fHEgIj91ZmI3sziZ9d_NyE{D}WOQnZz);($%e!Xgk8q zJ^Ae!ohK884a&lphP>~tNEC%N0W@d4T=0hvqR%ae1l!kyP{ha`7rFrWwr|YCpH1|% zZxvEHp~S@q2KWU~{e6qoUTr%eRr_1Q&(cCn{<%9%zttNR<%hMc{;L7sVB$r{(?AM_dcOhJa$p4Z)2=SQv5jS{(9!*sjS}RH`^8YRQ#)kV~=a-p~Gr z>>5fWRWpecot>ZhS<{~40KwqZuRjzCd-=*^%$>zlawqxc(W81PeRyA?Wy+y9S&7@! zX2~)~IZvxT5^9!D=X!C|UYV+j3`$sFQsDJb{#xEx$%AT1`Jhts83jXBKa zIX3JYwNkRS`wXNyb4yDk{T>E?Bj}ZC+U0+g4`5=d(R5A!sIU7M49^yL*7Va8LK3tZ z5OYn!t=k5DIw4ZHHiv!I*~!TasU0B7AQiE4P68tU+OfPW(NBC423JUE1Tb#mBes*6 zMjON(&EOD?tI>#7(Zi>ddagq+B$QMC^3<_ode}cv;T~gSE3c{wJXKR}W+|`;S!=S~ z8Hg{*s*hxD+%PK1`|z=V^M@1Pk+4s_YQ24NOIb1x2prno^9~XQk3kYLECG5kmcptWNeDR`!NNBaq6mKsqC z#zU}yR`~r`+2|B_jp74*yI%oh5D$#l3u72ZC?E%Bo*8E6g7#JXKHT$lF56yd#WwEU!b@W}<0qW0 z=Du}lPF;;6+fQs)cgA3-)J=BU`Su@mwVh$JdRG^Jys(Lg(N0%Z=nU{Ktg^gzUihh8ziXi6{pm&ECvZZn_o>L=Qbp}Q_ILmYj<;t6xm9AFLrfx z8y&fAqng5R4sC(Xg`cFq6_M@LeiVkk0M+Kd7G#c%jE;1x61 zuOvT0uKo7~Z2tR9GT2cr@h_w`%ZA#eYxUoH1y5MMcymjT#J{yKiL`pv^%ErKf76D5 zKXtuKJ#vhbjn76cPL)LSpI<#26p^8JR-(d6TDI`Vz zTX~RdNniI2&_9S`f7d;pIPRXj6Q79KHJY&Q!F++&a>DHPKR|oy;mn7S;O8F|v4|<2 zD+5j#y{;?o1^ASWuYi*d-?QR6!iyHjcJ$ghRnecT|Ci>wTJskt1NhCudw|J@^rXqJ z20%#4zw~b}$;-N)p92@*EHjxGZmJTlPMgcl#Fwm+|D%pXd^-Pizwl?2ncH2Rp0ApX zdoJ$LF@i%~@BGazf3SH{A9hje8gXP1i2VsFe$E|sMaJt?`mm1u2{=A~{AGnXJayn+=9j_(xZ=1L zSCbAMm+O>85f{ZXZ2u8oA+ECIiSSQSR~41yVw~!zb`?6X;^;KZ};yX-ue;f)EH0n8OpW9XR+nK$#mEFFXH2evi{#nv%-gDe{eE~yT+D-ku6H@cx=J-%Kah(EBNU}@=c1?GrD*`)Q zraU5!xJX;hq+A|_wya(rfyf*9HIm}TfI{2l!vvRfghFAvf@S%H8eYt0in66t;15=9 zu$lPwy5b*ovoAMv>^y>g?^T8AJqlA@;sr=unncjcnD7@t5hi-9az1Z9`qYlVCY&B$ zwN~=o#-4w=vrQ~1p=Pim$_S@_iAt?6&U0Z0%6~9rS|E4Bg2Y&s(9m% zxm}w4D7eIl@9XSYDf_iiH|Zl^($LF?>N8}nC#Ra{q;|9_*|rk<*}{uT?~lCU+uu-u zQGd#AsH)CH{pJX<%Evg-OJBENyZO5IQmS@(=Ox2GjP*$9`Z?_tjtsF(tfGRwx~KE(avZS&m?8L z;s;%w>;asuf@(&dJHSai8lO-6-kQc|j=zPEq5f?q>mJ0!AN$H`1+{UBcfY;Dgo%FF z!IOK4SwAHLku;L9&ygLQPPtH#sl$IgG$qYa6K|>0;J~E3pNIa1E5x@y`qEI9Wl!qo z7rGZeUq#ey-nHcZvx7C%|KlW5ciYo94AOK#%1kbU>fM5ueZqqT8t)ScC4qmjrRF-YqC;Qed*?u{~ZlmLuz3?!f|24btO3tyAP4kuQ!FQ znMe!$Owu7%-rAjW7ahh+@v`}*pToD8=b2}zkh)k@Vk=v(U;7#KL^^7Ipw6VfO2yA` zrf@p14c;IkxI%F39ONoao_uc7ELu9Fx~S>)62utdKl&B+6ECjN7oIE2PigzO(u zCnCpMm-jS!8T^EO6PiCLdVnJ9{EEvdZ;u}YdO}P*NWqYqYNEZO7eOd1nlYsHGD-Ys zNr^Ik2o)eQP^idlz&-)*Y>mAE<=}YFW%@$EEwS*v__U&Pu$gb|G%R1!brCmg^yX!+ zy)p_O$s89vONv83pY2+qFjtpQ+Y|f57_F=6AtsLXRFC8(94kY$hAW(eVQK)(3~J%6 zI0ntwQ^8_v2`4hbTS7mf_l*Y@R38P!szSgQUOv7&Xw^X~Qj^;t{Q?USuQyJJ78NsU z`|1xfFaU`EhS_i4;))n2hWK##R2`IZ2<8%n4e)ftk_2D-y{%5dOS9jB6>X#k{YIo6 zS;!pU=R%~?01gh_(t2Qr&2+PRQIYFXI%?t_2PzokNC9D*9MvW#FL=_O5`C3bT$sMd zK^h=g9$rP7rD$w$=1o)00D)`y`1G2G99x*k>$^;`Mhwt*qsc|-Wkm%mzoe{C!y0E1xYFhPs5i9W#m2a8!N%xJ9pj6MYKaa8j@Y>pNAM-ot)-MgqP?!=OE|{FzfUFUgEagxxCm3ZVE%%ltvtc zK$FG4EfhrlFB;mOzP>NMd~q={AWH^-sR8r3J@FGkje|kDXlKym)i%hiC3W8U;x@dl zw1MSh^)E3ox7|BwhNc=(nfw0Fn_nPbA3Mov7ZbssIl0)X_NU81_Kc-at!1LC8%6+c zvLk~-FCdV^07pR6_zJH&hB+lcl>Gx5Tq<(QlSUqBF#&^bKz@#-Xz(c7YJYtsKj|Ck zL8z(hl~wcV=aG--*WoSrF*ZW7-Bwem8M@ruwsY?Q34W8Z^3KRcl{A@iQT0}ngPEL- zeI`123mgBI>s{994~_4g&r#;2{k+@26J0hOL;3{{lvP4Ae%eYq$T}^_(0Q{UBoI&= zmLd%qU~I62j3@%ZoSmu381VkNSI_u`o!#0&j>fNFWdjKo!=4I!;pD|eoX1VsCXKm^ zm;53!LW17EH!%`%pbgG42tcJJBncXiEg?S5o9 zU^oL7R1(#@KQ;`4NJJi0MV)i&AmBceoH2nk>p& zXOV^DB|UI%6}RL&Y?Bg@gZYfyKZC-%6G=zEd)`QnbmS<+HO3L1@l2CV_Nt(KA<6w* zS?S?v{?LUxI2Av^s@hF1EG~u{lm=PQDtXqxhZ?33{flv;q1YfWyNS?vF93bmNydrF zqB;ElruHgg1Ht`3Bz`b36eu3ky=e)$>XL{^lQw2S-sU0)zy$z>v0wuVrqwF{3y%C5 zY&nTh_CxX};sg*S%;dRYd zo*<$}#e6WVy84<|hsrYys7fDv?@Jtjpz?gqcOx_cAcE+FxXq0{ycDFf0x-FSXj6*2 z1sr1hZ{%A_^@%q91N^(36NyrA&*K5YECYK7B1>=M3?xP@?Q_x3{&i7Q)N%7*AJEv9 zxN%Vl2_ZyG+-w3Erdd1&Ul{)Bt1!9fdSDLuS;*u`S=2Qvf$0-F7%X?Gm{5hOxB(aa z0s=bnY?h0QFIrII^|Jux4voKjNwv*%FRr2K|L&o%<=g90+}C#>+E=@4tR8T(^r?uO z?(U-@G@kPQCv=wVg03AEoP<=wIKTgc?Z*`nNgSGNxUKr+3#RkTMMXao=ovf@cZ**T zfSWI0l?JypzT{(eg`kYRw$gAa>r6zOh{Do}^OcPjV)-kuGs0+uTEZk=BxzgOmoHaE zMF9%VV&lffB?lge@VN^Hd`zB?Jf8+&(UPgC<(8YQ3I`^>J!P+<#WnBaK_n&>J}O z8I2q5+!MunC%R2<#ZnZ zqklwKf_KW=sqLwtXHbYS!#j;g)W+VN3hq7!b0ZT<4*Fu~E@)3MbTN%XK<5veo*HIYVU5(P;h71w0B{}|C)%8CbEVIvlaAYp*+%eMX zNl)Zyg&r)teW-V#4N?Vw`a+;W2^T1C9O6C_-hbf0!q*#;E_A}V5{(t* z-0u7K;Lo7YfY<`&!4moeWC}u20Bg-F_iFDpR)eP}3x0Pe#L0?EQLSV&@G z@V!Hngt~yR7lWuC=6T`bQu+Bat`E%|SeSa~egH8;LX?wy7cIpf*}trU0-FaTBjde$ z_Ye;)D}yRD6IL@~kO#gkIOZaXo{>SW~!nYd{Gw2H@f7FXB zPi(W9Er7QJ1J|Z}I~R%-Kip)+MlHc~1CGlJMiz^&tmh)y^*57juj+o4kOO_+`t=^} z*n>l^bT_zpcr3CYZRJasir}GRo^+hhQfaF)pCFs9m#LMz1zNGX-*f!obv)bp1g@Z71E~_Nh-cfeu#lGAWgtcp8U*7+)|RuH_LVEvuzl z-Wp=Cd9nf!r!MbYkk?fz0JifA%1x{1VXhe@6cJckaQVE>wOn-RsYM5`x#N=UydNEGF5Trwfp5 zzl3EbW<~^`qwbf?W1|^^kpQ=7f8n$&bK!A@zaogPEGa1|zv#WBI=}JF<0}Svo3&*_Ez9x^U~i;OdwXG0!qBi}HtS~yP zobOgtta;2o?~vV5PBuWUvVq#T0f%m`TIB}ekLIumoG9gGCMk3(XlCA z)?`L+`}J2HBZsQ_|MT}rL6xD&e|`}tb#`Q;SbL5yw%${Op#1N{;1$!=5T`m(YGT9jw-Rr{Gmmag=;W&A@LHcd;6Ym;-*H6?@-_<FrtjhthuPKP(S>aq&S$*`tiIbTylan1ZMMR|dL0 z%U3QRUZhKqxC#g4#x>i&)p3v=m%V6DYMm98nhEN9c!i{=w_QChdFKIbd6lopc)EwS ziF3GpRub_|tUQt(hzpd}(JpH7i+Qq|ngHVtr-cfz+-*6NPEM{wz${`Wy!77s+T(h z&Mk8a+_Sn~ms;TPb^qHdKZj_JS;j_0TqZOI=aZn}X>FggN2WN3a`H8PYk1i75Ac8e zUtiR{V+0@e%#Y7bAzxSUccbqzkyDOGm+#6YsMOd=cDyaAN5ihvHZZg|0g*H< zOIFK)IRe|wq&>dS8V4+h*GK zs^0Ig_|hDAK;^{HbAxAVs=YHOhh1#`ps#p))m-#{=`CQ7dbjGQzQG=ZN08AW;s@1N zhi2wQeyJak=Q&awKe$mxjD3Gev6{~c zt4h=@ADIwTL4CJ@tg8A>IZppZ!AdRv#@+qvd*|Plxlm}iW)~_2w-;PjM%o*HTC46! zZ+_Ej!`9+v^2piR{!hFUpV27tjS{4;Gt=EiY;3jF)wKa@xq84iL8a7|j-Y=v@ga$H~F& zYh*RKk;QFi1_~1bQOhM&HTa);@NVEMOI)M42!bru7#bG4?zKP;3da%lBbQMI^?Ysn>9E&wc?Qm6~%_r#-VbhOhHx z$NJ?*^TWl(#cKLF8K|buQ8^wxeR}SVWTU*{K(sxyur++>~cK$U!Zm_bW4Jvi{5&dY_E@*{R}djv{cwG%qVH zIG8@ZS_j_cWX<_h-MozNuGbkZ`2!?mNo%ED;zuJOcDmf~0@4FI*K9kwH|6Y-gEftF zk!r%x63pBZG**z~YVS z0~0bTx{s#4)gJ|HW8b+JN}GYA-O96Fi!&&V45)Vl);FKTP5Ft1BwHpXBYRrKK$&S;tavg3o91*W1dz`O&Pn=xFXq zHivf-`c71x9nWW@&v#VXB1!@Kxvciyc1@=agw5@#hp<#~7S3N)5uz!AVl|I3MqCqcrG{(oMc`&{<{k8qg15reZV}GKq4Fiol1hB}?bfYv zo$}K&(7NW26K`!aGb+qW_I0Rf=c1{Vl@eRQ;HKe&uAN+x!tH~5yQg!G$nTh49jmh3 zsUBdR=}~qja5Ui(of!xy{V#q|=Qr}i8#xyl_S5FEaOe)}v8yb_*59`p2!3{c@W9HY z>asMN-9ne#_U%xd+dKB`i*>^%Oil#4nP!Sc(T8x%Hf7y7&#?V^jQ9rAL;h9$Z~Twd zYCQ@`pqx4Vr1KHgotH!@IbeDI_LwBC?`tB0^pxT)&=cUjGA(!-%w6hw5H()sE7kg5zm$p17X=AS4d~#Xjo}6c( z%}CXf7uf1{!}3ooH^Nc7WA3pP%>APrDtbxH#6b6lWlEISF`Zk~o5wd$_z_I)>&`o% z6C2BpGfUyUP~VUy;;65G`E|$QOy1&dA87cB-Z1UjxdkTZUA?_*GBJw4&&v|oFV{xJ zWuLVAy+w`T865=@O4MMS=K@4KI)=sRlEwLh+?f9P9( ztrYbdo9H-~VbKYrPZf99t!$G@-R~@?gmWkP+s4zF?T?A=Kx$-h$R*dKB?n4`mOnWY zu)6h?%a=2Lz4vvX-2V`hdzW<1b!}vFSQiK|bC_-ry)%Z!Q|`5}NI-LRfnr|*w1cY7_3zgxaVq)dgl3B?{jFt_?O>m>l1OG7H0 zkr5H}?-(xSF8>~K`M=0|3#h8rHEeWQC<;hOH_{>9Qc5jSkWMKH>68{xN01Rk92K+h#zTfuW&l06 zM}UKTGMAZEYDq~wyQe&p_VF;}mKshnmH(9_BB-KDq5|I>-qCNm{WZ{H)w^Z&a_!}R z?~pF{_Q{!LVHcT~{!I7@bPmo?SirV<(AmuZj3cnmtlc`@SzKG$pE}KQ52%-zyZ zA+98GSav+;dRv7Y-c7>N`XDkc?qGoO;uD#2%ITX0{DsbH6(lZR?g+X%JGvwJyft1=-^(>y4lsvFPins- z#E+sq@~Vj-yefe)@p(U=QX@9=7)@T@X6m$4khx^$g2HPK{@-+A(xXIg;MLajhTSM= zg5qF#CVgos{)4_^B2Ojxr4laJdj7cDS+Ur0;l2gir24Ivqg!WUe4BiHqRTH2Y3UB0 z77O!Q;dgKX)c?3^e>1i4Nds7hbhUT+ER32%;s}nJxfy@KTPVq5lF6OdHhn8TyUQ|{ z+DnjU=!dN>#I^+j}qa1kuRR-qi%AlH@o3s?`xx=}FwHEmu$3zwdrZ zN${mrROH9KKk%b?_Nane&bJBe}imz8dmtNE=aB{e@T@Y_lDWM$_a{y z*46Fj(6K2=+-E;6vJevH@@EM4SROeuSIinIb6wiocCCIaxFKZ{5%$LS^tSIrp?B`& zuXDjK{GEn97U$H3!*5!;ZW(8c4mBLq47YaI%mkXXdG?O@KJO;Q)UgR1`pwM7HVMa` zhASt~7hQ9$Zk)CkAd_I|3H$`-FR+?wr99zhuvI#D;NK2t35Z?!H?`oOudtASMp%di zHkvtAL_4dL^!9K&N%@eqIwnbH_e+vux(0U4?~kl23Cb1pTaNd>Z=EGiXgF9YH1ut( z2_vnylCgssH|>1zt57*!0Qq}d5dE-n(NIBAakJKB^S5W6X8ne1U@n*&^YS)Z@RXJE z2R5GXQeIR*$=ik5H!(tVm{Ih@ef7&wmtF?xf49}8D{?$80%N5O9&HpgUo(-3VhcqI z=glSu>o08!RNL;mb%u>An&3t}3qGSGFc5vvuB{F|)8P zgS28w(?C$z{P(wS7=_Ko4wZWAH<&oLvuPb5wousPzqg05phV#_FZl4T_O5T^!kEI| z&=O)Ev#_refx+@&IDjihEs9pzG)#o=p3jfBX=wwLA|49XdS0|)B1@}XJEvewH|JVO zr=7@S`6n~Z;B(DJVPT};J{&iOR-ySLojD}gH)1k7BQM`^^ClX90aeh0cTvUa>gTg8 z`4y@ey*ze%>l>3%w8=Xwox%{PrHDjAxE6gn97x-XesU=M$oLDZpl;Yg?BBgUF8YX8 zn@_kdKZYA_64Um&uE*6<{j=1_NVb8{IvKBaY*2o(r~X_R-TTg}HHqW7YFM=2;bGRh)0e8KJ46|Kdz3~GsL`}jyv1`jvHMfCIaUdtVlnLwP&9y~GiEl( zYtp-BYXv@TqbF^MMFwuS|Exr-Mr}Wv9cy|*Z060!Qad6m%5)rh;+@=V&1QX~|VKQh7P87Kp`?9TqGnW8VC2uyd|9de> zgfw#}z*1OJN~dMIRURPxwkr+e<56rn(!pBMACL^JrJcvIS|Lq^9e*mHbtQWyll9PE zK5RX2AuZq+6E^P}+*xGbx*%#OfS{@FiCWLNQES^AM;JYd=e6C*o|Dy^=emNRoM2;N zp`!_Z%;Y|S3e5uCKvz>U`-?y=Ahr6!ZfJb<^Rcg?-51$7eZ!-9NLVv5E+!UaLum7@ z03tyb5k+7@iZ#lFKL3*==Gym<%?bByN}b<#9U*f4Z)Mtm+4kfa$;b89qi?j^?yGV2 z$1$B7H{I?`O5|xw`CeQpE~r9n?SW7fi*ET|@E?pl`%W$!fA&YSUI=h2z}EazI=d*g z_JuFJexIKxCku_vMeF3HxXp&4a2k-Ve)3Mniv%t3pPX!fr~qH%x5&~i81Am!sM57N z%QPssJ@mi9BYv2`wr?K@1X};Uc&2?E#-Nvz_@NiS7^h*_{X*#s>X4tb+t5nYw{-)q z40s3_{Q3YQt5>gH*T$p!V7qzyI`A=G;}EBEvQ~ZgRRHBmdJBxnfsPL$ZPWeGLxfH+1TLFT4-_RM*hA zj`Lau63&AZf{7G4ps|NAKMh_~1g4DT;_ zWZoDxz=92cWx^rB$AVAOJeJ}-Zguj)J)}EiI{e1J-$woOLk0%~n<75&D*xoJ*~`?` zc01mgJbhn}?k4{CmwKM9|MJ{gDtwnYuMXOjAcd@@P#?={{!Xo7byO4J0}EWvqicV^ zms70g|wi;X?GLw-0_^Q8E)^Oau)EBAMBayLzW)+DMgeF(ZKN0X+}&*z<4$C1 ztu$$WV0kiqZ!|V4gYLR1LS!7ERcLmS0wf@3`^ja{B!jU5A3G@6ELCYb z{!pH81p>(lXp!X^z7p8bP_X7B#UU5M8DMvG?wAvVc>`v zH3nRZeXo$^OC_sXsKrJ1JH5>iV=13C~7ACR?c5R3ccQ-s+WBX-z z@5}R28}<*C1x65Th&-<;scE+=#fxmalibUm_r(4l-yZ>ne_!##`yhi`c7iQd?|2HY z)DAgf$`n!I$QP~Q*WPpMEdSN#M%;4Gp)xD&_E#(KirH)Mb(cQDkS)(ZUa#-h`}(AT zcUTY4cpe%Z(1kyTgbYi=iy~HVB`1djGH@Am79azYOF1M`yTaaRsg^gP`2>h%d4PmK zeA3=%t;CU69Y~efw7ya;jVgj8sDi=JnFHuwGDUHowR-HV~DQb-xrB+zTVqE^7TOF zVM(A74~7$E_9pw7X_Pl@NRySIe)`U#Zi@iBN_57`*Gy+8udexNEEJLa!$WwX7JRzn zW9`;u8^1ii3fK2Qz#=;XoO8jt!L`JU=;`kWAK*7biwC7@1y+~M0YNkQE}SMndYx*N z=#vN77&JK=ad8o^l1GhiyWK!wGgr$WTfEmS5iB6ft+jYt6_ZGMQEqeLcQ;Rs<9#0G zm&20u8EcvSKS9bdruSwPHH%8r{wl3d-~v>t5p)8Qz8tPdIC6vCsOnB!JsC6?_Z1Hj|dv$3&}sUiWB zbI`ggO5X4G1&(hrp~)xJ$B(sRVK*HwX83XZ3#KxtwPpmst|RBf1iDTy_#8Yw;_FVZ zlvnD0w(r6yoPw2R=!^bTPOlW!Ujv^PDaOFVLitZp-H@ms^7|EDuGx|Paeo_w<;{kp z#yfWTBG#LdTpdN}zayn0Y5i!Wqv^D<+$!KcA*o8oU=Z$8E~6La7yz!WC8O3Tcm|kS zzr4Us0hTQiQqpIITHxhUV$qwf6t+77cML>86zvg6Kd>wD306PtB%w*X@9qflQOLaJ zZ$(2)M`8PJtpB|9p>@n5)a_`_P>Gj^NzAK+qY0zXxMN=g*xum4*7>*7jo7^bxP&i& z*jFXMy1`>EQU;b*XHgAO_Fx+1OV(f-I}@!LH7jXzQ)e`#Oqxi-=YCfW7HZQ2%?0%T zH#Hb@OOtOUl~t#GH!d8wI=gd}s7V3yAldB~QWEf9?LuQInS6Ok;Dbo2sShe5H|T-( zB{V4pvIINlopN z7?G^VA~`(#jpwLs$|IK_Yy&{TYhiG{D0blv!=MH z%c0CVOGrq7=LT1XbQrKYZ@9fkUW2}ncDZdbEz<8bV1+0%?QpoeJrH-JrOXT1PJe$* zsMo`C@ivCek2X@pQhiKITU#p8DCmCCQjDYoL9JvFtCeX(hDLE{m#ipzM~=YXkN$AL zosi!7^w|lCtAxNnpK2d#hbSsyW}(wbfDFpDL*SU@s+R5uKg{SIDI^kpWfTw39Te}G zC~jAY1x~9&J4GA#I9aat2kCAW-=cwa^9A4Gn(pScQRdnJYH-HkUSFMq+CdhV|KCdW zYLp?>3-{2(8+Dz*E)w+P2?t7e3>750p>y&k>~=sEuvC5CO73Uy zCB7L4yMZ@y2Fe!l$_)@^WA7@1IZ(gqJAk9XQe+91Gf4W-1E3mOe3NzZgaib*ks7%+ zhCAW!fz=JRJSsr;1epfY<1Fl60Nr4gLTnShre3dHzq0vwfMg;a#VCL>pDkpHE+$^eL%z>!;qiT-cyf}W^Vi5=A6{Z+Qa~>q2MKw$Mxm8{g$WAJS-Q$?ewt32=o}j#9}kl!$|xS{S+UdU+cs7} z2#2r*){de-L>6()&ut-#0~U@VR$uS~Z|9i-I>5sG{A*5H)Ss#NF6S4N9=4vO`Gl*q zEbGS+I;v(zEa9BG&pRJD^9csP>+=B_@o}ih#n<9BSMBO>YmRPlVrL#%F=$H9UZsBXpvK?+h*w`^0;yt56jl`%$m3EI;0M^vkvKnyT+Y@=JWK1W$*!A}9X5H-I@XGA43 z!ib59iD}P)!viY2#$?*otI}Ji6%b#i2~gB$V~x4;f9Eq3WipYt3h-zm*X#|D8p8I?ZS?e_d_;^@0%s>DE-2B^y7Rd{! z1nvIwc8o@d0RWP0DU1IK^B4L=BK$(u3|xjhO_@@K`Yo}*@QBH)IA_E4D6Zw&zZjS!anL$H|-wg3SZNoOUc zxO4R+^r(${9>eDLcYeM~?zoH7`}Jkbzv`B+Gz?~`TVZUd2=V)s#uRqrXCzG<2tq6ICIU zv{v*IhLU~!%!l?%BKvC$wO=a5@fN8}M3bbdJnaoDsefWJ++lq!K9M%&R zD!GFJeREI^=)$It1CU(7>?OwUCsl!(n;Rv?k8WTrnLw?*8}*xx##k#M zQai?iZ8ZA+3;~Umz^>5jNV;beOTy24s_CvOkR!$jNQW$07R$$2dxB zG^_&3TzyLYIAQPO5`%(G7`aP|N-6g(c}P5GuijG1xT_wz#@j(su9F?6Y_6@^@lrBW zwun}q21hwcmWI89rjbK3l(k(2E$nD*taw9CgOPV>J(2d^6Fy-Ztbz`fqvz(efe}xG zjT%J+;MNcpz2Mxbko9(f$cWP%{{4njeN$>DUkcy+Omy^|#y&&%8}3dx=vqrn{A;wy zR6v?d0T7P9MWIxCuoOy`fR!SBZk3dDR?lWp$Gz&`FW;VP8IAsE3&AAeu>e5@ENj(( zJUq;S_Lxtm`fwoR>wsX+zhP_CJoUAfVbn)3)U*V)9GDhv&h%7W%}})Ap;Yd%B*yQ^ z!IWfeji;`{k_+#H6=G;LgN&3gm z()A@T8!BaQr{bZ|*q*x}WaD%9xepGJf&XE2sy! z2PLl9YX=#VW|<8Ir?I5lwUJUTx2rLciV80_io0OG~j9_U;Uk27Q!i0(Ami0yS>%Jdq+#MhLw$+b!Es zlLdG7%|8PvF|+q-?7lUTeiuQVs!-PpCvM%fX)NmW&~BLr#XC}6pJU*sJ#T=`Dgw$; zjK+bXA%HCMU`_V!v6=#{7rXC~eD!3P!vrM2WolgaeS+=5_0TIkLX%Un$x#xfBobeW zE0Pu$34Bn_CXOb@(3T%dc*N=$oXj+SSKrS$oh0Rls1|O9#zlhp!M{u3w$$>7<=v9$ zaGJ~1+o&V#G$)v1f@&8PFbO=UHw0{npi>8%H7N7J(-KaUN=X8R0CE9yhB*e3J~p7Q zW*<0VS!2N7y+gfFLyjdgEHm&rJ?a=ImqD9Kn?{=^z(gxgK{84?R*FVCr{yb&YAeT= z09l$SvtFj7wVGj*0_TsRbqq|4-|Zt%Lk@NQp1_?^gvZmTCkTo*j=%T!msxCtQe?qm zbsvO}8o7HYIV5b=!~vW{7@$uxWB|q|sBo&6(%*b1u{1Z<9(YV}xivQjoH72}q}d?O(qAC}Mph*~G!> zAQUhy8yh@r+IIJZHGO!UvwCfSpWn@@;yP;A{d*a47=1lXm(bGtD*wMDM1{jFh1Zq@ zjg?usP7sJJ6iIol1^{j?c{Y6SeJ6cKk=R2rN=h-yzKb%??P*-97 z)^PoK>BWdOA1ndFUxT3$!D%9W5HOlhv2^L)`1U6O{>Bm640>s)BJnS=rjcG0QIgu3 zCecjI7@#lyMN>)sa$MiQbk%J%V=VraeZ+tFiKrUCCit23-cX=$NYG!h_3k4uor7yS z{BqSpFRxQ@_mh_dE+R-9bMx{Pp+ni-4IM~yHw%4Z&@{mT-Zt|)0LjoX8x6y85q!8} z?{uW|ZajmYrK2dcUA8HT6&op;&bl4i^mZF7j;#hL3yjnA`fQ5`?uUV2VLp^ z4Zs)s2^?Bs7!Gj{Q1#;P>wB27_JP=ZBBpDWf3HceUJ9=LuS;cx3|K^(mB{%rA*o>J zsGln}lFT&Pu~)T!OG!MTO}I-lgp8u{s5K~;v9DaQn!ALTuWngoDv`Exw@qavzeb<8=iD$=KahBUEi%t?g}cjTzkzGLm6VGl}&iFEA6 z)mFr~Eh;fniTGi3L3sU>i^{ZYe*4I!4=g-~nv=LMu=k+H}qv&cAo(V-DLwT-Y_ z?}}PKl~mcc{-cb+e~4Z9zxIJ`IzOGMp_hF9t%95{$*c+gaRJ1fK5hRzuyxApo;4K= z;}fDT?EV&AQ+OONspP*4)!(w`QMM7Z)>;wUzSjRf4Yc?hRTNezpo@^wQ1Cf z`0x5a_~~;#3LZvX$rALjH{Sn$f1i>_wT+8Yp<)(RcZ1g^+A2{%psA1@DHFd#XZ9#f zt!0FBKmT@KhtV1}jGphZv1TcY&`YKXpH*YVf8$%(DH}z5fk#Nhey)+lWP?4s>E8;}RsJWe^`xNX@ zFnt_eZ6sGwo~xH{kEoE(`&N6Cd;=f#l$`77pOFp>>I(IdJ)oAyW$93@Xhc04pyl93 zcC4!q*sc#ssyJ4PoJ%C*wS*VCc{}UhGP&*j_e6*K1K4ObI+ut$=*r*F$^$i}ksMZy zT`p$)>|x7Eih{}M5MJU&a;3l#@_RIWt0igdwQmYd*7PZ#5PlzdNa=Z1x46If>{G!P z={wCE)r_qw7k6xp{hn?}Rc%x=@9MC2FtbcBcPN_34}3UvLuwNcx#i$jXr?vRUPcH^ zJvk;v>|V9Z3HB2k%VPo;@}@GKyfP$^SqnsIqUf~gkhMOlFc_TcEixL`x2q;7N_Ftf zmhub_g!L)Rbc|5EF&20its|Zp`(Yw~l6d923ml@=TudAfWzuEo)Qyobo+m0(51qoi zXWqKJ7y{qjPoEggl66s|LA~PcO7P`P+>WCE*8_c(+-rd$g0$w@-_XXKo|*M#M&4}X zha$OWJV#B>I<(i=Ny`R{W+lx1Zgxk;RZ3D5>;94lfA6B2>*u}RjY1g;x`#zx;#=Y} zLoR;1cVy(pd5{rNfYIsocJsh*S2B@1-FFV7dznltmvx%Y|9@wj_XlKzgy0N`ZA=7v z8#zV$(T~;bqRFZjAQwww`%4g-@L)#{E_~G@ueGRGRf%RDqU_V>l=e?sCuaY5`}BQX z!`p>2QM|$;ash|04O>DxR);V+aV*t%?kVqlXE!P8tB7KD=u}cJH=sa`9k@Ku{TqOB zhb`9$Ru}%$H3mHSlSHv9=r6H1V6FL3&P9UB+M#2O6*oTX=!xNxtdy>4=nIM~H(j z6})&220p7`?oTFbx0W059y#}S%-3*CPE;sy;PI_4?AlX>g)Aucow>huf*}GpHBE`w)08n6C)i@{1qVAoa4!OW`1k`~oymUaMrKaqVTAzmK## zwNM6OmYTq!bXSA%(v$xGsB0GbaK~WSIcn`HKqmL#aYipuSPMwFzi>@T)O2V6d0zAH zTVTg^%wI(0vvy2lEne@ui)x=1sm_SKrA3}c(jud1Axv{4j0E996WG`b&K^KWHn8hH zVjC6-4HKm~UKDG*|E@wt>hfgw>K>OE4bA+8KgA(0JH*P|$@6NraQuB?nl@-vEuw%} z+9L`818x9S!P`zcgL7gUvFicS9W(7%v$IrItyuN*ydyC)o+E5OWn%r0vs0sB@(Ic= zkG|n298{DO_<%&W&pW;rhP7>pNk_Zx3WDk~WD0>;|P|q1PCHSpbMv zD*F19z%Thu4hnqb0r0j^RmI(4?7FLz{ibx(`tO7LUQkrjXImZ!AN?XcHI-Ue?T-efHipndc3_&jv0S#)!r&?=G{ZK-gJ48u8Te4UymS*e3V z!Ob9U@tOoSB#Fy4yDYlA51_trnezPa-c2L@@ZlBI;ZGu=^C@j`^`-6|<`}v8?dQ%y z`W60=uJzSHfNUBoO-8qLvi8j4S+|-tS)VUob;s(fRfZK|pGq)Af(8}@SU~0-3?B%^ zFf|8zFV2_HKHqasnS%Bq{14brS0x~AR$NI*$;ZbBUe~)sI3uHKRb^#V{&`RS=f6Dg zZyZ0NO@A8qSf}(3TSpNpHBXfuqLoD*W+dMg)4yW^yBCO#&9n8zowO?57%B7*0(Tff zFfA|X>Pi-7Banwr0A^0lO*ja_aa9yrgU})b4w-JH91Ib#ZeoE!lLGL$uVi#o6qnV& zC;CoZu74(9WkQdZn6;F|?%-n@OC=MLHcK!2%D&{nAa+p*^)q{KCdEP`shxFbK`%GH ztNE^zO*@$jhj>pxPi@OkueSF<=2o!9kKcO!X?Q+^fwHmlv#*;eA{f~D$M4`x&Z&9( zOWnB!J36Jg)vqBdXm=@9H!^jog7Ho$Co>;kR^~0<#JsSlZ-!^h1iL;AXJ*k_mL{g| zJuOYW$HYyNfp)und4L*qq)*D;qP~XsKzCSFMGE5|(#IgJhu{q=b7W#0PX8l^UUl|? zJxCxZzH>WN}Od_r&w;TY33!M&qhh}^>x?r$4sRe9=h6D~+ zC?DWPQB=*XC=MoJJksHF{nfU!ahxn}n(3UDPVB-uUK(I=>PFEg-6Y13zGTB&vw zMVlI5KX`mCD9Nof^B77LA;3l-LYc(q)-bKEo$H+m6b*EmzlKS zivNfDX%PN+#GkV$wRHnG4*=nznTw9o+|(3G!uuIUHQ)Pn;FAF_2qpCc>+9p0H|%I# ztteJC7(ic>ph}06I`zW=X4qhv?n5Ur4Haa@Gjx`IxF#y=4LbjwX>Dam}-K3Hv2nsP1Y4<^c zAq-(AK{$7HK7R#+nR%+ET5^sjFAON>v@e}jUaOUQuzTCiI+d!rhE`6^B=)KwWo)Lc zXr{p1Rt8FDf~%1OWMM=f`Yfwlv=@Ff?mQSAwW5(zmPco`vnWYoVk2zMTK1|>@6UW% z91}D%qrllKtsUD*@P2Z`!n))K7be+OvEf3te0vN=ArrljsHg^Z(@LUF3Sn0g)3DV5 zXRjEWUwlfe;~zDod-G_87GA4V7nuWUt%O&wqo`;T*6`%fvU3%*!KNE^Pf5UPm?lL! zqXF-Wgm+;%0G**SC5?!KWqc(6`m$R+N>sZ14p?^Zq zt_L}E&rN5<4JZptgZleb&qn8?=OpQztP8cIRMcX9sc5o0CVMP2`r58sH)Vc}zFim? zk@S`}r>5rflP%?E-0{Zg>dfJbN5|4ma;gu?Cs4)Biy(YwLRtjkQQa5VmvF+V!HQS` zsq_?*w)>Z7+<#7@Aq0HEObOd6*$pNHfy|qo5*=d)+3K6)97iJsULmShyEl)S+?9ab)&!@-mChB-k?Q#^VK zTrcO34^tKmea~Fzgej_k-ot#r3_%33^FaD9HMscQ;1B*~63QsGo=p|c?$veOMY15- zW)%a&7^aY*AI5~bpXueCmrA0;B;5}4nmJtQ_pMomx@jL*iHRkkr{$HqMN>-fXc z!LG|%CSAn-!*?G&i12D|r^?h5R{?$1;#EHC8|kCQ#X9Tf z*~%~V)f@%%F~bO|8;PBV3R+M4#h)8k@aT3DJbQBOP4nlE)>rab7>Q?^nhsx{`U&a3 zWhb!Fs4k-O(n&S-rEcV~%xP=ivMEd5cm16A>=8(OPWm(0NWz48>5xcj;iUX;N>D?d z`JqmR;xfM<-b9GYZ<)Dh?re)@_vffWk=)wclGv3ZGG6by#Y=k$K{W$ZYOvXUB^*Mr z_#zA|vs6tC2ms(%1bx5C=>XEiBu4=<*uXWow%N=g)2;(82{E`PEW*7xn)08kz89+H^m`1cySglRfCA zDHFtCt3Zd7(cfTH{9rfX&$m$aR)^7qvzg0f%X#^EAZZ?2bvf%hxf2)7%ryQpw5|<_ zF86C!LEFsRJ41}2lS9GUTEX?H*hQ$W7&7XnathW&!?0-xas0bBG*(e|q$;dRiDRi} zAsh+mPDR|8h3}u9q0^AOO;*vIvM3%6md(>RYF(Xm&+br^^utLk~ zQX^Mz@U~E1S)0eaF@N_t^?9(y>~kg>sP-Peq}mFh9S+ z)=f5QpuJ@swfxyDp`&XhUPDj;^SlB;xM|pP|7IT zWGS%Q;h72N`3Y>Ex}FP=XP%;))Wc+g!`6j=3;3?XCCLMZ0Skf{&43qP>=pRdg6+Oh zH#Q4Um;q7_234Tf#o597sws3v!Lf}Y9cAMQMFMr1Y8DPKGoPB9E5~#A1u?U*zz#R5 zdIpOk4vN8&*o`w$E&0ido2{?T6tn~X)%$^Fl>9M z-QhQb89Ax%2^Smw<}rTltMpZ(V26;o z5gU zbe%k*zY@5B5MTyuSQLQ`l9L1$2kDTivCA5Ljj+XS0-sMxsu`>pi;9c=`4YgF0@i~( zjaA^dL?@Lzh@FQXen3!`LPB4=$1o&TbS@&fG5U}1{pdjjym^VH7Rd^i#0)awsW^Y_ zwvbYP;F2LJIqF?eht9U4HK7rVzCDkxPLQkKHp%?Dh?V6QIRpLaRzc3^Drt8#v#k$g zVbXcDytkAIPQ1Lly-j{U=xitH1?M$(es21)rOlnS(Xr!WX|v&9b;~(qt-z!8XXQvj3FscIy|l*#8+2He#XQMicWR!3xhL? zXll208Gr=Y+1d1TPkv#D&w)`RF|pP_aqhq%KlA%C=bd(brR?!}SJI61-D8eTKAXa^ z=LSJuIJv!ait1Y_ta7q<{XbVmttY@J-SN!)dzqNSr`h+I2LL9PMd5@l&ySG12El=6KSWc0MU^o(NCf8SNrl z_<7`Iu-RN)T}|4YoFajueF4D_tXgzvA|Z?o2!ywY0^B`4!5ER9nz~`kLvHW$Bh5-4)>>c zzm^4K^Jkr#8|t~d8Q(ecd9M@Pd~L#^T9UWkw9_-#j6VDugZ|3R5(I+Y-`382e6pXP zKK8djh2RX=*3O>s)R$f?bm=E|%x=4BY9%Pp3yzJ;(e_PGN2X2VkVlSmb)hlC!5kNT z$Jq`4hV&%_Zcemx@yjCH7pb?UzYACog;~)u$eh%jUn;l!x+_J7hhv3FH_khjYPyfzxOzG55s)^M zl9MqtV35?-*5U@!f)TNnJ(IxAFzM=fTjA7hP39Qrr0Q0_>I~!M(v**)w~(Xs`j3 zB%h|_=WjS9Omua%bPfvAjuSaIiQ{~x}6+pXWq@|mT!IQ8jBE;RWp^P>xq;nhn>FP4$<5%#(Kqmm>JD}+Ru)&pp z(Yg(dZn&T=Z#VMQ2xi7t`so&-S+R+hAvUx$)f^rYu-{;cdAi^k00Jde-7;2C1$_N# z52SaXLG!8*wC#Ov2Z@Vt>77{aR{!Vx33<4l4p6B<|f@&Pr z{$pnTEp==9*Uc5&vA-ePxA7^ul*#1=Mrzz!(lDkq$oQre&x-?PwpZdXziH%;QFlDXzIT5%@{cqX7yL`av3U?9*Hao3hDjJs>|Rq zNZRykECdrHP3`mVB+Qg#0)VS}bjJppktcYMp3$G5W7=kz(BdM*GkTLbl5m|RP=mke zPG#l;1w`&-ozet+qmhwMd0P_Co)thXJ-JDqp_5ilmtoA03K!hhyFNQ6&RZN0C>^=%sccF zB`bksSctNDk_=Bijk=LPyv-C2?}^Erg=J*~tqwxTDe>{L*`)SDFmi3|?8-q(MBfAn zKzj!VR}f_6m^a}WkCtcwfp{UHMOY1**T8OKUl+7zLlmwug3$H&If z8T)XCpe^NzKfvKZo9=O9vsw#C7IVXb*3u}x2BQsiuqcdzCd z=52P}rs<{goExuAWcqvvfBiV+LImiJS25=g-LG4~u_<+FIIYACV>83&#W8BoduIAM zykgi-S(f09+41mAhwQfh{}AEy>IJ3CNSHZMNh zl($C5$@|Sj4(^pk=DF^)7YI1PrwlR&Y7Y#| z&CQ{S4u^Ejd_7C}7aJQ90fD8Nne@qGHRinh{HI(Aki;Y@ZO-@J#@w7CQWg>}OD8`B zt3b^J7WcrnPyxOvF4@q!e&!Xl|2W_#@#Y@2Pl& z5`lt*0!hysT0VX8zK+;B#9O^TVLNu)+wNe|`|Kg76CQ;YPhnJ_1y9VN%R0G!@+n26 zEbF_rRx>W%t`A(q(~-R_aoOLk)Lp9W$$Yoc=5dT9jvLp{c3((#-Hr(L@!D%_$#SM$ zH+8Vg%&o2t4#J8H>0o)r{Yk<0bm9jCrKj4-z}F|c$p`1R5V8il;*Y+uCI?*i6%u7t zJ3jIN$sMx#w3@Q-^Ygup6d&EPFWg6d#8On)Z*5Deshg;t$crB5RaMF?cXrGOgEzn&cqP4%aLZXupP7(iAnHHwAX8m`l$brfVix%du*U*rGx*RPLOISz8N8Rg}h#zr|hd3Dd1S4c1HSl)|R z-X0U9z3*07QJ1rGR8R6e{k&mmDssGD#zN6yljHHGrciXWIs}-zIPbwShCr~elx0_U ztUb9HcTYPmR@KyeW@CeixkvAaJ1d|>6Zs`C4{*5+$j@F`;p=}V3A@5o_i6K z*5mi@I8?7Q$B@7tiHlnVia{tQH4WUQGJ2DQTyBOmxw_)frwX$k9aA1KgHV|^&J$OltW&Qs>cPb8=-cR&t<9nC1AGig$+M6 zzdpcU7TPg~A;fl{_U$L9oQl)osp8UFo$QzaIo6aLRBc5`pIz=OYnaUS8+$aNOGf5ry)d@G@Y8_V z=P&BJV-+JJWzjGep$czV=ob4ML~#hv+3;UO)$Q>gK7LU(A9iF84ZR`X z&fH1r04LTSL-3aU)f=e;&O2g9b9nJrv<$~!yg?J;G6MMVSI{oaY(HP z2#a7sOG-{|tk%=b*q88Gw&OGew-M@1H@_`z~GAvvioAFM2-FaHw zId!SdaEVGr@3+S{o9Bj!TN}PUiPjuf(|*h^K-+WM{2qDS-rj9==~rI^hxYSX%+wz? z-W_vkX^INS8tMy4EW@h%6Hxk!lQwEQ@mZJ)=CDyLvHy?xko~TWqTHI;A*F)9s{-E!tIeZlnk4O9^#9Y=cR*v^|9^j_sLbqS6tc@j z$_kM(x{MoH$=-VtSqULYvRz5CvXeb8dynkB%Vk7XGM~5m_j}HBp7Z~AI^FlFQ|Vma z@8|P=zh=$u@)m*V*vc?;n-LQn<@-L99yb=gc}GCv&PC*Hl;%CQVmyS3CK?}EU9+K} zWWO=YTL0pC)g&Gg`-odGp^a>yp=)%in*edh9a10hWmM;O)jv@=sUxqtE#0SOs`%8_ zd#m8yaHjfA&FvjB64J|BvfE_D@DEGb7JMZcv`wik$t-R;_te@%W^b!63ddph`~I`! zj~dw>9SRr>#;}>3BIM<~@Hsn|`e9kpVAe=EFhBhOG_=16#){MHLA&(r@4xamm&7=s zp;KF^x;iv@oAvfijgMD;{8;zh$w=*kbV_Hkq@%ITS=e0u-R$Lz3g;<6b+V& zvxUj4)8MBdy)6tI5EZYato+kK7)~k|(WBYm@MiA^SAX5l)VbiSuV?pLVS2>x-j>>w zhaZn=NpKchpqh!Di^=b31wGqTbY5nb?OCMpq4LN$w+jojV}L_sgYgjb&xSghGyc8 zjN_sxb=@F4bWqRLIN0})IEZysXBA;(TwC+>BvfesqDss6v7e(ljQdNTl#n%{mw2R> zfD)SQ^fW5{T$0ze8f4~CO-xLD&(s|*K>~czbA*H$pFaa*u&}TIA3&L6!KADY|Al~>u3y1ucHa#wVb9$yGyodVt~z~k z?7n94oj#mhOza9$0IjRwKY`WvvK>kL`V|EUf%JIfDFW?$u@pN#-3H<*0Bfn=R1LL|<~c9pwKJnXa|b>gZkGm}d5Q zGryOfG4%euo~?~lAWhV43gxgEH^aO;M6Vw{w{I8fgfB>|@-DYgNuhE+&QlDjvyaI* zuJly-sNWZzJumP~c^w(>i{_@L>QJNQh-?!Q=V8)G*c^?O{wQzldY&oEkhof;5zS@fW`WO#XXOhVmTM zC1dZT3Ft{iFgGtpPiQ2Wyox9d#@x_kk4nCaxg~M4sJL)w$^x}y%oZb;|6WtM(3cpW z>DNufZ?)(zEMGF^(kX|V;|;J5`5ezJ;<~jBEi_<#S`12}KqHT}=v3?~ws%Q+N$F-^$hyY+PwGWKHvySv{!Oufv+gokLcMAE@anf_2# zvxj-KAT#qk6EtG?Xknmbg_ep6q#0Tn49yIB;LVCw2+VH>Cz_epc0$Wn(+(y`+d%5T z43$!zF-pFRQO%+v5M<%x;(|Z88O3}1wks?ix;NdqyURr7(kPo7OQS5B8ye;ZHTBOS zV2*~uG>6dK8O-L!#(7fGAonjY!N|PI0;azHT0&x?s}gKm*dSE)r8E3bCv-7MnAMOG z$+0>L4q35G^5NUMC#xOILFZEG2pCpvZOb$eKgamPgZnMq-1)d~jMSl|jDH{B)KuZ3 zx;Y%`$*Q{FH=n^5s|x@w8%0~ws6U$BS}?&%uc76j<}&3Y7tz0qBW?T5Gz0;&YdAB@ zwnj&lC)0;VWR_#D5-0mDFFH(xN$Fe?Did!7N;f;I3_BdvCi~{EU%^C16H*)TkUkkV z`kI$=p#?7p&m)aICT%e>zuH0?tQ_}#npLC-Z@kpI<-xkpGxHIlsjv(`@m5-Mb!PbQHEb&8Q14F1_wjy`4-gDXgkVAwDbC{InDpD1^R2LeHeh{8_I*sENY{ZmsPVq((u-x!WS zaz`;M>QS2Nrym;|4vLB-5iJ4N^7)Cqz^oJS;>Gp>{}m374B(BuK}ocq@sY^&`SU6% zQz1ZhAc_HorqjKsYU2gAdvbDeer^sT*i*E{C-lm%gYA)^nj8vu%zEw=%g9_k9{%l| zQn%2UYMd}xt8iTHpG!OI;b}nkDx9>Zz1$z{+prnnceBUf)x#RudkkHr%~S? ztO{C9BEm`?3&baBj-zsmc^cc=E@11K|E({1ttX0|fvW;~E z$%PLN5g#b|#eUlO=%37t62*=(>?XCUgvfYqy4>8}(0X@QU)JE)+Lf35M@bll46g_9 znK}BhP$gV$9j*#GI->OFG5AuR)!)|#SQAkfw9f!?3qA1pa%|kZyn=Ma7Rvy78EK5E zWT~(5qRyVyX+djDU0og6h`qO${$YuEcbR zI1H{JqhyMR{qW&9!uRn00hJ5un^&(kwzh8FnOj=Q#ELmMK2;M0!Dc-_E$ZjYqJxzk#2^ioi9k|4VAOXHM zHTwyNerJJ!;LSU8v!-FLdfh1`*I)l5L-nqW&6=kK7hx9@A*(!Hx1;ei)fZ%>_S50r z95Hu}0w~WEkPPc&d<{GoC6&>(5bBRj8I_8fHdS`l%oHLzn@W?{Pvn7u>0mp(B_*oV zMb~X+h7T36$=X4aT6OD{*UOxwFf+k&`zXpkySqRSdU|-kr&%^CEljC~B4$Oy4-Jk< zQszMaQ_)giha`WuDyq}v1i?& zOuHNaRRX@St9>zW^H1#wqup8ZDp;_E-+)k1?kYJ{$0=ttzy}KPGc!u(-0ZBZUgXv7 z=jarNexhcueExjGK#`Cz7(yELLFwymA~6O`Lry^f`SRS^yG&%FTKDHYy|s9UU;;i( z?}3~WSQm{8Cx>#h@x3cgQ@9^I8;_%`10wmY-0QvmqPsCzYt85(9_w*+$)|7a6v`*% z2>6}ri=6rD7Zp~+#o1qBZU|ZGp|Oi!VeMch&1jG&ZlLLmz*KOq%q@TcD`)(qOS$1i zUsQZl`96yH^Gzj|#(}J;cX#s(%O7)6m@oc-?+w!Sd_u)K{UVIX1t0yiO$08KBxtm* zeE+JDVj->B3IM1=f*+`aU%!0ree-fweVABA@E$Bt%9!CbAT&a6-a$ z@OB5#SP{&UdHUV64v`_=Un&$B(98Sc0Uyv#UhMLKViOvt#OyC4P%~XMUC(y=5 zFo=9&y!<2?(hL0k@i2cu2O!D8VGfotD%K0^LwQd-=6?g350kSgOYZ=b1nAz6F9`%G zVlDQy+-U$}46n8ThUmeeNXQ3DH*OwfcFlp?x@1K0djx|*C9Nh-#0{T*zHE3G9Fq=P zIftwlXRkEJ{LAu4?ef35YftG(lqyE6w&S~2T)t!9Yp6p(vUjycz6ZHRdcURomQiJe zrvlHHM?6+8GIM()t)HJ!Vn;I7+Y_S6GzJG|g_l;B{hb{ON+;e2f;2Ei_L{^#N3o;srP$=x*Q?#uHt9y^o;P7 z5bC9jQDgNzdF`~2yN%6smoLS@c|`um=*zPw{dSQt@M+sgw)rtu6#hz!<|;TWI0 zaRZ<&5n)kk>Zc#Xgt`X@UDNsS{zY9bo5`Q`mOp!B6J7O9e)M}P)8&5X@v-^NOY4*) zxpQaH@QkEnEhOCFK$3WuDZjj2n+y!fOcC1JDt-q+de#O`pUg zmPnvm0h&gm(Ym@5R8clK2xBnpX?{_D8t7(JsKp*tUa6cjH#R<=`sovV$f~OE3he*^ z5)l=>h%BwFI6FG}r!JudAgi5=OP-eo>N1roCmI@z;qM%)lxt)QcRVU4$v_$AZ zZnN#E+Wv=x%)|Cn8n1!xkbV}K&&1S^F1&9%GU`P6;~@z#>G4+=VdGnjohk}~Tj9Ch!Qmf(b)Qn9`^0c+Foly^;bt}5! zUybRS)_?ZPMMLj)zFw9(9#S@x^q_5vg3<^jF6$8IC?ecDy!ulFPUH!DE$zatk;a!K zT^6uA8r)5>EC$X*h0SNipYRpOAqalqTUcl)T*PI%4Yety_~|FNoCBa^1z0df5hpfi zU*qDk1mW)w5-I45a;X3*5@`Y^HkKo{1In`cY_+uLI$IMXBMnv9ZgQS}>*WKv?+}Cu z8BR@ngZXR}0<9nlM^sc4mSYIh%#Y6`p8SZ9k1sCHp(_13JsluceNHO93YRtm7OSNj z)$Z#+IP^hn9?MtI83&=+iwXk3}coKhoQ=E`$^bVvBW}Nw{Ha(*H*gKp&B>PLg z)Z^%l^y@FRYysak_(B_~+V)Y|PG1XkSwbk0t$iW%*xwMTJA76s>a@Afp^@WT@8`Di z(_JmdfHbkmNc&B=JOKeA9!#1JbkK$CUMNV7)a429A=oA(a6{8fKHWvu`Hog#g;$x-(z zd4U#hf6xNr2PQF#bv~+#poztPw0#R<7Sms|^J|YiD{{}D;?teBn!!MtE-!OkCn+u{ z&?+$SkBEpkZu|y$+HeqLxwWxE_hl<h;|+WuMP*(88@N1I&v0NqjWwhrEa?7`w}P2O9a?diN| zIql4qRWTva=XNe-xDT!cvpTjqwe&66I4yLVDv2jYU3Iy&t0Lmp`&KOeHgYMi1zHwqT7`fcb+6^EpK9oMt2lN^afi zkCQ{E_Ce&8&r)y7_O^>O+wsn@{%l2a-0IoC5WtgOv~g`e>BnQ1<8O`4GDAWo8_Nj> zjh4lpEJH4Ro+_lM|Q zWczZ(?wdR%p9nd&n6`y^fq0)gb0ON=S9Ym`oW3?~KQI!-?AAM9kv!NW^l{gJtmyq% zQQXl=zP$=oQy+KczFbT-31Wce9U@&(yh=wcJ#DcjO^lCbzt7D`8`jj1N$k#$O?jK8 z{zW?FtWGl18U6qlGzWT^(Q;2x;fZy}V7mgXxp~ZWC|8^pz8Z;Q=@kr-sW()Dtfn{0 zBfe4?I)x^95kLhI9jzHy-}mZOtJuy|0DyMq&(6gd(4|+$@>A#JxY!!3YXeU|1&mJC zfQy3zJjmF|}~oSt!u6r(9IMmW*s~pELMnaZyLSEvPy%a zxCC#K6iRGF-)q`e2hPa{IahUbbXZt3mO1e;YIul!^-c`7sFan$n9hus{_TbTjaPe} zva-S<0m3pbkKW12r`p;V$hM6k6W|kAU{(lO6u^N5CA_na_wP3Vt3zgddwLY}9hjr{ zv9Vi!4*|ykfz9QdsMuISOcMkOr}0$-yC=m-MgN2vyaM}B-UWmo=t4mM!pFyFZ*LFm zKLoP~i1X8jn>91aOlZCgJ}W@y5|7rK_7Hb*yXS4(QHJjR*k6DDOo%98GrmXSPtp=z ztKP|N6@01B*WvUfl0vT3$&EHFZJJrZ+78!y^K-wYRk0y%Z0R0IR*+%3m>5N4tclHZ zMfsQXysYvaPisQh>0RBDB>9@{V}#Hj8_X16GCF-L4QA>6A0%e}tuHN0=uzd`XLSo<4p&+Bfy<$8x)g&P1;-2hWVKL}b)nvpW+Z z63`9OGlm(25MU9IG<0-i=jX3?#;*eL_GDZvYMA_VlsVsc^cslDgg#VUjnbYD$by*`LBZCVr5axx7N5{oPAbPyS4!Qkg(F&%8?5mld zQd5D|=F=_Pfb1@bxHi3!eYQY22&w+8+=?Ed-2f&b7{O`6<1+19|Ysy~Jpf0p_H%WoGr zT>IjLZ;0dn5Dy6pC*JSL#VIKjDA?~gU4(;w?MX0eV=ZD^co zoq2mtmW_RrW~J=JO0W!llMpG;b{FXG@4COFFn=*^`DS<^;CgTFp>#bLFTR-vMFDD) z@F9!Tw1JVa{{}}odDlm~*VYaQLV!XX{0!;CgC3@eh>5}L155TS!E>-V5@*TFNU1|) zAPCD?w)p?uHpxpHpsf6@FY5GVf7iM(gZL&74Ryo8micP*mT=UkccF#+@*tPPy=*ys z0D6C72LgeH5t(HF5l&4-wJP@vby%nPcNhfS-~NBx$p^b}kfq<(ABSs~MaNY`g9bf!01djh0nlZv zgZvp3haMerlc%v-uu|#dPLT0Uk+1O^p$Aw*wm-1meBC?(T5>>L9+nFA|=CXT{+H<(~+e6W)CI zrj})2MoM<)Zh3oCn?EN!Mjwn$+)D1C*^Qs;4o&r?u~LwcM8F86k~Ppf+D}x6m{T*+ zXJTj{=4!bA*;USFURvdeL_cA(v#?rQTPZaV_}MJ8lq4GEb&25F?(Yv}T0}(b9p9yS zBWZLiC>T7{?0OIDDVDBgQrhWoGX`T*QeJzTr}SGONVLKY##n6>$KWSy%B+|K&6$*l zXzKAQ7bXpjez~HZ+E-|H2=*Bs`wke5g{djX2ifli+3%51ZlWT+`Lf#S0n|Z2>rjVPm zZw|EWMI`gT82;a?XImgag>q}?`~0GsHSsS1`DnTZ;x~?ZRLe8+1@l@MOL8fzA>{WJ$jMLKCzS-Jl*tsV6=e%d7nU&pRCCg^T zNeTSaS+c4;Z_f|NV>b#SN`+!O0{QFgmTIHw3zXgqTnsvW^*sgyE@c=VyY2agKg1^k z6Cwt~7~FtcU(e6ZcP?;;GuYkD&3~%>^o@?wo+;8f+3WE?roME&(LQF^pWn>r7yOFj zy5IDz+Dm`B(d52@`?VcQr+e@rf9Fhs(N3tT@Ecm)o0Xy1+CuAYK>vR9kLBKx8>f1xhcbkQ~56f4!>9} zcg5K{yiGpd?p*rGgol4GPi1>&_l}Sd2`MQEc+1Kw`z>6E*;#5+2(t&RScF6f*j=u0nfG!OCQb!hC~pyJ;;37Oej>W{iM%XyyqIIvF22#jFS3y{5>}8YA2kWNJ?QcC^$2mP{iw+GAhw{ zmW{#^#2}|T9sqS9MY@(x0o(OytmTF<4-ZjA*xijfd;+hq@bKcoLYR8BwY5d{G9VW# z0Lu;zb``cDn0?kqi&_j6VaVj9NHhb5HylRoU0t_5hO|!L(SWd5Nob0gk)*!(0QgD7 z0Du9i&{?6BfdO@<2u(G${?Sp~tRXip3xk2sx)pFojP&-t#bA!W_cgW2o&F{`xP>Rb zY7?xJ7POEN(cP_NY}bn*G-0=WkD(8)2XwCxcNaZEG&Bor=MhA5zE`-8QPQt@{#X5n z^REe!fZ6a9^8MBvWvvS&M<%#jhcom2*e9+6WMD8hUR;91xN=LFC*huY;p3z!b z#C_4QuGp(qTj$_3poxE-HRxfk+4D_WFRJac;>s|?A(tZOyF)kbKc#9>$|Bs+hpRC= zLmEPXHKoOcQw2Ud*!wN#Qa|z4isyJ+?S!W7A4g)ILdlb_J~JcPyjW9^Yj&-;hYWZ0 zk<+T+KXg}41Jj*_g6?1a77`XB_*==8IPdn)UHxxnaxMz1U#SxBaE`>-l>f{#q?(l%B{1oj~F;!^P+> zqtWs4j>SzNoAdH+hRA$U%>pC`gyF%)P`V28=r}ZcI0)e_Oaub&;)TetOmEn;TmB(t zUODM#7l((jlg=dffq|{<+UxGcJcHhXFitjUBa15^FSVe|9s*U)EM^MNY+32~-{~$p z)_Ja+sb>F}JJ1h{zQ4o!u98(XWL*u8R(ZB}Zgo6sIdTQvpW}-znbo+HW+56(r0S_hXJ~W}TAyl95}FXn|G^6bcARN}fPoFRbRM-fZCg zpo9@*lF)1~Gw{C61P)r*7@He)1mX|3`;}v3W24$kw2W+RG4I|P8M%Dofz_%1_61p! zPs&6@4c#0-PZ7kUW7HR9+>GD1b0R0C?aA3%9 zb#84=NS)#oB9_A>F*Mu{u(8P@CY@bh*(r@puGUrU{`p6t#dJu+#@syk?Mxj~n|Mrb zsAWb(l&-Y(@t!`1EaB1~5#j*2<#CD1RBUc8i8+rsYRT}LRIV)s)Y#23M#}EP;ACn0M`-^=MCrkbSjpnTqPvS*#OsTA zi2B9rb<-TCvvQp5VZnHie)dZtGO^J!JcG=X5wrlc znD>vhAbs(}RE;ckt76rZ_Z^D@W?x@h4=(ZP^c8>WDz^jUe(5lw|Ux4PN{o3XP32 literal 0 HcmV?d00001 From c0de5ee32deab212ab2030e9d9a62a24e01b6023 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=83=A1=E9=A3=9E?= <1835698775@qq.com> Date: Fri, 8 May 2026 21:00:27 +0800 Subject: [PATCH 25/45] chore(main): bump version to 0.4.0 --- main/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main/Cargo.toml b/main/Cargo.toml index 85442412a2..48c5bae265 100644 --- a/main/Cargo.toml +++ b/main/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "main" -version = "0.3.3" +version = "0.4.0" publish.workspace = true edition.workspace = true From 3114bae96380980b29055b65d5c2f1702cab7531 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=83=A1=E9=A3=9E?= <1835698775@qq.com> Date: Fri, 8 May 2026 21:01:01 +0800 Subject: [PATCH 26/45] =?UTF-8?q?feat:=20=E4=BF=AE=E6=94=B9=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index ed2e23b43c..16f8b00db4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6049,7 +6049,7 @@ checksum = "670fdfda89751bc4a84ac13eaa63e205cf0fd22b4c9a5fbfa085b63c1f1d3a30" [[package]] name = "main" -version = "0.3.3" +version = "0.4.0" dependencies = [ "anyhow", "base64 0.22.1", From df68274a4ad5d4ed68dbbc79445f3252a526b281 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=83=A1=E9=A3=9E?= <1835698775@qq.com> Date: Sat, 9 May 2026 14:25:11 +0800 Subject: [PATCH 27/45] =?UTF-8?q?feat(logging):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E6=97=A5=E5=BF=97=E6=96=87=E4=BB=B6=E8=B7=AF=E5=BE=84=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E5=8F=8A=E6=96=87=E4=BB=B6=E5=86=99=E5=85=A5=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增日志相关国际化文案,包括日志组标题与日志文件路径说明 - 增加配置项支持自定义日志文件保存路径,默认为配置目录下logs文件夹 - 实现日志文件初始化逻辑,支持自动创建父目录及文件权限设置(Unix系统权限为600) - 日志初始化失败时输出错误日志,并降级为控制台打印 - 使用tracing-appender实现异步非阻塞日志写入文件功能 - 添加单元测试覆盖日志路径解析及文件写入行为,保证功能稳定 - 在设置界面新增日志文件路径配置项,支持用户输入自定义路径并保存 --- Cargo.lock | 20 ++++++ main/Cargo.toml | 1 + main/locales/main.yml | 14 +++++ main/src/onetcli_app.rs | 135 ++++++++++++++++++++++++++++++++++++++-- main/src/setting_tab.rs | 24 +++++++ 5 files changed, 188 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 16f8b00db4..5e602db821 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6080,6 +6080,7 @@ dependencies = [ "terminal_view", "tokio", "tracing", + "tracing-appender", "tracing-subscriber", "winresource", "zip 2.4.2", @@ -10578,6 +10579,12 @@ dependencies = [ "zeno", ] +[[package]] +name = "symlink" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" + [[package]] name = "syn" version = "1.0.109" @@ -11430,6 +11437,19 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-appender" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" +dependencies = [ + "crossbeam-channel", + "symlink", + "thiserror 2.0.18", + "time", + "tracing-subscriber", +] + [[package]] name = "tracing-attributes" version = "0.1.31" diff --git a/main/Cargo.toml b/main/Cargo.toml index 48c5bae265..afe74cd8cb 100644 --- a/main/Cargo.toml +++ b/main/Cargo.toml @@ -19,6 +19,7 @@ semver = { workspace = true } tokio = { workspace = true } tracing = { workspace = true } tracing-subscriber = { version = "0.3", features = ["env-filter"] } +tracing-appender = "0.2" rust-i18n = { workspace = true } base64 = { workspace = true } dirs = {workspace = true} diff --git a/main/locales/main.yml b/main/locales/main.yml index f2aff6f6fb..ab7de3ab15 100644 --- a/main/locales/main.yml +++ b/main/locales/main.yml @@ -1201,6 +1201,20 @@ Settings: zh-CN: 根据首条消息由 AI 生成会话标题 zh-HK: 根據首條消息由 AI 生成會話標題 + Log: + group_title: + en: Log + zh-CN: 日志 + zh-HK: 日誌 + file_path: + en: Log File Path + zh-CN: 日志保存路径 + zh-HK: 日誌保存路徑 + file_path_desc: + en: Leave empty to write logs to the default config directory logs folder. Restart the app after changing this path. + zh-CN: 留空时写入默认配置目录下的 logs 文件夹,修改后重启应用生效。 + zh-HK: 留空時寫入默認配置目錄下的 logs 文件夾,修改後重啟應用生效。 + Update: group_title: en: Update diff --git a/main/src/onetcli_app.rs b/main/src/onetcli_app.rs index f53ef2f928..9cee93b7ba 100644 --- a/main/src/onetcli_app.rs +++ b/main/src/onetcli_app.rs @@ -145,12 +145,16 @@ use gpui_component::dock::{ClosePanel, ToggleZoom}; use gpui_component::{ActiveTheme, Icon, IconName, Root, Sizable, h_flex, v_flex}; use one_core::llm::manager::GlobalProviderState; use one_core::storage::ActiveConnections; +use one_core::storage::manager::get_config_dir; use one_core::tab_container::{ TabContainer, TabContainerEvent, TabContainerState, TabContentRegistry, TabItem, }; use one_core::tab_persistence::{load_tab_state, save_tab_state, schedule_save}; use one_core::utils::debouncer::Debouncer; use one_core::{PendingChangeLevel, RunningKind, RunningState}; +#[cfg(unix)] +use std::os::unix::fs::OpenOptionsExt; +use std::path::{Path, PathBuf}; use rust_i18n::t; use terminal_view::with_recovery_snapshot_overrides; use tracing_subscriber::layer::SubscriberExt; @@ -632,16 +636,70 @@ fn request_app_close_without_window(cx: &mut App) -> bool { } } -pub fn init(cx: &mut App) { - // 从 RUST_LOG 环境变量读取日志级别,默认 info +fn init_tracing(settings: &AppSettings) { let env_filter = tracing_subscriber::EnvFilter::try_from_default_env() .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")); - tracing_subscriber::registry() - .with(tracing_subscriber::fmt::layer()) - .with(env_filter) - .init(); + match configured_log_file_path(&settings.log_file_path) { + Ok(log_file_path) => match log_file_appender(&log_file_path) { + Ok(file_appender) => { + let (non_blocking, guard) = tracing_appender::non_blocking(file_appender); + Box::leak(Box::new(guard)); + tracing_subscriber::registry() + .with(tracing_subscriber::fmt::layer()) + .with(tracing_subscriber::fmt::layer().with_writer(non_blocking)) + .with(env_filter) + .init(); + } + Err(err) => { + tracing_subscriber::registry() + .with(tracing_subscriber::fmt::layer()) + .with(env_filter) + .init(); + tracing::error!(path = %log_file_path.display(), error = %err, "日志文件初始化失败"); + } + }, + Err(err) => { + tracing_subscriber::registry() + .with(tracing_subscriber::fmt::layer()) + .with(env_filter) + .init(); + tracing::error!(error = %err, "默认日志目录初始化失败"); + } + } +} + +fn configured_log_file_path(value: &str) -> anyhow::Result { + let trimmed = value.trim(); + if trimmed.is_empty() { + Ok(default_log_file_path()?) + } else { + Ok(PathBuf::from(trimmed)) + } +} + +fn default_log_file_path() -> anyhow::Result { + Ok(get_config_dir()?.join("logs").join("onetcli.log")) +} + +fn log_file_appender(path: &Path) -> std::io::Result { + if let Some(parent) = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + std::fs::create_dir_all(parent)?; + } + + let mut options = std::fs::OpenOptions::new(); + options.create(true).append(true); + #[cfg(unix)] + options.mode(0o600); + options.open(path) +} + +pub fn init(cx: &mut App) { let settings = AppSettings::load(); + init_tracing(&settings); let http_client = build_app_http_client(&settings.global_proxy).expect("HTTP 客户端初始化失败"); cx.set_http_client(http_client); gpui_component::init(cx); @@ -1400,6 +1458,71 @@ impl OnetCliApp { } } +#[cfg(test)] +mod tests { + use super::{configured_log_file_path, default_log_file_path, log_file_appender}; + use std::io::Write; + + #[test] + fn configured_log_file_path_uses_default_for_empty_value() { + let default_path = default_log_file_path().expect("应返回默认日志路径"); + + assert_eq!(configured_log_file_path("").unwrap(), default_path); + assert_eq!(configured_log_file_path(" ").unwrap(), default_path); + } + + #[test] + fn configured_log_file_path_trims_value() { + let path = configured_log_file_path(" /tmp/onetcli.log ").expect("应返回日志路径"); + assert_eq!(path, std::path::PathBuf::from("/tmp/onetcli.log")); + } + + #[test] + fn log_file_appender_creates_parent_directories_and_appends() { + let path = std::env::temp_dir() + .join(format!("onetcli-log-test-{}", std::process::id())) + .join("nested") + .join("app.log"); + + { + let mut file = log_file_appender(&path).expect("应创建日志文件"); + writeln!(file, "first").expect("应写入第一行"); + } + { + let mut file = log_file_appender(&path).expect("应重新打开日志文件"); + writeln!(file, "second").expect("应追加第二行"); + } + + let content = std::fs::read_to_string(&path).expect("应读取日志文件"); + assert_eq!(content, "first\nsecond\n"); + + let _ = std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()); + } + + #[cfg(unix)] + #[test] + fn log_file_appender_creates_private_file() { + use std::os::unix::fs::PermissionsExt; + + let path = std::env::temp_dir() + .join(format!( + "onetcli-log-permission-test-{}", + std::process::id() + )) + .join("app.log"); + let _file = log_file_appender(&path).expect("应创建日志文件"); + + let mode = std::fs::metadata(&path) + .expect("应读取日志文件元数据") + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o600); + + let _ = std::fs::remove_dir_all(path.parent().unwrap()); + } +} + impl Render for OnetCliApp { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let next_window_title = { diff --git a/main/src/setting_tab.rs b/main/src/setting_tab.rs index ba368d8afb..017cf1d16d 100644 --- a/main/src/setting_tab.rs +++ b/main/src/setting_tab.rs @@ -495,6 +495,8 @@ pub struct AppSettings { pub terminal_confirm_multiline_paste: bool, #[serde(default = "default_true")] pub terminal_confirm_high_risk_command: bool, + #[serde(default)] + pub log_file_path: String, #[serde(default = "default_true")] pub restore_connections_on_startup: bool, #[serde(default = "default_true")] @@ -834,6 +836,7 @@ impl Default for AppSettings { terminal_confirm_high_risk_command: default_true(), restore_connections_on_startup: default_true(), restore_session_content: default_true(), + log_file_path: String::new(), auto_update: true, sync_server_url: String::new(), sync_backend_type: default_sync_backend_type(), @@ -2679,6 +2682,27 @@ impl SettingsPanel { t!("Settings.General.Database.ai_auto_title_desc").to_string(), ), ]), + SettingGroup::new() + .title(t!("Settings.General.Log.group_title")) + .item( + SettingItem::new( + t!("Settings.General.Log.file_path"), + SettingField::input( + |cx: &App| { + SharedString::from( + AppSettings::global(cx).log_file_path.clone(), + ) + }, + |val: SharedString, cx: &mut App| { + let settings = AppSettings::global_mut(cx); + settings.log_file_path = val.trim().to_string(); + settings.save(); + }, + ) + .default_value(SharedString::from("")), + ) + .description(t!("Settings.General.Log.file_path_desc").to_string()), + ), SettingGroup::new() .title(t!("Settings.General.Update.group_title")) .items(vec![ From 53ad10b3bec7931161766a6789678e251954341a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=83=A1=E9=A3=9E?= <1835698775@qq.com> Date: Sat, 9 May 2026 16:47:27 +0800 Subject: [PATCH 28/45] =?UTF-8?q?feat(terminal):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E7=BB=88=E7=AB=AF=E9=BC=A0=E6=A0=87=E6=BB=9A=E8=BD=AE=20SGR=20?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F=E6=94=AF=E6=8C=81=E5=8F=8A=20Vim=20=E9=BC=A0?= =?UTF-8?q?=E6=A0=87=E5=A2=9E=E5=BC=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 sgr_mouse_wheel_report 函数,为鼠标滚轮提供 SGR 报告支持 - 终端视图处理鼠标滚轮时,根据模式发送正确的 SGR 控制序列 - 在 shell_integration.sh 中增加 Vim 和 Neovim 鼠标支持的包装函数 - 通过 shell_integration.rs 添加多项单元测试,确保 Vim 包装函数行为正确 - 支持 ONETCLI_VIM_MOUSE 环境变量禁用 Vim 鼠标功能 - 单元测试覆盖 vim/neovim 包装函数参数传递和别名、函数保护机制 --- crates/terminal/src/shell_integration.rs | 225 +++++++++++++++++++++++ crates/terminal/src/shell_integration.sh | 39 ++++ crates/terminal_view/src/view.rs | 68 +++++-- 3 files changed, 314 insertions(+), 18 deletions(-) diff --git a/crates/terminal/src/shell_integration.rs b/crates/terminal/src/shell_integration.rs index d16f44a965..0f11ba7fde 100644 --- a/crates/terminal/src/shell_integration.rs +++ b/crates/terminal/src/shell_integration.rs @@ -9,6 +9,8 @@ pub(crate) fn embedded_shell_integration_script() -> String { #[cfg(test)] mod tests { use super::{embedded_shell_integration_script, normalized_shell_integration_script}; + #[cfg(unix)] + use std::{fs, os::unix::fs::PermissionsExt, process::Command}; #[test] fn normalized_shell_integration_script_converts_crlf_to_lf() { @@ -26,4 +28,227 @@ mod tests { "嵌入式 shell integration 脚本不应保留 CR,避免远端 shell 解析失败" ); } + + #[test] + fn embedded_shell_integration_script_enables_vim_mouse() { + let script = embedded_shell_integration_script(); + assert!(script.contains("--cmd 'set mouse=a'")); + assert!(script.contains("--cmd 'nnoremap gkzz'")); + assert!(script.contains("--cmd 'nnoremap gjzz'")); + assert!(script.contains("function vim {")); + assert!(script.contains("__onetcli_can_wrap_command vim")); + } + + #[cfg(unix)] + #[test] + fn bash_vim_wrapper_preserves_args() { + assert_vim_wrapper_preserves_args("bash"); + } + + #[cfg(unix)] + #[test] + fn zsh_vim_wrapper_preserves_args() { + if !shell_available("zsh") { + return; + } + assert_vim_wrapper_preserves_args("zsh"); + } + + #[cfg(unix)] + #[test] + fn bash_vim_wrapper_does_not_override_alias() { + assert_vim_wrapper_does_not_override_alias("bash"); + } + + #[cfg(unix)] + #[test] + fn zsh_vim_wrapper_does_not_override_alias() { + if !shell_available("zsh") { + return; + } + assert_vim_wrapper_does_not_override_alias("zsh"); + } + + #[cfg(unix)] + #[test] + fn bash_vim_wrapper_does_not_override_function() { + assert_vim_wrapper_does_not_override_function("bash"); + } + + #[cfg(unix)] + #[test] + fn zsh_vim_wrapper_does_not_override_function() { + if !shell_available("zsh") { + return; + } + assert_vim_wrapper_does_not_override_function("zsh"); + } + + #[cfg(unix)] + #[test] + fn bash_vim_mouse_can_be_disabled() { + assert_vim_mouse_can_be_disabled("bash"); + } + + #[cfg(unix)] + #[test] + fn zsh_vim_mouse_can_be_disabled() { + if !shell_available("zsh") { + return; + } + assert_vim_mouse_can_be_disabled("zsh"); + } + + #[cfg(unix)] + #[test] + fn bash_nvim_wrapper_preserves_args() { + assert_nvim_wrapper_preserves_args("bash"); + } + + #[cfg(unix)] + #[test] + fn zsh_nvim_wrapper_preserves_args() { + if !shell_available("zsh") { + return; + } + assert_nvim_wrapper_preserves_args("zsh"); + } + + #[cfg(unix)] + fn assert_vim_wrapper_preserves_args(shell: &str) { + let output = run_interactive_shell( + shell, + "source \"$ONETCLI_TEST_SCRIPT\"\nvim 'a b.txt' -- '--weird;$HOME'", + ); + + assert!(output.status.success()); + assert_eq!( + strip_shell_integration_osc(&String::from_utf8_lossy(&output.stdout)), + "--cmd\nset mouse=a\n--cmd\nnnoremap gkzz\n--cmd\nnnoremap gjzz\n--cmd\ninoremap gkzz\n--cmd\ninoremap gjzz\na b.txt\n--\n--weird;$HOME\n" + ); + } + + #[cfg(unix)] + fn assert_vim_wrapper_does_not_override_alias(shell: &str) { + let output = run_interactive_shell( + shell, + "shopt -s expand_aliases 2>/dev/null || true\nalias vim='echo alias-safe'\nsource \"$ONETCLI_TEST_SCRIPT\"\nvim", + ); + + assert!(output.status.success()); + assert_eq!( + strip_shell_integration_osc(&String::from_utf8_lossy(&output.stdout)), + "alias-safe\n" + ); + } + + #[cfg(unix)] + fn assert_vim_wrapper_does_not_override_function(shell: &str) { + let output = run_interactive_shell( + shell, + "vim() { echo function-safe; }\nsource \"$ONETCLI_TEST_SCRIPT\"\nvim", + ); + + assert!(output.status.success()); + assert_eq!( + strip_shell_integration_osc(&String::from_utf8_lossy(&output.stdout)), + "function-safe\n" + ); + } + + #[cfg(unix)] + fn assert_vim_mouse_can_be_disabled(shell: &str) { + let output = run_interactive_shell( + shell, + "source \"$ONETCLI_TEST_SCRIPT\"\nONETCLI_VIM_MOUSE=0 vim file.txt", + ); + + assert!(output.status.success()); + assert_eq!( + strip_shell_integration_osc(&String::from_utf8_lossy(&output.stdout)), + "file.txt\n" + ); + } + + #[cfg(unix)] + fn assert_nvim_wrapper_preserves_args(shell: &str) { + let output = run_interactive_shell(shell, "source \"$ONETCLI_TEST_SCRIPT\"\nnvim file.txt"); + + assert!(output.status.success()); + assert_eq!( + strip_shell_integration_osc(&String::from_utf8_lossy(&output.stdout)), + "--cmd\nset mouse=a\n--cmd\nnnoremap gkzz\n--cmd\nnnoremap gjzz\n--cmd\ninoremap gkzz\n--cmd\ninoremap gjzz\nfile.txt\n" + ); + } + + #[cfg(unix)] + fn shell_available(shell: &str) -> bool { + let available = Command::new(shell).arg("--version").output().is_ok(); + if !available { + eprintln!("跳过 {shell} 行为测试:当前环境未安装该 shell"); + } + available + } + + #[cfg(unix)] + fn run_interactive_shell(shell: &str, command: &str) -> std::process::Output { + let temp_dir = std::env::temp_dir().join(format!( + "onetcli-shell-integration-test-{}-{:?}", + std::process::id(), + std::thread::current().id() + )); + let bin_dir = temp_dir.join("bin"); + let home_dir = temp_dir.join("home"); + let zdot_dir = temp_dir.join("zsh"); + fs::create_dir_all(&bin_dir).expect("应创建测试 bin 目录"); + fs::create_dir_all(&home_dir).expect("应创建测试 HOME 目录"); + fs::create_dir_all(&zdot_dir).expect("应创建测试 ZDOTDIR 目录"); + + let script_path = temp_dir.join("shell_integration.sh"); + fs::write(&script_path, embedded_shell_integration_script()).expect("应写入集成脚本"); + + write_fake_editor(&bin_dir.join("vim")); + write_fake_editor(&bin_dir.join("nvim")); + + let command_path = temp_dir.join("command.sh"); + fs::write(&command_path, command).expect("应写入测试命令脚本"); + + let path = format!( + "{}:{}", + bin_dir.display(), + std::env::var("PATH").unwrap_or_default() + ); + let output = Command::new(shell) + .arg("-i") + .arg(&command_path) + .env("PATH", path) + .env("HOME", &home_dir) + .env("ZDOTDIR", &zdot_dir) + .env("ONETCLI_TEST_SCRIPT", &script_path) + .output() + .expect("应执行 shell 行为测试"); + + let _ = fs::remove_dir_all(&temp_dir); + output + } + + #[cfg(unix)] + fn write_fake_editor(path: &std::path::Path) { + fs::write( + path, + "#!/bin/sh\nfor arg in \"$@\"; do printf '%s\\n' \"$arg\"; done\n", + ) + .expect("应写入 fake editor"); + fs::set_permissions(path, fs::Permissions::from_mode(0o755)) + .expect("应设置 fake editor 可执行权限"); + } + + #[cfg(unix)] + fn strip_shell_integration_osc(output: &str) -> String { + output + .replace("\u{1b}]133;C\u{7}", "") + .replace("\u{1b}]133;D;0\u{7}", "") + .replace("\u{1b}]133;A\u{7}", "") + .replace("\u{1b}]133;B\u{7}", "") + } } diff --git a/crates/terminal/src/shell_integration.sh b/crates/terminal/src/shell_integration.sh index dadb622bf2..2559bf1738 100644 --- a/crates/terminal/src/shell_integration.sh +++ b/crates/terminal/src/shell_integration.sh @@ -50,6 +50,45 @@ __onetcli_last_history_command() { fi } +__onetcli_enable_vim_mouse() { + local editor="$1" + shift + if [[ "${ONETCLI_VIM_MOUSE:-1}" == "0" ]]; then + command "$editor" "$@" + return + fi + command "$editor" \ + --cmd 'set mouse=a' \ + --cmd 'nnoremap gkzz' \ + --cmd 'nnoremap gjzz' \ + --cmd 'inoremap gkzz' \ + --cmd 'inoremap gjzz' \ + "$@" +} + +__onetcli_can_wrap_command() { + local name="$1" + if [[ -n "${ZSH_VERSION:-}" ]]; then + local command_type + command_type="$(whence -w "$name" 2>/dev/null)" + [[ "$command_type" == "$name: command" || "$command_type" == "$name: hashed" ]] + else + [[ "$(type -t "$name" 2>/dev/null)" == "file" ]] + fi +} + +if __onetcli_can_wrap_command vim; then + function vim { + __onetcli_enable_vim_mouse vim "$@" + } +fi + +if __onetcli_can_wrap_command nvim; then + function nvim { + __onetcli_enable_vim_mouse nvim "$@" + } +fi + __onetcli_emit_recorded_command() { local command_text encoded command_text="$(__onetcli_last_history_command)" diff --git a/crates/terminal_view/src/view.rs b/crates/terminal_view/src/view.rs index 4939ba934f..88f68c20e6 100644 --- a/crates/terminal_view/src/view.rs +++ b/crates/terminal_view/src/view.rs @@ -248,17 +248,13 @@ fn take_whole_scroll_lines(scroll_lines_accumulated: &mut f32) -> i32 { lines } -fn alt_screen_scroll_arrow(lines: i32, app_cursor: bool) -> Option<&'static str> { +fn sgr_mouse_wheel_report(lines: i32, col: usize, row: usize) -> Option { if lines == 0 { return None; } - Some(match (lines > 0, app_cursor) { - (true, true) => "\x1bOA", // Up, application mode - (true, false) => "\x1b[A", // Up, normal mode - (false, true) => "\x1bOB", // Down, application mode - (false, false) => "\x1b[B", // Down, normal mode - }) + let button = if lines > 0 { 64 } else { 65 }; + Some(format!("\x1b[<{};{};{}M", button, col + 1, row + 1)) } fn should_scroll_to_bottom_on_user_input( @@ -3407,11 +3403,14 @@ impl TerminalView { } if mode.contains(TermMode::ALT_SCREEN) { - // ALT_SCREEN(vim、less 等):累计到整行后再转为上下箭头,避免放大小幅滚轮输入 - if let Some(arrow) = alt_screen_scroll_arrow(lines, mode.contains(TermMode::APP_CURSOR)) - { - for _ in 0..lines.abs() { - self.write_to_pty(arrow.as_bytes().to_vec(), cx); + if mode.contains(TermMode::SGR_MOUSE) && mode.intersects(TermMode::MOUSE_MODE) { + let point = self.pixel_to_point(event.position, self.terminal_bounds, cx); + if let Some(report) = + sgr_mouse_wheel_report(lines, point.column.0, point.line.0 as usize) + { + for _ in 0..lines.unsigned_abs() { + self.write_to_pty(report.as_bytes().to_vec(), cx); + } } } return; @@ -4473,9 +4472,9 @@ mod tests { #[cfg(target_os = "macos")] use super::TerminalView; use super::{ - alt_screen_scroll_arrow, detect_unbracketed_paste_hazard, has_trailing_line_continuation, + UnbracketedPasteHazard, alt_screen_scroll_arrow, detect_unbracketed_paste_hazard, has_trailing_line_continuation, has_unterminated_shell_quote, history_prompt_available, history_prompt_dropdown_origin, - history_prompt_overlay_bounds, multiline_non_empty_line_count, preserve_theme_typography, + history_prompt_overlay_bounds, multiline_non_empty_line_count, preserve_theme_typography, sgr_mouse_wheel_report, should_defer_inline_history_prompt_input_to_text_system, should_dismiss_history_prompt_for_keystroke, should_dismiss_history_prompt_for_mouse, should_dismiss_history_prompt_for_scroll, should_reset_history_prompt_for_terminal_event, @@ -4521,16 +4520,49 @@ mod tests { } #[test] +<<<<<<< HEAD fn alt_screen_scroll_arrow_maps_positive_lines_to_up() { assert_eq!(alt_screen_scroll_arrow(1, false), Some("\x1b[A")); assert_eq!(alt_screen_scroll_arrow(1, true), Some("\x1bOA")); +======= + fn terminal_keybindings_bind_ctrl_zero_to_reset_font() { + let source = include_str!("view.rs"); + let binding = format!("{}{}", r#"KeyBinding::new("ctrl-0", "#, "ResetFont"); + + assert!(source.contains(&binding)); + } + + #[test] + fn terminal_reset_font_size_is_fifteen() { + assert_eq!(super::TERMINAL_RESET_FONT_SIZE, 15.0); + } + + #[test] + fn terminal_theme_source_does_not_define_font_settings() { + let source = include_str!("theme.rs"); + + assert!(!source.contains("pub font_size")); + assert!(!source.contains("pub font_family")); + assert!(!source.contains("pub font_fallbacks")); + assert!(!source.contains("pub line_height_scale")); } #[test] - fn alt_screen_scroll_arrow_maps_negative_lines_to_down() { - assert_eq!(alt_screen_scroll_arrow(-1, false), Some("\x1b[B")); - assert_eq!(alt_screen_scroll_arrow(-1, true), Some("\x1bOB")); - assert_eq!(alt_screen_scroll_arrow(0, false), None); + fn sgr_mouse_wheel_report_maps_positive_lines_to_wheel_up() { + assert_eq!( + sgr_mouse_wheel_report(1, 4, 2).as_deref(), + Some("\x1b[<64;5;3M") + ); +>>>>>>> 690937ef (feat(terminal): 添加终端鼠标滚轮 SGR 模式支持及 Vim 鼠标增强) + } + + #[test] + fn sgr_mouse_wheel_report_maps_negative_lines_to_wheel_down() { + assert_eq!( + sgr_mouse_wheel_report(-1, 4, 2).as_deref(), + Some("\x1b[<65;5;3M") + ); + assert_eq!(sgr_mouse_wheel_report(0, 4, 2), None); } #[test] From 062b6f7c737159e98aed93efcc350a42f1c80f8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=83=A1=E9=A3=9E?= <1835698775@qq.com> Date: Sat, 9 May 2026 19:29:00 +0800 Subject: [PATCH 29/45] =?UTF-8?q?feat(ui):=20=E6=B7=BB=E5=8A=A0=20TitleBar?= =?UTF-8?q?=20=E7=BB=84=E4=BB=B6=E6=8F=90=E5=8D=87=E7=95=8C=E9=9D=A2?= =?UTF-8?q?=E7=BB=93=E6=9E=84=E4=B8=80=E8=87=B4=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 sql_dump_view、sql_run_view、table_export_view 和 table_import_view 中引入 TitleBar 组件 - 将视图内容包装在带有 TitleBar 的垂直布局容器中 - 调整布局高度以适应 TitleBar 的加入 - 优化界面整体视觉层次感和用户体验 --- crates/db_view/src/import_export/sql_dump_view.rs | 13 +++++++++---- crates/db_view/src/import_export/sql_run_view.rs | 13 +++++++++---- .../db_view/src/import_export/table_export_view.rs | 13 +++++++++---- .../db_view/src/import_export/table_import_view.rs | 13 +++++++++---- 4 files changed, 36 insertions(+), 16 deletions(-) diff --git a/crates/db_view/src/import_export/sql_dump_view.rs b/crates/db_view/src/import_export/sql_dump_view.rs index 3e78302e58..1bba7e851c 100644 --- a/crates/db_view/src/import_export/sql_dump_view.rs +++ b/crates/db_view/src/import_export/sql_dump_view.rs @@ -3,7 +3,7 @@ use gpui::{ IntoElement, ParentElement, Render, Styled, Window, div, prelude::FluentBuilder, px, }; use gpui_component::{ - ActiveTheme, VirtualListScrollHandle, + ActiveTheme, TitleBar, VirtualListScrollHandle, button::{Button, ButtonVariants as _}, h_flex, v_flex, v_virtual_list, }; @@ -506,12 +506,11 @@ impl Render for SqlDumpView { let elapsed = self.elapsed_time.read(cx).clone(); let logs = self.logs.read(cx).clone(); - v_flex() + let content = v_flex() .w_full() .h(px(450.0)) .gap_3() .p_4() - .pt_8() .child( v_flex() .gap_1() @@ -708,6 +707,12 @@ impl Render for SqlDumpView { }), ) }), - ) + ); + + v_flex() + .w_full() + .h(px(510.0)) + .child(TitleBar::new()) + .child(content) } } diff --git a/crates/db_view/src/import_export/sql_run_view.rs b/crates/db_view/src/import_export/sql_run_view.rs index f956d944b0..e478239b99 100644 --- a/crates/db_view/src/import_export/sql_run_view.rs +++ b/crates/db_view/src/import_export/sql_run_view.rs @@ -9,7 +9,7 @@ use gpui::{ prelude::FluentBuilder, px, }; use gpui_component::{ - ActiveTheme, Disableable, Sizable, VirtualListScrollHandle, + ActiveTheme, Disableable, Sizable, TitleBar, VirtualListScrollHandle, button::{Button, ButtonVariants as _}, h_flex, input::{Input, InputState}, @@ -453,12 +453,11 @@ impl Render for SqlRunView { let elapsed = self.elapsed_time.read(cx).clone(); let logs = self.logs.read(cx).clone(); - v_flex() + let content = v_flex() .w_full() .h(px(500.0)) .gap_3() .p_4() - .pt_8() .child( h_flex() .gap_2() @@ -687,6 +686,12 @@ impl Render for SqlRunView { }), ) }), - ) + ); + + v_flex() + .w_full() + .h(px(520.0)) + .child(TitleBar::new()) + .child(content) } } diff --git a/crates/db_view/src/import_export/table_export_view.rs b/crates/db_view/src/import_export/table_export_view.rs index 64bc01c8e5..1042ec262b 100644 --- a/crates/db_view/src/import_export/table_export_view.rs +++ b/crates/db_view/src/import_export/table_export_view.rs @@ -9,7 +9,7 @@ use gpui::{ px, }; use gpui_component::{ - ActiveTheme, Disableable, IconName, IndexPath, Sizable, VirtualListScrollHandle, + ActiveTheme, Disableable, IconName, IndexPath, Sizable, TitleBar, VirtualListScrollHandle, button::{Button, ButtonVariants as _}, checkbox::Checkbox, h_flex, @@ -876,12 +876,11 @@ impl Render for DataExportView { let logs = self.logs.read(cx).clone(); let current_step = self.current_step; - v_flex() + let content = v_flex() .w_full() .h(px(540.0)) .gap_2() .p_4() - .pt_8() .child( div() .text_sm() @@ -1351,6 +1350,12 @@ impl Render for DataExportView { }) ) }), - ) + ); + + v_flex() + .w_full() + .h(px(600.0)) + .child(TitleBar::new()) + .child(content) } } diff --git a/crates/db_view/src/import_export/table_import_view.rs b/crates/db_view/src/import_export/table_import_view.rs index 288aac4c69..912c54edad 100644 --- a/crates/db_view/src/import_export/table_import_view.rs +++ b/crates/db_view/src/import_export/table_import_view.rs @@ -7,7 +7,7 @@ use gpui::{ Window, div, prelude::FluentBuilder, px, }; use gpui_component::{ - ActiveTheme, Disableable, IconName, IndexPath, VirtualListScrollHandle, + ActiveTheme, Disableable, IconName, IndexPath, TitleBar, VirtualListScrollHandle, button::{Button, ButtonVariants as _}, h_flex, input::{Input, InputState}, @@ -792,12 +792,11 @@ impl Render for TableImportView { let current_step = self.current_step; let validation_error = self.validation_error.read(cx).clone(); - v_flex() + let content = v_flex() .w_full() .h(px(540.0)) .gap_3() .p_4() - .pt_8() .child( div() .text_sm() @@ -1371,6 +1370,12 @@ impl Render for TableImportView { }, )) }), - ) + ); + + v_flex() + .w_full() + .h(px(600.0)) + .child(TitleBar::new()) + .child(content) } } From 298cb102802150680a653211b547b4b3fce5ba56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=83=A1=E9=A3=9E?= <1835698775@qq.com> Date: Sun, 10 May 2026 10:32:10 +0800 Subject: [PATCH 30/45] =?UTF-8?q?feat(remote=5Ffile=5Feditor):=20=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0=E6=94=AF=E6=8C=81=E5=A4=9A=E6=A0=87=E7=AD=BE=E7=9A=84?= =?UTF-8?q?=E8=BF=9C=E7=A8=8B=E6=96=87=E4=BB=B6=E7=BC=96=E8=BE=91=E5=99=A8?= =?UTF-8?q?=E7=AA=97=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增多标签页管理,支持打开、切换、关闭标签 - 按标签ID和远程路径唯一定位标签页 - 根据关闭的标签调整激活标签索引,实现正确聚焦 - 标签支持保存、加载、显示加载和保存状态 - 增加关闭未保存修改时的确认提示,支持保存或放弃关闭 - 完善标签软换行、语言模式、文件大小显示功能 - 实现关闭窗口时所有标签的未保存内容处理逻辑 - 主窗口只允许单实例,多次打开文件时复用现有窗口 - 优化窗口标题显示为当前激活标签名 - 本地加入关闭标签按钮,禁用正在保存的标签关闭操作 - 添加相关单元测试覆盖新功能和边界情况 - sftp模块排序相关代码使用sort_by_key简化提高可读性 - 语言配置文件新增“关闭页签”文本支持中文和英文 --- .../locales/remote_file_editor.yml | 4 + crates/remote_file_editor/src/close_guard.rs | 87 ++- .../remote_file_editor/src/editor_window.rs | 737 ++++++++++++++---- crates/remote_file_editor/src/lib.rs | 5 +- crates/sftp/src/russh_impl.rs | 6 +- 5 files changed, 679 insertions(+), 160 deletions(-) diff --git a/crates/remote_file_editor/locales/remote_file_editor.yml b/crates/remote_file_editor/locales/remote_file_editor.yml index 933185e79f..a3f1c3829b 100644 --- a/crates/remote_file_editor/locales/remote_file_editor.yml +++ b/crates/remote_file_editor/locales/remote_file_editor.yml @@ -26,6 +26,10 @@ RemoteFileEditor: en: Soft Wrap zh-CN: 自动换行 zh-HK: 自動換行 + close_tab: + en: Close Tab + zh-CN: 关闭页签 + zh-HK: 關閉頁籤 discard: en: Discard zh-CN: 放弃更改 diff --git a/crates/remote_file_editor/src/close_guard.rs b/crates/remote_file_editor/src/close_guard.rs index 0422298160..1afe57b8d0 100644 --- a/crates/remote_file_editor/src/close_guard.rs +++ b/crates/remote_file_editor/src/close_guard.rs @@ -15,9 +15,42 @@ pub fn decide_close_intercept(is_dirty: bool, prompt_open: bool) -> CloseInterce } } +pub fn find_tab_index(paths: &[String], remote_path: &str) -> Option { + paths.iter().position(|path| path == remote_path) +} + +pub fn active_index_after_open(paths: &[String], remote_path: &str) -> usize { + find_tab_index(paths, remote_path).unwrap_or(paths.len()) +} + +pub fn active_index_after_close( + active_index: usize, + closed_index: usize, + tab_count: usize, +) -> Option { + if tab_count <= 1 || closed_index >= tab_count { + return None; + } + + if closed_index < active_index { + Some(active_index - 1) + } else if closed_index == active_index && active_index >= tab_count - 1 { + Some(active_index - 1) + } else { + Some(active_index) + } +} + +pub fn has_dirty_tabs(dirty_tabs: &[bool]) -> bool { + dirty_tabs.iter().any(|dirty| *dirty) +} + #[cfg(test)] mod tests { - use super::{CloseIntercept, decide_close_intercept}; + use super::{ + CloseIntercept, active_index_after_close, active_index_after_open, decide_close_intercept, + find_tab_index, has_dirty_tabs, + }; #[test] fn allows_close_when_editor_is_clean() { @@ -33,4 +66,56 @@ mod tests { fn ignores_repeated_close_while_prompt_is_open() { assert_eq!(decide_close_intercept(true, true), CloseIntercept::Ignore); } + + #[test] + fn finds_existing_tab_index_by_remote_path() { + let paths = vec!["/tmp/a.txt".to_string(), "/tmp/b.txt".to_string()]; + + assert_eq!(find_tab_index(&paths, "/tmp/b.txt"), Some(1)); + } + + #[test] + fn returns_next_index_for_new_remote_path() { + let paths = vec!["/tmp/a.txt".to_string(), "/tmp/b.txt".to_string()]; + + assert_eq!(active_index_after_open(&paths, "/tmp/c.txt"), 2); + } + + #[test] + fn reuses_existing_index_for_existing_remote_path() { + let paths = vec!["/tmp/a.txt".to_string(), "/tmp/b.txt".to_string()]; + + assert_eq!(active_index_after_open(&paths, "/tmp/a.txt"), 0); + } + + #[test] + fn keeps_active_index_when_closing_tab_after_active_tab() { + assert_eq!(active_index_after_close(0, 2, 3), Some(0)); + } + + #[test] + fn shifts_active_index_left_when_closing_tab_before_active_tab() { + assert_eq!(active_index_after_close(2, 0, 3), Some(1)); + } + + #[test] + fn activates_left_tab_when_closing_last_active_tab() { + assert_eq!(active_index_after_close(2, 2, 3), Some(1)); + } + + #[test] + fn keeps_same_index_when_closing_middle_active_tab_with_right_neighbor() { + assert_eq!(active_index_after_close(1, 1, 3), Some(1)); + } + + #[test] + fn returns_none_when_closing_last_remaining_tab() { + assert_eq!(active_index_after_close(0, 0, 1), None); + } + + #[test] + fn detects_any_dirty_tab() { + assert!(has_dirty_tabs(&[false, true, false])); + assert!(!has_dirty_tabs(&[false, false])); + } } diff --git a/crates/remote_file_editor/src/editor_window.rs b/crates/remote_file_editor/src/editor_window.rs index 6d7c4a77cf..32a008c573 100644 --- a/crates/remote_file_editor/src/editor_window.rs +++ b/crates/remote_file_editor/src/editor_window.rs @@ -2,25 +2,29 @@ use crate::file_policy::{ EditorMode, FilePolicy, MAX_EDITABLE_FILE_SIZE, decode_text_content, determine_file_policy, }; use crate::language::language_for_path; -use crate::{CloseIntercept, decide_close_intercept}; +use crate::{ + CloseIntercept, active_index_after_close, active_index_after_open, decide_close_intercept, +}; use gpui::{ - App, AppContext, Bounds, Context, Entity, InteractiveElement as _, IntoElement, KeyBinding, - ParentElement, PromptLevel, Render, Size as GpuiSize, Styled, Window, WindowBounds, WindowKind, - WindowOptions, actions, div, px, size, + AnyWindowHandle, App, AppContext, Context, Entity, InteractiveElement as _, IntoElement, + KeyBinding, ParentElement, PromptLevel, Render, Styled, WeakEntity, Window, actions, div, px, }; use gpui_component::{ - ActiveTheme as _, Disableable as _, Root, Selectable as _, Sizable as _, Size, TitleBar, - WindowExt, + ActiveTheme as _, Disableable as _, Selectable as _, Sizable as _, Size, TitleBar, WindowExt, button::Button, h_flex, input::{Input, InputEvent, InputState, Search}, notification::Notification, + tab::{Tab, TabBar}, v_flex, }; -use one_core::gpui_tokio::Tokio; +use one_core::{ + gpui_tokio::Tokio, + popup_window::{PopupWindowOptions, open_popup_window}, +}; use rust_i18n::t; use sftp::{RusshSftpClient, SftpClient}; -use std::sync::{Arc, Once}; +use std::sync::{Arc, Mutex as StdMutex, Once, OnceLock}; use tokio::sync::Mutex; actions!(remote_file_editor, [OpenSearch, OpenReplace]); @@ -36,6 +40,13 @@ const REMOTE_EDITOR_REPLACE_SHORTCUT: &str = "cmd-r"; const REMOTE_EDITOR_REPLACE_SHORTCUT: &str = "ctrl-r"; static REMOTE_EDITOR_KEYBINDINGS_INIT: Once = Once::new(); +static REMOTE_EDITOR_WINDOW: OnceLock>> = OnceLock::new(); + +#[derive(Clone)] +struct RemoteEditorWindowRef { + window: AnyWindowHandle, + view: WeakEntity, +} pub fn open_remote_file_editor( remote_path: String, @@ -43,44 +54,28 @@ pub fn open_remote_file_editor( cx: &mut Context, ) { init_keybindings(cx); - let title = t!( - "RemoteFileEditor.title", - name = display_name_from_path(&remote_path) - ) - .to_string(); cx.spawn(async move |_this, cx| { - let title = title.clone(); let remote_path_for_log = remote_path.clone(); let result = cx.update(|cx| { - let mut window_size = size(px(960.0), px(720.0)); - if let Some(display) = cx.primary_display() { - let display_size = display.bounds().size; - window_size.width = window_size.width.min(display_size.width * 0.85); - window_size.height = window_size.height.min(display_size.height * 0.85); + if open_in_existing_window(remote_path.clone(), cx)? { + return Ok(()); } - let window_bounds = Bounds::centered(None, window_size, cx); - let window_opts = WindowOptions { - window_bounds: Some(WindowBounds::Windowed(window_bounds)), - titlebar: Some(TitleBar::title_bar_options()), - window_min_size: Some(GpuiSize { - width: px(640.0), - height: px(480.0), - }), - kind: WindowKind::Normal, - #[cfg(target_os = "linux")] - window_background: gpui::WindowBackgroundAppearance::Transparent, - #[cfg(target_os = "linux")] - window_decorations: Some(gpui::WindowDecorations::Client), - ..Default::default() - }; - cx.open_window(window_opts, move |window, cx| { - window.activate_window(); - window.set_window_title(&title); - let view = - cx.new(|cx| RemoteFileEditorWindow::new(remote_path, client, window, cx)); - cx.new(|cx| Root::new(view, window, cx)) - })?; + let title = editor_window_title(&remote_path); + open_popup_window( + PopupWindowOptions::new(title).size(960.0, 720.0).min_width(640.0).min_height(480.0), + move |window, cx| { + let view = cx.new(|cx| { + RemoteFileEditorWindow::new(remote_path, client, window, cx) + }); + set_editor_window(RemoteEditorWindowRef { + window: window.window_handle(), + view: view.downgrade(), + }); + view + }, + cx, + ); Ok::<_, anyhow::Error>(()) }); @@ -92,6 +87,50 @@ pub fn open_remote_file_editor( .detach(); } +fn open_in_existing_window(remote_path: String, cx: &mut App) -> anyhow::Result { + let Some(editor_window) = current_editor_window() else { + return Ok(false); + }; + + let result = cx.update_window(editor_window.window, |_, window, cx| { + window.activate_window(); + editor_window + .view + .update(cx, |this, cx| { + this.open_or_focus_tab(remote_path, window, cx); + }) + .is_ok() + }); + + match result { + Ok(true) => Ok(true), + Ok(false) | Err(_) => { + clear_editor_window(); + Ok(false) + } + } +} + +fn editor_window_slot() -> &'static StdMutex> { + REMOTE_EDITOR_WINDOW.get_or_init(|| StdMutex::new(None)) +} + +fn current_editor_window() -> Option { + editor_window_slot().lock().ok()?.clone() +} + +fn set_editor_window(window: RemoteEditorWindowRef) { + if let Ok(mut slot) = editor_window_slot().lock() { + *slot = Some(window); + } +} + +fn clear_editor_window() { + if let Ok(mut slot) = editor_window_slot().lock() { + *slot = None; + } +} + fn init_keybindings(cx: &mut App) { REMOTE_EDITOR_KEYBINDINGS_INIT.call_once(|| { cx.bind_keys([ @@ -117,6 +156,14 @@ fn replace_shortcut() -> &'static str { REMOTE_EDITOR_REPLACE_SHORTCUT } +fn editor_window_title(remote_path: &str) -> String { + t!( + "RemoteFileEditor.title", + name = display_name_from_path(remote_path) + ) + .to_string() +} + struct LoadedFile { text: String, policy: FilePolicy, @@ -124,10 +171,16 @@ struct LoadedFile { language: String, } -struct RemoteFileEditorWindow { +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum PendingCloseAction { + Window, + Tab(usize), +} + +struct RemoteEditorTab { + id: u64, remote_path: String, display_name: String, - client: Arc>, editor: Option>, subscriptions: Vec, saved_text: String, @@ -136,23 +189,16 @@ struct RemoteFileEditorWindow { loading: bool, saving: bool, soft_wrap: bool, - close_prompt_open: bool, - close_after_save: bool, status_message: String, load_error: Option, } -impl RemoteFileEditorWindow { - fn new( - remote_path: String, - client: Arc>, - window: &mut Window, - cx: &mut Context, - ) -> Self { - let mut this = Self { +impl RemoteEditorTab { + fn new(id: u64, remote_path: String) -> Self { + Self { + id, display_name: display_name_from_path(&remote_path), remote_path, - client, editor: None, subscriptions: Vec::new(), saved_text: String::new(), @@ -164,13 +210,54 @@ impl RemoteFileEditorWindow { loading: true, saving: false, soft_wrap: false, - close_prompt_open: false, - close_after_save: false, status_message: t!("RemoteFileEditor.status.loading").to_string(), load_error: None, + } + } + + fn is_dirty(&self, cx: &App) -> bool { + self.editor + .as_ref() + .map(|editor| editor.read(cx).text() != self.saved_text.as_str()) + .unwrap_or(false) + } + + fn policy_label(&self) -> String { + match self.policy.mode { + EditorMode::Code => t!("RemoteFileEditor.policy.code").to_string(), + EditorMode::PlainText => t!("RemoteFileEditor.policy.plain_text").to_string(), + } + } +} + +struct RemoteFileEditorWindow { + client: Arc>, + tabs: Vec, + active_tab: usize, + close_prompt_open: bool, + pending_close_action: Option, + close_window_after_saves: bool, + next_tab_id: u64, +} + +impl RemoteFileEditorWindow { + fn new( + remote_path: String, + client: Arc>, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let mut this = Self { + client, + tabs: Vec::new(), + active_tab: 0, + close_prompt_open: false, + pending_close_action: None, + close_window_after_saves: false, + next_tab_id: 1, }; this.register_close_guard(window, cx); - this.reload(window, cx); + this.open_or_focus_tab(remote_path, window, cx); this } @@ -182,25 +269,84 @@ impl RemoteFileEditorWindow { }); } + fn open_or_focus_tab( + &mut self, + remote_path: String, + window: &mut Window, + cx: &mut Context, + ) { + let paths = self.tab_paths(); + let active_index = active_index_after_open(&paths, &remote_path); + if active_index == self.tabs.len() { + let tab_id = self.next_tab_id; + self.next_tab_id += 1; + self.tabs.push(RemoteEditorTab::new(tab_id, remote_path)); + self.active_tab = active_index; + self.reload_tab(active_index, window, cx); + } else { + self.active_tab = active_index; + self.focus_editor(window, cx); + cx.notify(); + } + self.update_window_title(window); + } + + fn tab_paths(&self) -> Vec { + self.tabs + .iter() + .map(|tab| tab.remote_path.clone()) + .collect() + } + + fn tab_index_by_identity(&self, tab_id: u64, remote_path: &str) -> Option { + self.tabs + .iter() + .position(|tab| tab.id == tab_id && tab.remote_path == remote_path) + } + + fn active_tab(&self) -> Option<&RemoteEditorTab> { + self.tabs.get(self.active_tab) + } + + fn active_tab_mut(&mut self) -> Option<&mut RemoteEditorTab> { + self.tabs.get_mut(self.active_tab) + } + + fn update_window_title(&self, window: &mut Window) { + if let Some(tab) = self.active_tab() { + window.set_window_title(&editor_window_title(&tab.remote_path)); + } + } + fn reload(&mut self, window: &mut Window, cx: &mut Context) { - self.loading = true; - self.load_error = None; - self.status_message = t!("RemoteFileEditor.status.loading").to_string(); + self.reload_tab(self.active_tab, window, cx); + } + + fn reload_tab(&mut self, index: usize, window: &mut Window, cx: &mut Context) { + let Some(tab) = self.tabs.get_mut(index) else { + return; + }; + + tab.loading = true; + tab.load_error = None; + tab.status_message = t!("RemoteFileEditor.status.loading").to_string(); cx.notify(); - let remote_path = self.remote_path.clone(); + let tab_id = tab.id; + let remote_path = tab.remote_path.clone(); + let task_remote_path = remote_path.clone(); let client = self.client.clone(); let task = Tokio::spawn(cx, async move { let bytes = { let mut client = client.lock().await; client - .read_file(&remote_path, MAX_EDITABLE_FILE_SIZE) + .read_file(&task_remote_path, MAX_EDITABLE_FILE_SIZE) .await? }; let file_size = bytes.len(); let policy = determine_file_policy(file_size)?; let text = decode_text_content(&bytes)?; - let language = language_for_path(&remote_path, policy.is_large_file).to_string(); + let language = language_for_path(&task_remote_path, policy.is_large_file).to_string(); Ok::<_, anyhow::Error>(LoadedFile { text, policy, @@ -214,24 +360,20 @@ impl RemoteFileEditorWindow { .spawn(cx, async move |cx| match task.await { Ok(Ok(loaded)) => { let _ = view.update_in(cx, |this, window, cx| { - this.apply_loaded_file(loaded, window, cx); + this.apply_loaded_file(tab_id, &remote_path, loaded, window, cx); }); } Ok(Err(error)) => { let message = error.to_string(); let _ = view.update_in(cx, |this, window, cx| { - this.loading = false; - this.load_error = Some(message.clone()); - this.status_message = t!("RemoteFileEditor.status.load_failed").to_string(); + this.apply_load_error(tab_id, &remote_path, message.clone(), cx); window.push_notification(Notification::error(message), cx); }); } Err(error) => { let message = error.to_string(); let _ = view.update_in(cx, |this, window, cx| { - this.loading = false; - this.load_error = Some(message.clone()); - this.status_message = t!("RemoteFileEditor.status.load_failed").to_string(); + this.apply_load_error(tab_id, &remote_path, message.clone(), cx); window.push_notification(Notification::error(message), cx); }); } @@ -241,10 +383,18 @@ impl RemoteFileEditorWindow { fn apply_loaded_file( &mut self, + tab_id: u64, + remote_path: &str, loaded: LoadedFile, window: &mut Window, cx: &mut Context, ) { + let Some(index) = self.tab_index_by_identity(tab_id, remote_path) else { + return; + }; + let Some(tab) = self.tabs.get_mut(index) else { + return; + }; let LoadedFile { text, policy, @@ -253,7 +403,7 @@ impl RemoteFileEditorWindow { } = loaded; let initial_text = text.clone(); - let soft_wrap = self.soft_wrap; + let soft_wrap = tab.soft_wrap; let editor = cx.new(|cx| { let mut state = InputState::new(window, cx) .code_editor(language) @@ -264,8 +414,8 @@ impl RemoteFileEditorWindow { state }); - self.subscriptions.clear(); - self.subscriptions.push( + tab.subscriptions.clear(); + tab.subscriptions.push( cx.subscribe(&editor, |_this, _input, event: &InputEvent, cx| { if matches!(event, InputEvent::Change) { cx.notify(); @@ -273,18 +423,20 @@ impl RemoteFileEditorWindow { }), ); - editor.update(cx, |state: &mut InputState, cx| { - state.focus(window, cx); - }); + if index == self.active_tab { + editor.update(cx, |state: &mut InputState, cx| { + state.focus(window, cx); + }); + } - self.editor = Some(editor); - self.saved_text = text; - self.file_size = file_size; - self.policy = policy; - self.loading = false; - self.saving = false; - self.load_error = None; - self.status_message = if policy.is_large_file { + tab.editor = Some(editor); + tab.saved_text = text; + tab.file_size = file_size; + tab.policy = policy; + tab.loading = false; + tab.saving = false; + tab.load_error = None; + tab.status_message = if policy.is_large_file { t!("RemoteFileEditor.status.loaded_plain_text").to_string() } else { t!("RemoteFileEditor.status.loaded").to_string() @@ -292,30 +444,64 @@ impl RemoteFileEditorWindow { cx.notify(); } + fn apply_load_error( + &mut self, + tab_id: u64, + remote_path: &str, + message: String, + cx: &mut Context, + ) { + let Some(index) = self.tab_index_by_identity(tab_id, remote_path) else { + return; + }; + let Some(tab) = self.tabs.get_mut(index) else { + return; + }; + tab.loading = false; + tab.load_error = Some(message); + tab.status_message = t!("RemoteFileEditor.status.load_failed").to_string(); + cx.notify(); + } + fn save(&mut self, close_after_save: bool, window: &mut Window, cx: &mut Context) { - self.close_after_save |= close_after_save; - let Some(editor) = self.editor.clone() else { - if self.close_after_save { - self.close_after_save = false; - window.remove_window(); + self.save_tab(self.active_tab, close_after_save, window, cx); + } + + fn save_tab( + &mut self, + index: usize, + close_after_save: bool, + window: &mut Window, + cx: &mut Context, + ) { + let Some(tab) = self.tabs.get_mut(index) else { + return; + }; + let Some(editor) = tab.editor.clone() else { + if close_after_save { + self.close_clean_tab(index, window, cx); } return; }; - if self.saving { + if tab.saving { return; } let text = editor.read(cx).text().to_string(); - self.saving = true; - self.status_message = t!("RemoteFileEditor.status.saving").to_string(); + tab.saving = true; + tab.status_message = t!("RemoteFileEditor.status.saving").to_string(); cx.notify(); - let remote_path = self.remote_path.clone(); + let tab_id = tab.id; + let remote_path = tab.remote_path.clone(); + let task_remote_path = remote_path.clone(); let client = self.client.clone(); let task = Tokio::spawn(cx, async move { let mut client = client.lock().await; - client.write_file(&remote_path, text.as_bytes()).await?; + client + .write_file(&task_remote_path, text.as_bytes()) + .await?; Ok::<_, anyhow::Error>(text) }); @@ -324,40 +510,27 @@ impl RemoteFileEditorWindow { .spawn(cx, async move |cx| match task.await { Ok(Ok(saved_text)) => { let _ = view.update_in(cx, |this, window, cx| { - this.saved_text = saved_text; - this.file_size = this.saved_text.len(); - this.saving = false; - this.status_message = t!("RemoteFileEditor.status.saved").to_string(); - let close_after_save = this.close_after_save; - this.close_after_save = false; - if close_after_save { - window.remove_window(); - } else { - window.push_notification( - Notification::success( - t!("RemoteFileEditor.notification.saved").to_string(), - ), - cx, - ); - } - cx.notify(); + this.apply_saved_file( + tab_id, + &remote_path, + saved_text, + close_after_save, + window, + cx, + ); }); } Ok(Err(error)) => { let message = error.to_string(); let _ = view.update_in(cx, |this, window, cx| { - this.saving = false; - this.close_after_save = false; - this.status_message = t!("RemoteFileEditor.status.save_failed").to_string(); + this.apply_save_error(tab_id, &remote_path, message.clone(), cx); window.push_notification(Notification::error(message), cx); }); } Err(error) => { let message = error.to_string(); let _ = view.update_in(cx, |this, window, cx| { - this.saving = false; - this.close_after_save = false; - this.status_message = t!("RemoteFileEditor.status.save_failed").to_string(); + this.apply_save_error(tab_id, &remote_path, message.clone(), cx); window.push_notification(Notification::error(message), cx); }); } @@ -365,19 +538,170 @@ impl RemoteFileEditorWindow { .detach(); } + fn apply_saved_file( + &mut self, + tab_id: u64, + remote_path: &str, + saved_text: String, + close_after_save: bool, + window: &mut Window, + cx: &mut Context, + ) { + let Some(index) = self.tab_index_by_identity(tab_id, remote_path) else { + return; + }; + let Some(tab) = self.tabs.get_mut(index) else { + return; + }; + tab.saved_text = saved_text; + tab.file_size = tab.saved_text.len(); + tab.saving = false; + tab.status_message = t!("RemoteFileEditor.status.saved").to_string(); + + if self.close_window_after_saves && !self.has_dirty_tabs(cx) { + self.close_window_after_saves = false; + clear_editor_window(); + window.remove_window(); + } else if close_after_save { + self.close_clean_tab(index, window, cx); + } else { + window.push_notification( + Notification::success(t!("RemoteFileEditor.notification.saved").to_string()), + cx, + ); + cx.notify(); + } + } + + fn apply_save_error( + &mut self, + tab_id: u64, + remote_path: &str, + _message: String, + cx: &mut Context, + ) { + let Some(index) = self.tab_index_by_identity(tab_id, remote_path) else { + return; + }; + let Some(tab) = self.tabs.get_mut(index) else { + return; + }; + tab.saving = false; + tab.status_message = t!("RemoteFileEditor.status.save_failed").to_string(); + self.close_window_after_saves = false; + cx.notify(); + } + fn handle_window_should_close(&mut self, window: &mut Window, cx: &mut Context) -> bool { - match decide_close_intercept(self.is_dirty(cx), self.close_prompt_open) { - CloseIntercept::Allow => true, + match decide_close_intercept(self.has_dirty_tabs(cx), self.close_prompt_open) { + CloseIntercept::Allow => { + clear_editor_window(); + true + } CloseIntercept::Ignore => false, CloseIntercept::Prompt => { - self.show_unsaved_changes_prompt(window, cx); + if let Some(index) = self.first_dirty_tab(cx) { + self.active_tab = index; + self.update_window_title(window); + self.focus_editor(window, cx); + } + self.show_unsaved_changes_prompt(PendingCloseAction::Window, window, cx); false } } } - fn show_unsaved_changes_prompt(&mut self, window: &mut Window, cx: &mut Context) { + fn request_close_active_tab(&mut self, window: &mut Window, cx: &mut Context) { + self.request_close_tab(self.active_tab, window, cx); + } + + fn request_close_tab(&mut self, index: usize, window: &mut Window, cx: &mut Context) { + if index >= self.tabs.len() { + return; + } + + match decide_close_intercept(self.is_tab_dirty(index, cx), self.close_prompt_open) { + CloseIntercept::Allow => self.close_clean_tab(index, window, cx), + CloseIntercept::Ignore => {} + CloseIntercept::Prompt => { + self.show_unsaved_changes_prompt(PendingCloseAction::Tab(index), window, cx); + } + } + } + + fn close_clean_tab(&mut self, index: usize, window: &mut Window, cx: &mut Context) { + if index >= self.tabs.len() { + return; + } + + let next_active = active_index_after_close(self.active_tab, index, self.tabs.len()); + self.tabs.remove(index); + if let Some(next_active) = next_active { + self.active_tab = next_active; + self.update_window_title(window); + self.focus_editor(window, cx); + cx.notify(); + } else { + clear_editor_window(); + window.remove_window(); + } + } + + fn discard_close_action( + &mut self, + action: PendingCloseAction, + window: &mut Window, + cx: &mut Context, + ) { + match action { + PendingCloseAction::Window => { + clear_editor_window(); + window.remove_window(); + } + PendingCloseAction::Tab(index) => self.close_clean_tab(index, window, cx), + } + } + + fn save_close_action( + &mut self, + action: PendingCloseAction, + window: &mut Window, + cx: &mut Context, + ) { + match action { + PendingCloseAction::Window => self.save_dirty_tabs_and_close_window(window, cx), + PendingCloseAction::Tab(index) => self.save_tab(index, true, window, cx), + } + } + + fn save_dirty_tabs_and_close_window(&mut self, window: &mut Window, cx: &mut Context) { + let dirty_indexes = self + .tabs + .iter() + .enumerate() + .filter_map(|(index, tab)| tab.is_dirty(cx).then_some(index)) + .collect::>(); + + if dirty_indexes.is_empty() { + clear_editor_window(); + window.remove_window(); + return; + } + + self.close_window_after_saves = true; + for index in dirty_indexes { + self.save_tab(index, false, window, cx); + } + } + + fn show_unsaved_changes_prompt( + &mut self, + action: PendingCloseAction, + window: &mut Window, + cx: &mut Context, + ) { self.close_prompt_open = true; + self.pending_close_action = Some(action); let prompt_title = t!("RemoteFileEditor.prompt.unsaved_title").to_string(); let prompt_message = t!("RemoteFileEditor.prompt.unsaved_message").to_string(); let save_label = t!("RemoteFileEditor.action.save").to_string(); @@ -401,13 +725,12 @@ impl RemoteFileEditorWindow { let selection = answer.await.ok(); let _ = cx.update_window(window_handle, |_, window, cx| { let _ = this.update(cx, |this, cx| { + let action = this.pending_close_action.take(); this.close_prompt_open = false; - match selection { - Some(0) => this.save(true, window, cx), - Some(1) => window.remove_window(), - _ => { - this.close_after_save = false; - } + match (selection, action) { + (Some(0), Some(action)) => this.save_close_action(action, window, cx), + (Some(1), Some(action)) => this.discard_close_action(action, window, cx), + _ => {} } }); }); @@ -439,7 +762,7 @@ impl RemoteFileEditorWindow { } fn trigger_replace(&mut self, window: &mut Window, cx: &mut Context) { - let Some(editor) = self.editor.as_ref() else { + let Some(editor) = self.active_tab().and_then(|tab| tab.editor.as_ref()) else { return; }; @@ -449,7 +772,7 @@ impl RemoteFileEditorWindow { } fn focus_editor(&mut self, window: &mut Window, cx: &mut Context) { - let Some(editor) = self.editor.as_ref() else { + let Some(editor) = self.active_tab().and_then(|tab| tab.editor.as_ref()) else { return; }; @@ -459,25 +782,92 @@ impl RemoteFileEditorWindow { } fn toggle_soft_wrap(&mut self, window: &mut Window, cx: &mut Context) { - self.soft_wrap = !self.soft_wrap; - if let Some(editor) = self.editor.as_ref() { + let Some(tab) = self.active_tab_mut() else { + return; + }; + tab.soft_wrap = !tab.soft_wrap; + if let Some(editor) = tab.editor.as_ref() { editor.update(cx, |state, cx| { - state.set_soft_wrap(self.soft_wrap, window, cx); + state.set_soft_wrap(tab.soft_wrap, window, cx); }); } cx.notify(); } - fn is_dirty(&self, cx: &App) -> bool { - self.editor - .as_ref() - .map(|editor| editor.read(cx).text().to_string() != self.saved_text) + fn switch_tab(&mut self, index: usize, window: &mut Window, cx: &mut Context) { + if index >= self.tabs.len() || index == self.active_tab { + return; + } + + self.active_tab = index; + self.update_window_title(window); + self.focus_editor(window, cx); + cx.notify(); + } + + fn is_tab_dirty(&self, index: usize, cx: &App) -> bool { + self.tabs + .get(index) + .map(|tab| tab.is_dirty(cx)) .unwrap_or(false) } + fn has_dirty_tabs(&self, cx: &App) -> bool { + self.tabs.iter().any(|tab| tab.is_dirty(cx)) + } + + fn first_dirty_tab(&self, cx: &App) -> Option { + self.tabs.iter().position(|tab| tab.is_dirty(cx)) + } + + fn render_tabs(&self, cx: &mut Context) -> impl IntoElement { + let mut tab_bar = TabBar::new("remote-file-editor-tabs") + .menu(true) + .with_size(Size::Small) + .selected_index(self.active_tab) + .on_click({ + let view = cx.entity().clone(); + move |index, window, cx| { + let _ = view.update(cx, |this, cx| { + this.switch_tab(*index, window, cx); + }); + } + }); + + for (index, tab) in self.tabs.iter().enumerate() { + let label = if tab.is_dirty(cx) { + format!("* {}", tab.display_name) + } else { + tab.display_name.clone() + }; + tab_bar = tab_bar.child( + Tab::new().label(label).suffix( + Button::new(format!("remote-file-close-tab-{index}")) + .label("×") + .with_size(Size::XSmall) + .disabled(tab.saving) + .on_click(cx.listener(move |this, _, window, cx| { + this.request_close_tab(index, window, cx); + })), + ), + ); + } + + h_flex() + .border_b_1() + .border_color(cx.theme().border) + .bg(cx.theme().tab_bar) + .child(tab_bar) + } + fn render_toolbar(&self, cx: &mut Context) -> impl IntoElement { - let dirty = self.is_dirty(cx); - let disabled = self.loading || self.saving || self.editor.is_none(); + let tab = self.active_tab(); + let dirty = tab.map(|tab| tab.is_dirty(cx)).unwrap_or(false); + let disabled = tab + .map(|tab| tab.loading || tab.saving || tab.editor.is_none()) + .unwrap_or(true); + let loading_or_saving = tab.map(|tab| tab.loading || tab.saving).unwrap_or(true); + let soft_wrap = tab.map(|tab| tab.soft_wrap).unwrap_or(false); h_flex() .gap_2() @@ -518,7 +908,7 @@ impl RemoteFileEditorWindow { Button::new("remote-file-reload") .label(t!("RemoteFileEditor.action.reload")) .with_size(Size::Small) - .disabled(self.loading || self.saving) + .disabled(loading_or_saving) .on_click(cx.listener(|this, _, window, cx| { this.reload(window, cx); })), @@ -526,19 +916,28 @@ impl RemoteFileEditorWindow { .child( Button::new("remote-file-soft-wrap") .label(t!("RemoteFileEditor.action.soft_wrap")) - .selected(self.soft_wrap) + .selected(soft_wrap) .with_size(Size::Small) .disabled(disabled) .on_click(cx.listener(|this, _, window, cx| { this.toggle_soft_wrap(window, cx); })), ) + .child( + Button::new("remote-file-close-active-tab") + .label(t!("RemoteFileEditor.action.close_tab")) + .with_size(Size::Small) + .disabled(loading_or_saving || self.tabs.is_empty()) + .on_click(cx.listener(|this, _, window, cx| { + this.request_close_active_tab(window, cx); + })), + ) .child(div().flex_1()) .child( div() .text_sm() .text_color(cx.theme().muted_foreground) - .child(self.policy_label()), + .child(tab.map(RemoteEditorTab::policy_label).unwrap_or_default()), ) .child( div() @@ -557,6 +956,19 @@ impl RemoteFileEditorWindow { } fn render_status_bar(&self, cx: &mut Context) -> impl IntoElement { + let remote_path = self + .active_tab() + .map(|tab| tab.remote_path.clone()) + .unwrap_or_default(); + let file_size = self + .active_tab() + .map(|tab| tab.file_size) + .unwrap_or_default(); + let status_message = self + .active_tab() + .map(|tab| tab.status_message.clone()) + .unwrap_or_default(); + h_flex() .gap_2() .items_center() @@ -569,25 +981,29 @@ impl RemoteFileEditorWindow { div() .text_sm() .text_color(cx.theme().muted_foreground) - .child(self.remote_path.clone()), + .child(remote_path), ) .child(div().flex_1()) .child( div() .text_sm() .text_color(cx.theme().muted_foreground) - .child(format_size(self.file_size)), + .child(format_size(file_size)), ) .child( div() .text_sm() .text_color(cx.theme().muted_foreground) - .child(self.status_message.clone()), + .child(status_message), ) } fn render_body(&self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - if self.loading { + let Some(tab) = self.active_tab() else { + return v_flex().size_full().into_any_element(); + }; + + if tab.loading { return v_flex() .size_full() .items_center() @@ -596,7 +1012,7 @@ impl RemoteFileEditorWindow { .into_any_element(); } - if let Some(error) = self.load_error.as_ref() { + if let Some(error) = tab.load_error.as_ref() { return v_flex() .size_full() .items_center() @@ -617,7 +1033,7 @@ impl RemoteFileEditorWindow { .into_any_element(); } - match self.editor.as_ref() { + match tab.editor.as_ref() { Some(editor) => v_flex() .size_full() .child(Input::new(editor).size_full()) @@ -625,17 +1041,15 @@ impl RemoteFileEditorWindow { None => v_flex().size_full().into_any_element(), } } - - fn policy_label(&self) -> String { - match self.policy.mode { - EditorMode::Code => t!("RemoteFileEditor.policy.code").to_string(), - EditorMode::PlainText => t!("RemoteFileEditor.policy.plain_text").to_string(), - } - } } impl Render for RemoteFileEditorWindow { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let title = self + .active_tab() + .map(|tab| tab.display_name.clone()) + .unwrap_or_default(); + v_flex() .size_full() .key_context(REMOTE_FILE_EDITOR_CONTEXT) @@ -650,9 +1064,10 @@ impl Render for RemoteFileEditorWindow { .justify_center() .flex_1() .text_sm() - .child(self.display_name.clone()), + .child(title), ), ) + .child(self.render_tabs(cx)) .child(self.render_toolbar(cx)) .child(v_flex().flex_1().child(self.render_body(window, cx))) .child(self.render_status_bar(cx)) @@ -698,4 +1113,16 @@ mod tests { assert_eq!(search_shortcut(), EXPECTED_SEARCH_SHORTCUT); assert_eq!(replace_shortcut(), EXPECTED_REPLACE_SHORTCUT); } + + #[test] + fn display_name_ignores_trailing_slash() { + assert_eq!(display_name_from_path("/tmp/example/"), "example"); + } + + #[test] + fn format_size_uses_binary_units() { + assert_eq!(format_size(42), "42 B"); + assert_eq!(format_size(1024), "1.0 KiB"); + assert_eq!(format_size(1024 * 1024), "1.0 MiB"); + } } diff --git a/crates/remote_file_editor/src/lib.rs b/crates/remote_file_editor/src/lib.rs index e84ca7edf9..83c5b36367 100644 --- a/crates/remote_file_editor/src/lib.rs +++ b/crates/remote_file_editor/src/lib.rs @@ -9,7 +9,10 @@ mod language; #[cfg(feature = "ui")] pub use editor_window::open_remote_file_editor; -pub use close_guard::{CloseIntercept, decide_close_intercept}; +pub use close_guard::{ + CloseIntercept, active_index_after_close, active_index_after_open, decide_close_intercept, + find_tab_index, has_dirty_tabs, +}; pub use file_policy::{ EditorMode, FilePolicy, LARGE_FILE_PLAIN_TEXT_THRESHOLD, MAX_EDITABLE_FILE_SIZE, decode_text_content, determine_file_policy, diff --git a/crates/sftp/src/russh_impl.rs b/crates/sftp/src/russh_impl.rs index 4107e4045e..257dddb6d4 100644 --- a/crates/sftp/src/russh_impl.rs +++ b/crates/sftp/src/russh_impl.rs @@ -1004,7 +1004,7 @@ impl SftpClient for RusshSftpClient { // 按路径深度倒序删除目录(先删子目录) let mut dirs: Vec<&FileEntry> = entries.iter().filter(|e| e.is_dir).collect(); - dirs.sort_by(|a, b| b.path.len().cmp(&a.path.len())); + dirs.sort_by_key(|dir| std::cmp::Reverse(dir.path.len())); for dir in dirs { ensure_not_cancelled(&cancelled)?; progress(TransferProgress { @@ -1201,7 +1201,7 @@ impl SftpClient for RusshSftpClient { .map_err(|e| anyhow!("Failed to create local directory {}: {}", local_path, e))?; let mut dirs: Vec<&FileEntry> = entries.iter().filter(|e| e.is_dir).collect(); - dirs.sort_by(|a, b| a.path.len().cmp(&b.path.len())); + dirs.sort_by_key(|dir| dir.path.len()); for dir_entry in dirs { ensure_not_cancelled(&cancelled)?; let relative = dir_entry @@ -1404,7 +1404,7 @@ impl SftpClient for RusshSftpClient { let _ = self.sftp.create_dir(remote_path).await; let mut dirs: Vec<_> = entries.iter().filter(|(_, is_dir, _)| *is_dir).collect(); - dirs.sort_by(|a, b| a.0.as_os_str().len().cmp(&b.0.as_os_str().len())); + dirs.sort_by_key(|dir| dir.0.as_os_str().len()); for (dir_path, _, _) in dirs { ensure_not_cancelled(&cancelled)?; From cf259377ed7a91a939249dbab2b3fcb0081a3a63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=83=A1=E9=A3=9E?= <1835698775@qq.com> Date: Sun, 10 May 2026 10:35:17 +0800 Subject: [PATCH 31/45] =?UTF-8?q?faet=EF=BC=9A=E5=88=A0=E9=99=A4=E6=97=A0?= =?UTF-8?q?=E7=94=A8=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main/src/home/home_new_connection.rs | 226 --------------------------- 1 file changed, 226 deletions(-) delete mode 100644 main/src/home/home_new_connection.rs diff --git a/main/src/home/home_new_connection.rs b/main/src/home/home_new_connection.rs deleted file mode 100644 index bb6095fb07..0000000000 --- a/main/src/home/home_new_connection.rs +++ /dev/null @@ -1,226 +0,0 @@ -use crate::home_tab::HomePage; -use gpui::{App, Context, Entity, ParentElement, SharedString, Styled, Task, Window, div}; -use gpui_component::{ - ActiveTheme, IndexPath, WindowExt, h_flex, - list::{ListDelegate, ListItem, ListState}, - tokens::Radius, -}; -use one_core::storage::DatabaseType; -use rust_i18n::t; - -/// 新建连接对话框中的连接类型选项 -#[derive(Clone)] -enum NewConnectionKind { - Workspace, - Ssh, - Terminal, - Redis, - MongoDB, - Serial, - Database(DatabaseType), -} - -impl NewConnectionKind { - fn label(&self) -> String { - match self { - NewConnectionKind::Workspace => t!("Workspace.label").to_string(), - NewConnectionKind::Ssh => "SSH".to_string(), - NewConnectionKind::Terminal => "Terminal".to_string(), - NewConnectionKind::Redis => "Redis".to_string(), - NewConnectionKind::MongoDB => "MongoDB".to_string(), - NewConnectionKind::Serial => t!("Serial.new").to_string(), - NewConnectionKind::Database(db_type) => db_type.as_str().to_string(), - } - } - - fn category(&self) -> String { - match self { - NewConnectionKind::Workspace => t!("NewConnection.workspace").to_string(), - NewConnectionKind::Ssh | NewConnectionKind::Terminal | NewConnectionKind::Serial => { - t!("NewConnection.terminal").to_string() - } - NewConnectionKind::Redis | NewConnectionKind::MongoDB => "NoSQL".to_string(), - NewConnectionKind::Database(_) => t!("NewConnection.database").to_string(), - } - } - - /// 在 HomePage 上执行对应的操作 - fn execute(&self, home: &mut HomePage, window: &mut Window, cx: &mut Context) { - match self { - NewConnectionKind::Workspace => { - home.show_workspace_form(None, window, cx); - } - NewConnectionKind::Ssh => { - home.editing_connection_id = None; - home.show_ssh_form(window, cx); - } - NewConnectionKind::Terminal => { - home.add_terminal_tab(window, cx); - } - NewConnectionKind::Redis => { - home.editing_connection_id = None; - home.show_redis_form(window, cx); - } - NewConnectionKind::MongoDB => { - home.editing_connection_id = None; - home.show_mongodb_form(window, cx); - } - NewConnectionKind::Serial => { - home.editing_connection_id = None; - home.show_serial_form(window, cx); - } - NewConnectionKind::Database(db_type) => { - home.editing_connection_id = None; - home.show_connection_form(*db_type, window, cx); - } - } - } -} - -pub(crate) struct NewConnectionDelegate { - parent: Entity, - items: Vec, - filtered_items: Vec, - selected_index: Option, - search_query: String, -} - -impl NewConnectionDelegate { - pub(crate) fn new(parent: Entity) -> Self { - let mut items = vec![ - NewConnectionKind::Workspace, - NewConnectionKind::Ssh, - NewConnectionKind::Terminal, - NewConnectionKind::Redis, - NewConnectionKind::MongoDB, - NewConnectionKind::Serial, - ]; - - for db_type in DatabaseType::all() { - items.push(NewConnectionKind::Database(*db_type)); - } - - let filtered_items = items.clone(); - - Self { - parent, - items, - filtered_items, - selected_index: None, - search_query: String::new(), - } - } - - fn apply_filter(&mut self) { - if self.search_query.is_empty() { - self.filtered_items = self.items.clone(); - return; - } - let query = self.search_query.to_lowercase(); - self.filtered_items = self - .items - .iter() - .filter(|kind| { - kind.label().to_lowercase().contains(&query) - || kind.category().to_lowercase().contains(&query) - }) - .cloned() - .collect(); - } -} - -impl ListDelegate for NewConnectionDelegate { - type Item = ListItem; - - fn perform_search( - &mut self, - query: &str, - _window: &mut Window, - cx: &mut Context>, - ) -> Task<()> { - self.search_query = query.to_string(); - self.apply_filter(); - cx.notify(); - Task::ready(()) - } - - fn items_count(&self, _section: usize, _cx: &App) -> usize { - self.filtered_items.len() - } - - fn render_item( - &mut self, - ix: IndexPath, - _window: &mut Window, - cx: &mut Context>, - ) -> Option { - let kind = self.filtered_items.get(ix.row)?.clone(); - let parent = self.parent.clone(); - let label = kind.label(); - let category = kind.category(); - - Some( - ListItem::new(ix) - .px_3() - .py_2() - .rounded(Radius::Md.px()) - .on_click(move |_, window, cx| { - parent.update(cx, |this, cx| { - kind.execute(this, window, cx); - }); - window.close_dialog(cx); - }) - .child( - h_flex() - .w_full() - .items_center() - .gap_2() - .child( - div() - .flex_1() - .min_w_0() - .text_sm() - .text_ellipsis() - .whitespace_nowrap() - .child(SharedString::from(label)), - ) - .child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(SharedString::from(category)), - ), - ), - ) - } - - fn set_selected_index( - &mut self, - ix: Option, - _window: &mut Window, - _cx: &mut Context>, - ) { - self.selected_index = ix; - } - - fn confirm( - &mut self, - _secondary: bool, - window: &mut Window, - cx: &mut Context>, - ) { - if let Some(ix) = self.selected_index { - if let Some(kind) = self.filtered_items.get(ix.row).cloned() { - let parent = self.parent.clone(); - parent.update(cx, |this, cx| { - kind.execute(this, window, cx); - }); - window.close_dialog(cx); - } - } - } - - fn cancel(&mut self, window: &mut Window, cx: &mut Context>) { - window.close_dialog(cx); - } -} From 07a5c75d88e156ebd3a849bcf8923112f0cd9755 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=83=A1=E9=A3=9E?= <1835698775@qq.com> Date: Mon, 11 May 2026 11:22:07 +0800 Subject: [PATCH 32/45] =?UTF-8?q?feat(terminal):=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E7=BB=88=E7=AB=AF=E4=BA=8B=E4=BB=B6=E8=BD=AC=E5=8F=91=E5=92=8C?= =?UTF-8?q?=E5=9D=97=E5=AD=97=E7=AC=A6=E6=B8=B2=E6=9F=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 GpuiEventProxy 中添加窗口尺寸同步和 Wakeup 事件去重逻辑,防止事件堆积 - 实现默认颜色回复,避免 OSC 颜色请求总返回黑色 - 终端事件循环支持 Wakeup 去重标记重置,避免高频输出时阻塞事件转发 - 增加多项终端事件去重单元测试,保证 Wakeup 行为正确 - 新增块状字符 (U+2580..U+259F) 的几何绘制支持,避免字体回退时出现渲染接缝 - RenderCache 新增块字符缓存,重构重建逻辑以支持块字符几何渲染 - 终端渲染阶段绘制块字符几何路径,提高渲染质量和字体兼容性 - 终端视图添加多项块字符几何绘制单元测试,确保坐标计算正确 - 优化按键映射单元测试,覆盖常用键及修饰键序列生成 - 终端视图中新增 SGR 鼠标按钮事件生成及回报,完善鼠标按钮编码和修饰符支持 - 终端主流程改用 wakeup_pending 标记,避免重复 Wakeup 事件引发的性能问题 - 调整窗口尺寸更新接口,确保所有相关模块共享正确终端尺寸信息 --- crates/terminal/src/pty_backend.rs | 196 ++++++++-- crates/terminal/src/terminal.rs | 27 +- crates/terminal_view/src/keys.rs | 156 ++++++++ crates/terminal_view/src/terminal_element.rs | 356 ++++++++++++++++--- crates/terminal_view/src/view.rs | 177 ++++++++- 5 files changed, 815 insertions(+), 97 deletions(-) diff --git a/crates/terminal/src/pty_backend.rs b/crates/terminal/src/pty_backend.rs index b9c12a9d77..1e7b6f5a3c 100644 --- a/crates/terminal/src/pty_backend.rs +++ b/crates/terminal/src/pty_backend.rs @@ -3,8 +3,10 @@ use alacritty_terminal::event_loop::{EventLoop, EventLoopSender, Msg}; use alacritty_terminal::sync::FairMutex; use alacritty_terminal::term::{ClipboardType, Term}; use alacritty_terminal::tty::{self, Options as PtyOptions}; +use alacritty_terminal::vte::ansi::{NamedColor, Rgb}; use std::borrow::Cow; -use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; use std::thread; use std::thread::JoinHandle; use tokio::sync::mpsc::UnboundedSender; @@ -155,6 +157,7 @@ impl PtyWriteBack { /// 3. Sends Wakeup event via EventListener pub struct LocalPtyBackend { event_loop_sender: EventLoopSender, + event_proxy: GpuiEventProxy, _event_loop_handle: JoinHandle<()>, child_pid: Option, } @@ -190,6 +193,7 @@ impl LocalPtyBackend { // 设置 PtyWrite 回写通道,使 DA 等终端响应能写回 PTY event_proxy.set_write_back(PtyWriteBack::Local(event_loop_sender.clone())); + event_proxy.set_window_size(window_size); let handle = thread::spawn(move || { let _ = event_loop.spawn().join(); @@ -197,6 +201,7 @@ impl LocalPtyBackend { Ok(Self { event_loop_sender, + event_proxy, _event_loop_handle: handle, child_pid, }) @@ -234,6 +239,7 @@ impl LocalPtyBackend { size.pixel_width, size.pixel_height ); + self.event_proxy.set_window_size(window_size); let _ = self.event_loop_sender.send(Msg::Resize(window_size)); } @@ -248,21 +254,7 @@ impl TerminalBackend for LocalPtyBackend { } fn resize(&self, size: TerminalSize) { - let window_size = WindowSize { - num_lines: size.rows, - num_cols: size.cols, - cell_width: if size.cols > 0 { - size.pixel_width / size.cols - } else { - 8 - }, - cell_height: if size.rows > 0 { - size.pixel_height / size.rows - } else { - 18 - }, - }; - let _ = self.event_loop_sender.send(Msg::Resize(window_size)); + LocalPtyBackend::resize(self, size); } fn close(&self, mode: TerminalCloseMode) { @@ -287,14 +279,25 @@ impl TerminalBackend for LocalPtyBackend { pub struct GpuiEventProxy { event_tx: UnboundedSender, /// PtyWrite 回写通道(在后端创建后设置) - write_back: Arc>>, + write_back: Arc>>, + /// 共享窗口尺寸,供 TextAreaSizeRequest 真实回复使用 + window_size: Arc>, + /// Wakeup 去重标记:true 表示已有未消费的 Wakeup 在事件队列里 + wakeup_pending: Arc, } impl GpuiEventProxy { pub fn new(event_tx: UnboundedSender) -> Self { Self { event_tx, - write_back: Arc::new(std::sync::Mutex::new(None)), + write_back: Arc::new(Mutex::new(None)), + window_size: Arc::new(Mutex::new(WindowSize { + num_lines: 24, + num_cols: 80, + cell_width: 8, + cell_height: 18, + })), + wakeup_pending: Arc::new(AtomicBool::new(false)), } } @@ -308,6 +311,7 @@ impl GpuiEventProxy { self.set_write_back(PtyWriteBack::Ssh(sender)); } +<<<<<<< HEAD /// 设置 Hosted 本地 PTY 回写通道 #[allow(dead_code)] pub(crate) fn set_hosted_write_back( @@ -316,6 +320,26 @@ impl GpuiEventProxy { session_id: String, ) { self.set_write_back(PtyWriteBack::Hosted { sender, session_id }); +======= + /// 同步当前真实窗口尺寸(含 cell 像素),后续 TextAreaSizeRequest 将以此回复 + pub(crate) fn set_window_size(&self, size: WindowSize) { + *self.window_size.lock().unwrap() = size; + } + + /// 当 UI 已经消费 Wakeup 后调用,允许下一次 Wakeup 入队 + pub fn reset_wakeup_pending(&self) { + self.wakeup_pending.store(false, Ordering::Release); + } + + /// 返回 Wakeup 去重标记的句柄,便于事件聚合任务在转发 Wakeup 后立即 reset, + /// 让下一次 PTY 输出能继续触发 Wakeup + pub fn wakeup_pending_handle(&self) -> Arc { + self.wakeup_pending.clone() + } + + fn current_window_size(&self) -> WindowSize { + *self.window_size.lock().unwrap() +>>>>>>> d0e858e4 (feat(terminal): 优化终端事件转发和块字符渲染) } fn write_back(&self, data: Vec) { @@ -332,22 +356,23 @@ impl EventListener for GpuiEventProxy { self.write_back(text.into_bytes()); return; } - AlacTermEvent::ColorRequest(_index, format_fn) => { - let text = format_fn(alacritty_terminal::vte::ansi::Rgb { r: 0, g: 0, b: 0 }); + AlacTermEvent::ColorRequest(index, format_fn) => { + let text = format_fn(default_color_for_index(index)); self.write_back(text.into_bytes()); return; } AlacTermEvent::TextAreaSizeRequest(format_fn) => { - let text = format_fn(WindowSize { - num_lines: 24, - num_cols: 80, - cell_width: 8, - cell_height: 18, - }); + let text = format_fn(self.current_window_size()); self.write_back(text.into_bytes()); return; } - AlacTermEvent::Wakeup => TerminalEvent::Wakeup, + AlacTermEvent::Wakeup => { + // 去重:已有未消费 Wakeup 时直接丢弃,避免高速输出下事件堆积 + if self.wakeup_pending.swap(true, Ordering::AcqRel) { + return; + } + TerminalEvent::Wakeup + } AlacTermEvent::Title(title) => { // 尝试从标题中提取工作目录 // PowerShell 格式: "PS C:\path\to\dir" 或 "PS ~/path" @@ -366,3 +391,120 @@ impl EventListener for GpuiEventProxy { let _ = self.event_tx.send(terminal_event); } } + +/// 为 OSC 4/10/11 等颜色查询提供合理的默认回复,避免一律返回黑色 +fn default_color_for_index(index: usize) -> Rgb { + match index { + // OSC 10:默认前景色 -> 接近白色 + idx if idx == NamedColor::Foreground as usize => Rgb { + r: 0xE4, + g: 0xE4, + b: 0xE4, + }, + // OSC 11:默认背景色 -> 接近深灰 + idx if idx == NamedColor::Background as usize => Rgb { + r: 0x1E, + g: 0x1E, + b: 0x1E, + }, + // OSC 12:光标颜色 + idx if idx == NamedColor::Cursor as usize => Rgb { + r: 0xFF, + g: 0xFF, + b: 0xFF, + }, + _ => Rgb { r: 0, g: 0, b: 0 }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + use tokio::sync::mpsc::unbounded_channel; + + #[test] + fn wakeup_dedup_collapses_repeated_wakeups_until_reset() { + let (tx, mut rx) = unbounded_channel::(); + let proxy = GpuiEventProxy::new(tx); + + proxy.send_event(AlacTermEvent::Wakeup); + proxy.send_event(AlacTermEvent::Wakeup); + proxy.send_event(AlacTermEvent::Wakeup); + + // 多次 Wakeup 只入队一次 + let first = rx.try_recv(); + assert!(matches!(first, Ok(TerminalEvent::Wakeup))); + assert!(rx.try_recv().is_err()); + + // reset 后允许新一轮 Wakeup 入队 + proxy.reset_wakeup_pending(); + proxy.send_event(AlacTermEvent::Wakeup); + let next = rx.try_recv(); + assert!(matches!(next, Ok(TerminalEvent::Wakeup))); + } + + #[test] + fn non_wakeup_events_are_not_swallowed_by_dedup() { + let (tx, mut rx) = unbounded_channel::(); + let proxy = GpuiEventProxy::new(tx); + + // 先压一个 Wakeup 进去拉起去重标记 + proxy.send_event(AlacTermEvent::Wakeup); + // 期间发生 Title/Bell/Exit 等事件,不应被去重逻辑吞掉 + proxy.send_event(AlacTermEvent::Title("shell".to_string())); + proxy.send_event(AlacTermEvent::Bell); + proxy.send_event(AlacTermEvent::Exit); + + let mut got = Vec::new(); + while let Ok(ev) = rx.try_recv() { + got.push(ev); + } + assert_eq!(got.len(), 4); + assert!(matches!(got[0], TerminalEvent::Wakeup)); + assert!(matches!(got[1], TerminalEvent::TitleChanged(ref t) if t == "shell")); + assert!(matches!(got[2], TerminalEvent::Bell)); + assert!(matches!(got[3], TerminalEvent::ChildExit(0))); + } + + #[test] + fn text_area_size_request_uses_current_window_size() { + let (tx, _rx) = unbounded_channel::(); + let proxy = GpuiEventProxy::new(tx); + + // 注入一个回写通道收集 reply 字节 + let captured: Arc>> = Arc::new(Mutex::new(Vec::new())); + let (write_tx, mut write_rx) = unbounded_channel::>(); + proxy.set_ssh_write_back(write_tx); + + proxy.set_window_size(WindowSize { + num_lines: 40, + num_cols: 132, + cell_width: 9, + cell_height: 20, + }); + + proxy.send_event(AlacTermEvent::TextAreaSizeRequest(std::sync::Arc::new( + |size| format!("{}x{}", size.num_cols, size.num_lines), + ))); + + if let Ok(bytes) = write_rx.try_recv() { + captured.lock().unwrap().extend_from_slice(&bytes); + } + let reply = String::from_utf8(captured.lock().unwrap().clone()).unwrap(); + assert_eq!(reply, "132x40"); + } + + #[test] + fn color_request_returns_named_defaults_instead_of_black() { + let fg = default_color_for_index(NamedColor::Foreground as usize); + let bg = default_color_for_index(NamedColor::Background as usize); + let cursor = default_color_for_index(NamedColor::Cursor as usize); + let other = default_color_for_index(NamedColor::Red as usize); + + assert_ne!((fg.r, fg.g, fg.b), (0, 0, 0)); + assert_ne!((bg.r, bg.g, bg.b), (0, 0, 0)); + assert_eq!((cursor.r, cursor.g, cursor.b), (0xFF, 0xFF, 0xFF)); + assert_eq!((other.r, other.g, other.b), (0, 0, 0)); + } +} diff --git a/crates/terminal/src/terminal.rs b/crates/terminal/src/terminal.rs index a0a4adec91..db19f68041 100644 --- a/crates/terminal/src/terminal.rs +++ b/crates/terminal/src/terminal.rs @@ -30,9 +30,11 @@ use std::collections::HashSet; use std::collections::VecDeque; use std::fs; use std::path::PathBuf; -use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex as StdMutex}; use std::time::{Duration, Instant}; use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender}; +use tokio::sync::oneshot; use tokio::time::interval; #[cfg(any(test, target_os = "windows"))] @@ -1142,10 +1144,10 @@ impl TerminalScrollProxy { impl Terminal { fn new_local_disconnected(error: String, cx: &mut Context) -> Self { let (event_tx, event_rx) = unbounded_channel::(); - let (term, _event_proxy, _colors) = + let (term, event_proxy, _colors) = Self::create_term(DEFAULT_COLS, DEFAULT_ROWS, event_tx.clone()); - Self::spawn_event_loop(event_rx, cx); + Self::spawn_event_loop(event_rx, event_proxy.wakeup_pending_handle(), cx); Self { term, @@ -1255,10 +1257,10 @@ impl Terminal { #[cfg(target_os = "windows")] escape_args: true, }; - let local_backend = LocalPtyBackend::new(term.clone(), event_proxy, pty_options)?; + let local_backend = LocalPtyBackend::new(term.clone(), event_proxy.clone(), pty_options)?; let local_shell_pid = local_backend.child_pid(); - Self::spawn_event_loop(event_rx, cx); + Self::spawn_event_loop(event_rx, event_proxy.wakeup_pending_handle(), cx); #[cfg(target_os = "macos")] Self::spawn_local_process_tree_settler(cx); Self::spawn_local_history_loader(history_shell.as_deref(), cx); @@ -1609,7 +1611,7 @@ impl Terminal { let ssh_session_manager = Arc::new(SshSessionManager::new(config.ssh_config.clone())); Self::spawn_disconnect_handler(disconnect_rx, connection_generation, cx); - Self::spawn_event_loop(event_rx, cx); + Self::spawn_event_loop(event_rx, event_proxy.wakeup_pending_handle(), cx); Self::spawn_ssh_connect( ssh_session_manager.clone(), config.clone(), @@ -1668,13 +1670,13 @@ impl Terminal { .expect("StoredConnection 应包含有效的 SerialParams"); let (event_tx, event_rx) = unbounded_channel::(); - let (term, _event_proxy, _colors) = + let (term, event_proxy, _colors) = Self::create_term(DEFAULT_COLS, DEFAULT_ROWS, event_tx.clone()); let (disconnect_tx, disconnect_rx) = tokio::sync::oneshot::channel::<()>(); let connection_generation = 1; Self::spawn_disconnect_handler(disconnect_rx, connection_generation, cx); - Self::spawn_event_loop(event_rx, cx); + Self::spawn_event_loop(event_rx, event_proxy.wakeup_pending_handle(), cx); Self::spawn_serial_connect( serial_params.clone(), term.clone(), @@ -1803,7 +1805,11 @@ impl Terminal { .detach(); } - fn spawn_event_loop(mut event_rx: UnboundedReceiver, cx: &mut Context) { + fn spawn_event_loop( + mut event_rx: UnboundedReceiver, + wakeup_pending: Arc, + cx: &mut Context, + ) { let _entity = cx.entity().downgrade(); let (render_tx, mut render_rx) = futures::channel::mpsc::unbounded::(); @@ -1836,6 +1842,9 @@ impl Terminal { // 最后发送 Wakeup if pending_wakeup { pending_wakeup = false; + // 转发完毕后允许 alacritty 线程的下一次 Wakeup 重新入队, + // 避免高速输出时被 GpuiEventProxy 的去重永久吞掉 + wakeup_pending.store(false, Ordering::Release); if render_tx.unbounded_send(TerminalEvent::Wakeup).is_err() { return; } diff --git a/crates/terminal_view/src/keys.rs b/crates/terminal_view/src/keys.rs index 3453478b88..fe39cd9bbf 100644 --- a/crates/terminal_view/src/keys.rs +++ b/crates/terminal_view/src/keys.rs @@ -331,4 +331,160 @@ mod tests { "\x1ba" ); } + + #[test] + fn enter_emits_carriage_return() { + let enter = Keystroke::parse("enter").unwrap(); + assert_eq!( + to_esc_str(&enter, &TermMode::NONE, false).unwrap().as_ref(), + "\x0d" + ); + } + + #[test] + fn backspace_emits_del_by_default() { + let bs = Keystroke::parse("backspace").unwrap(); + assert_eq!( + to_esc_str(&bs, &TermMode::NONE, false).unwrap().as_ref(), + "\x7f" + ); + } + + #[test] + fn ctrl_backspace_emits_bs() { + let bs = Keystroke::parse("ctrl-backspace").unwrap(); + assert_eq!( + to_esc_str(&bs, &TermMode::NONE, false).unwrap().as_ref(), + "\x08" + ); + } + + #[test] + fn shift_tab_emits_csi_z() { + let shift_tab = Keystroke::parse("shift-tab").unwrap(); + assert_eq!( + to_esc_str(&shift_tab, &TermMode::NONE, false) + .unwrap() + .as_ref(), + "\x1b[Z" + ); + } + + #[test] + fn home_app_cursor_mode_emits_ss3() { + let home = Keystroke::parse("home").unwrap(); + assert_eq!( + to_esc_str(&home, &TermMode::NONE, false).unwrap().as_ref(), + "\x1b[H" + ); + assert_eq!( + to_esc_str(&home, &TermMode::APP_CURSOR, false) + .unwrap() + .as_ref(), + "\x1bOH" + ); + } + + #[test] + fn page_up_down_emit_csi_tilde() { + let pageup = Keystroke::parse("pageup").unwrap(); + let pagedown = Keystroke::parse("pagedown").unwrap(); + assert_eq!( + to_esc_str(&pageup, &TermMode::NONE, false) + .unwrap() + .as_ref(), + "\x1b[5~" + ); + assert_eq!( + to_esc_str(&pagedown, &TermMode::NONE, false) + .unwrap() + .as_ref(), + "\x1b[6~" + ); + } + + #[test] + fn insert_delete_emit_csi_tilde() { + let ins = Keystroke::parse("insert").unwrap(); + let del = Keystroke::parse("delete").unwrap(); + assert_eq!( + to_esc_str(&ins, &TermMode::NONE, false).unwrap().as_ref(), + "\x1b[2~" + ); + assert_eq!( + to_esc_str(&del, &TermMode::NONE, false).unwrap().as_ref(), + "\x1b[3~" + ); + } + + #[test] + fn ctrl_letter_covers_full_alphabet() { + // Ctrl-A => 0x01, Ctrl-Z => 0x1a + for (key, expected) in [("ctrl-a", 0x01u8), ("ctrl-m", 0x0d), ("ctrl-z", 0x1a)] { + let ks = Keystroke::parse(key).unwrap(); + let seq = to_esc_str(&ks, &TermMode::NONE, false).unwrap(); + assert_eq!(seq.as_ref().as_bytes(), &[expected], "{key}"); + } + } + + #[test] + fn ctrl_bracket_and_underscore_emit_c0() { + assert_eq!( + to_esc_str(&Keystroke::parse("ctrl-[").unwrap(), &TermMode::NONE, false) + .unwrap() + .as_ref() + .as_bytes(), + b"\x1b" + ); + assert_eq!( + to_esc_str(&Keystroke::parse("ctrl-_").unwrap(), &TermMode::NONE, false) + .unwrap() + .as_ref() + .as_bytes(), + b"\x1f" + ); + } + + #[test] + fn ctrl_space_emits_nul() { + let ks = Keystroke::parse("ctrl-space").unwrap(); + assert_eq!( + to_esc_str(&ks, &TermMode::NONE, false) + .unwrap() + .as_ref() + .as_bytes(), + b"\x00" + ); + } + + #[test] + fn shift_arrow_in_alt_screen_remains_none_in_normal_mode() { + // 锁定当前行为:normal screen 下 shift-arrow 不发送修饰序列 + let shift_up = Keystroke::parse("shift-up").unwrap(); + assert_eq!(to_esc_str(&shift_up, &TermMode::NONE, false), None); + } + + #[test] + fn ctrl_arrow_emits_csi_with_modifier_param_5() { + // xterm modifier param: ctrl=4 => +1 = 5 + let ctrl_right = Keystroke::parse("ctrl-right").unwrap(); + assert_eq!( + to_esc_str(&ctrl_right, &TermMode::NONE, false) + .unwrap() + .as_ref(), + "\x1b[1;5C" + ); + } + + #[test] + fn alt_arrow_emits_csi_with_modifier_param_3() { + // alt=2 => +1 = 3 + let alt_left = Keystroke::parse("alt-left").unwrap(); + assert_eq!( + to_esc_str(&alt_left, &TermMode::NONE, false) + .unwrap() + .as_ref(), + "\x1b[1;3D" + ); + } } diff --git a/crates/terminal_view/src/terminal_element.rs b/crates/terminal_view/src/terminal_element.rs index 341bc715d6..a216b94751 100644 --- a/crates/terminal_view/src/terminal_element.rs +++ b/crates/terminal_view/src/terminal_element.rs @@ -111,6 +111,79 @@ fn is_decorative_character(ch: char) -> bool { ) } +/// 为 Unicode 块字符(U+2580..U+259F)生成几何矩形序列。 +/// +/// 返回的 rect 坐标以 cell 自身宽高的 [0, 1] 归一化系数表示, +/// 调用方在 paint 阶段乘以 cell_width / cell_height 得到像素矩形。 +/// +/// 几何绘制避免依赖字体字形,可解决字体回退时块状字符出现接缝、 +/// 抗锯齿不一致或 line-height gap 导致的视觉断层问题。 +fn block_element_geometry(c: char) -> Option> { + fn rect(x: f32, y: f32, w: f32, h: f32) -> BlockRect { + BlockRect { x, y, w, h } + } + fn lower(fraction: f32) -> Vec { + vec![rect(0.0, 1.0 - fraction, 1.0, fraction)] + } + fn left(fraction: f32) -> Vec { + vec![rect(0.0, 0.0, fraction, 1.0)] + } + const QUAD_UPPER_LEFT: u8 = 1 << 0; + const QUAD_UPPER_RIGHT: u8 = 1 << 1; + const QUAD_LOWER_LEFT: u8 = 1 << 2; + const QUAD_LOWER_RIGHT: u8 = 1 << 3; + fn quadrants(mask: u8) -> Vec { + let mut out = Vec::with_capacity(4); + if mask & QUAD_UPPER_LEFT != 0 { + out.push(rect(0.0, 0.0, 0.5, 0.5)); + } + if mask & QUAD_UPPER_RIGHT != 0 { + out.push(rect(0.5, 0.0, 0.5, 0.5)); + } + if mask & QUAD_LOWER_LEFT != 0 { + out.push(rect(0.0, 0.5, 0.5, 0.5)); + } + if mask & QUAD_LOWER_RIGHT != 0 { + out.push(rect(0.5, 0.5, 0.5, 0.5)); + } + out + } + + Some(match c { + '\u{2580}' => vec![rect(0.0, 0.0, 1.0, 0.5)], // ▀ upper half + '\u{2581}' => lower(1.0 / 8.0), // ▁ + '\u{2582}' => lower(2.0 / 8.0), // ▂ + '\u{2583}' => lower(3.0 / 8.0), // ▃ + '\u{2584}' => lower(4.0 / 8.0), // ▄ + '\u{2585}' => lower(5.0 / 8.0), // ▅ + '\u{2586}' => lower(6.0 / 8.0), // ▆ + '\u{2587}' => lower(7.0 / 8.0), // ▇ + '\u{2588}' => vec![rect(0.0, 0.0, 1.0, 1.0)], // █ full block + '\u{2589}' => left(7.0 / 8.0), // ▉ + '\u{258A}' => left(6.0 / 8.0), // ▊ + '\u{258B}' => left(5.0 / 8.0), // ▋ + '\u{258C}' => left(4.0 / 8.0), // ▌ + '\u{258D}' => left(3.0 / 8.0), // ▍ + '\u{258E}' => left(2.0 / 8.0), // ▎ + '\u{258F}' => left(1.0 / 8.0), // ▏ + '\u{2590}' => vec![rect(0.5, 0.0, 0.5, 1.0)], // ▐ right half + // U+2591..U+2593 阴影块由文本路径处理(依赖字体本身的密度图,更自然) + '\u{2594}' => vec![rect(0.0, 0.0, 1.0, 1.0 / 8.0)], // ▔ upper one-eighth + '\u{2595}' => vec![rect(7.0 / 8.0, 0.0, 1.0 / 8.0, 1.0)], // ▕ right one-eighth + '\u{2596}' => quadrants(QUAD_LOWER_LEFT), + '\u{2597}' => quadrants(QUAD_LOWER_RIGHT), + '\u{2598}' => quadrants(QUAD_UPPER_LEFT), + '\u{2599}' => quadrants(QUAD_UPPER_LEFT | QUAD_LOWER_LEFT | QUAD_LOWER_RIGHT), + '\u{259A}' => quadrants(QUAD_UPPER_LEFT | QUAD_LOWER_RIGHT), + '\u{259B}' => quadrants(QUAD_UPPER_LEFT | QUAD_UPPER_RIGHT | QUAD_LOWER_LEFT), + '\u{259C}' => quadrants(QUAD_UPPER_LEFT | QUAD_UPPER_RIGHT | QUAD_LOWER_RIGHT), + '\u{259D}' => quadrants(QUAD_UPPER_RIGHT), + '\u{259E}' => quadrants(QUAD_UPPER_RIGHT | QUAD_LOWER_LEFT), + '\u{259F}' => quadrants(QUAD_UPPER_RIGHT | QUAD_LOWER_LEFT | QUAD_LOWER_RIGHT), + _ => return None, + }) +} + /// Manages decorations from all addons pub struct DecorationManager { // Decorations indexed by line number @@ -206,6 +279,8 @@ impl DecorationManager { pub struct CachedLine { pub background_rects: Vec<(usize, usize, Hsla)>, pub text_runs: Vec, + /// 块状字符(U+2580..U+259F)使用几何绘制,避免字体回退导致的接缝 + pub block_glyphs: Vec, } #[derive(Clone)] @@ -219,6 +294,25 @@ pub struct CachedTextRun { pub char_count: usize, } +/// 单个 cell 内的几何块字符渲染数据 +/// +/// rects 中的坐标均归一化到 cell 自身的 [0, 1] 范围, +/// paint 时再按当前 cell_width/cell_height 缩放为像素矩形。 +#[derive(Clone)] +pub struct CachedBlockGlyph { + pub column: usize, + pub color: Hsla, + pub rects: Vec, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct BlockRect { + pub x: f32, + pub y: f32, + pub w: f32, + pub h: f32, +} + /// Terminal rendering cache maintained by TerminalView pub struct RenderCache { lines: Vec, @@ -250,6 +344,23 @@ struct CachedCursor { shape: CursorShape, } +enum DamageSnapshot { + Full, + Partial(Vec), +} + +impl DamageSnapshot { + fn from_term_damage(damage: TermDamage<'_>) -> Self { + match damage { + TermDamage::Full => Self::Full, + TermDamage::Partial(iter) => { + let lines = iter.map(|line_damage| line_damage.line).collect(); + Self::Partial(lines) + } + } + } +} + impl RenderCache { pub fn new(num_lines: usize, num_cols: usize, colors: Colors) -> Self { let default_bg = convert_color(Color::Named(NamedColor::Background), &colors); @@ -257,7 +368,8 @@ impl RenderCache { lines: vec![ CachedLine { background_rects: Vec::new(), - text_runs: Vec::new() + text_runs: Vec::new(), + block_glyphs: Vec::new(), }; num_lines ], @@ -290,6 +402,9 @@ impl RenderCache { self.resize(num_lines, num_cols); } + let damage = DamageSnapshot::from_term_damage(term.damage()); + term.reset_damage(); + // Collect decorations from all addons let display_offset = term.grid().display_offset(); self.decoration_manager @@ -309,37 +424,29 @@ impl RenderCache { // 同步主题光标颜色 self.custom_cursor = theme.cursor; - // Force full rebuild when theme colors or decorations changed - let has_decorations = !self.decoration_manager.decorations_by_line.is_empty(); - if fg_changed || bg_changed || has_decorations { - self.rebuild_all(term); - self.update_last_selection(term); - return; - } - - // Check terminal color palette changes + // 在任何 full rebuild 早返回之前同步终端调色板。 let colors = term.colors(); - if !colors_equal(&self.colors, colors) { + let colors_changed = !colors_equal(&self.colors, colors); + if colors_changed { self.colors = colors.clone(); self.default_bg = convert_color(Color::Named(NamedColor::Background), &self.colors); - self.rebuild_all(term); - self.update_last_selection(term); + } + + // 主题颜色变化或存在装饰时保守全量重建。 + let has_decorations = !self.decoration_manager.decorations_by_line.is_empty(); + if fg_changed || bg_changed || colors_changed || has_decorations { + self.rebuild_all_and_update_state(term); return; } - // Collect dirty lines from terminal damage let mut dirty_lines: std::collections::HashSet = std::collections::HashSet::new(); - let damage = term.damage(); match damage { - TermDamage::Full => { - self.rebuild_all(term); - self.update_last_selection(term); + DamageSnapshot::Full => { + self.rebuild_all_and_update_state(term); return; } - TermDamage::Partial(iter) => { - for line_damage in iter { - dirty_lines.insert(line_damage.line); - } + DamageSnapshot::Partial(lines) => { + dirty_lines.extend(lines); } } @@ -389,11 +496,18 @@ impl RenderCache { CachedLine { background_rects: Vec::new(), text_runs: Vec::new(), + block_glyphs: Vec::new(), }, ); self.left_edge_fingerprint.resize(num_lines, 0); } + fn rebuild_all_and_update_state(&mut self, term: &Term) { + self.rebuild_all(term); + self.update_last_selection(term); + self.sync_left_edge_fingerprint(term, 4); + } + fn rebuild_all(&mut self, term: &Term) { let content = term.renderable_content(); let display_offset = content.display_offset; @@ -403,6 +517,7 @@ impl RenderCache { for line in &mut self.lines { line.background_rects.clear(); line.text_runs.clear(); + line.block_glyphs.clear(); } // Group cells by screen line @@ -481,6 +596,7 @@ impl RenderCache { if line_idx < self.num_lines { self.lines[line_idx].background_rects.clear(); self.lines[line_idx].text_runs.clear(); + self.lines[line_idx].block_glyphs.clear(); let cells = std::mem::take(&mut line_cells[line_idx]); self.build_line_cache(line_idx, cells); } @@ -538,8 +654,39 @@ impl RenderCache { term: &Term, probe_cols: usize, ) -> Vec { + let current = self.compute_left_edge_fingerprint(term, probe_cols); + + if self.left_edge_fingerprint.len() != self.num_lines { + self.left_edge_fingerprint.resize(self.num_lines, 0); + } + + let mut changed = Vec::new(); + for (line_idx, (old, new)) in self + .left_edge_fingerprint + .iter() + .zip(current.iter()) + .enumerate() + { + if old != new { + changed.push(line_idx); + } + } + + self.left_edge_fingerprint = current; + changed + } + + fn sync_left_edge_fingerprint(&mut self, term: &Term, probe_cols: usize) { + self.left_edge_fingerprint = self.compute_left_edge_fingerprint(term, probe_cols); + } + + fn compute_left_edge_fingerprint( + &self, + term: &Term, + probe_cols: usize, + ) -> Vec { if self.num_lines == 0 || probe_cols == 0 { - return Vec::new(); + return vec![0; self.num_lines]; } let mut current = vec![0_u64; self.num_lines]; @@ -567,24 +714,7 @@ impl RenderCache { .wrapping_add(piece.wrapping_add(1469598103934665603)); } - if self.left_edge_fingerprint.len() != self.num_lines { - self.left_edge_fingerprint.resize(self.num_lines, 0); - } - - let mut changed = Vec::new(); - for (line_idx, (old, new)) in self - .left_edge_fingerprint - .iter() - .zip(current.iter()) - .enumerate() - { - if old != new { - changed.push(line_idx); - } - } - - self.left_edge_fingerprint = current; - changed + current } fn build_line_cache(&mut self, line_idx: usize, mut cells: Vec) { @@ -692,6 +822,19 @@ impl RenderCache { continue; } + // 块状字符走几何路径,避免不同字体渲染出现接缝 + if let Some(rects) = block_element_geometry(cell.c) { + if let Some(run) = text_run.take() { + line.text_runs.push(run); + } + line.block_glyphs.push(CachedBlockGlyph { + column: cell.column, + color: fg, + rects, + }); + continue; + } + let bold = cell.flags.contains(Flags::BOLD); let italic = cell.flags.contains(Flags::ITALIC); @@ -1009,6 +1152,24 @@ impl Element for TerminalElementImpl { } } + // Paint block-element geometry(在文字之前,与背景同样的覆盖关系) + for line_idx in first_visible..visible_end { + let line = &self.lines[line_idx]; + for glyph in &line.block_glyphs { + let cell_origin = tb.cell_origin(line_idx, glyph.column); + for r in &glyph.rects { + let rect = Bounds::new( + Point::new( + cell_origin.x + tb.cell_width * r.x, + cell_origin.y + tb.cell_height * r.y, + ), + size(tb.cell_width * r.w, tb.cell_height * r.h), + ); + window.paint_quad(fill(rect, glyph.color)); + } + } + } + // Paint text (only visible lines, using cached fonts) // 使用 cell_width 确保等宽渲染,避免字符布局漂移 for line_idx in first_visible..visible_end { @@ -1299,23 +1460,23 @@ fn indexed_color_to_hsla(idx: u8) -> Hsla { #[cfg(test)] mod tests { - use super::{hsla_eq, terminal_font_features, CellData, RenderCache}; + use super::{BlockRect, block_element_geometry, hsla_eq, terminal_font_features, CellData, RenderCache}; use alacritty_terminal::term::cell::Flags; use alacritty_terminal::term::color::Colors; use alacritty_terminal::vte::ansi::{Color, NamedColor}; use gpui::hsla; - #[test] - fn terminal_font_features_explicitly_enable_all_ligature_tags() { - let features = terminal_font_features(true); + fn approx_eq(a: f32, b: f32) -> bool { + (a - b).abs() < 1e-5 + } - assert_eq!( - features.tag_value_list(), - &[ - ("liga".to_string(), 1), - ("clig".to_string(), 1), - ("calt".to_string(), 1), - ] + fn assert_rect(actual: &BlockRect, x: f32, y: f32, w: f32, h: f32) { + assert!( + approx_eq(actual.x, x) + && approx_eq(actual.y, y) + && approx_eq(actual.w, w) + && approx_eq(actual.h, h), + "expected ({x}, {y}, {w}, {h}) got {actual:?}" ); } @@ -1390,4 +1551,95 @@ mod tests { cache.custom_background )); } + + #[test] + fn full_block_covers_entire_cell() { + let rects = block_element_geometry('\u{2588}').expect("full block"); + assert_eq!(rects.len(), 1); + assert_rect(&rects[0], 0.0, 0.0, 1.0, 1.0); + } + + #[test] + fn lower_half_block_fills_bottom_half() { + let rects = block_element_geometry('\u{2584}').expect("lower half"); + assert_eq!(rects.len(), 1); + assert_rect(&rects[0], 0.0, 0.5, 1.0, 0.5); + } + + #[test] + fn upper_half_block_fills_top_half() { + let rects = block_element_geometry('\u{2580}').expect("upper half"); + assert_eq!(rects.len(), 1); + assert_rect(&rects[0], 0.0, 0.0, 1.0, 0.5); + } + + #[test] + fn left_half_block_fills_left_half() { + let rects = block_element_geometry('\u{258C}').expect("left half"); + assert_eq!(rects.len(), 1); + assert_rect(&rects[0], 0.0, 0.0, 0.5, 1.0); + } + + #[test] + fn right_half_block_fills_right_half() { + let rects = block_element_geometry('\u{2590}').expect("right half"); + assert_eq!(rects.len(), 1); + assert_rect(&rects[0], 0.5, 0.0, 0.5, 1.0); + } + + #[test] + fn quadrant_block_lower_left_only() { + let rects = block_element_geometry('\u{2596}').expect("quadrant lower left"); + assert_eq!(rects.len(), 1); + assert_rect(&rects[0], 0.0, 0.5, 0.5, 0.5); + } + + #[test] + fn quadrant_block_diagonal_pair() { + let rects = block_element_geometry('\u{259A}').expect("quadrant diagonal"); + assert_eq!(rects.len(), 2); + // 上左 + 下右 + let mut found_upper_left = false; + let mut found_lower_right = false; + for r in &rects { + if approx_eq(r.x, 0.0) && approx_eq(r.y, 0.0) { + found_upper_left = true; + } + if approx_eq(r.x, 0.5) && approx_eq(r.y, 0.5) { + found_lower_right = true; + } + } + assert!(found_upper_left && found_lower_right); + } + + #[test] + fn shade_blocks_fall_back_to_text_path() { + // U+2591..U+2593 阴影块继续走文本路径,避免几何绘制无法表达密度 + assert!(block_element_geometry('\u{2591}').is_none()); + assert!(block_element_geometry('\u{2592}').is_none()); + assert!(block_element_geometry('\u{2593}').is_none()); + } + + #[test] + fn non_block_characters_return_none() { + // Box drawing 不在本批几何路径内 + assert!(block_element_geometry('─').is_none()); + // 普通字符也不返回几何 + assert!(block_element_geometry('A').is_none()); + } + + #[test] + fn eighth_lower_blocks_use_one_eighth_increments() { + for (i, ch) in [ + '\u{2581}', '\u{2582}', '\u{2583}', '\u{2584}', '\u{2585}', '\u{2586}', '\u{2587}', + ] + .iter() + .enumerate() + { + let fraction = (i + 1) as f32 / 8.0; + let rects = block_element_geometry(*ch).expect("lower eighth"); + assert_eq!(rects.len(), 1); + assert_rect(&rects[0], 0.0, 1.0 - fraction, 1.0, fraction); + } + } } diff --git a/crates/terminal_view/src/view.rs b/crates/terminal_view/src/view.rs index 88f68c20e6..c6e412dee6 100644 --- a/crates/terminal_view/src/view.rs +++ b/crates/terminal_view/src/view.rs @@ -257,6 +257,44 @@ fn sgr_mouse_wheel_report(lines: i32, col: usize, row: usize) -> Option Some(format!("\x1b[<{};{};{}M", button, col + 1, row + 1)) } +/// 生成 SGR 鼠标按钮报告。 +/// +/// - `button`:xterm 按钮编码(0=左键、1=中键、2=右键,加上 shift/alt/ctrl/拖动等位) +/// - `pressed`:true 用 `M` 表示按下,false 用 `m` 表示释放(SGR 协议规定) +/// - `col` / `row`:0-based,输出转为 1-based +/// +/// 抽出为独立纯函数,便于单元测试和后续扩展(拖动 32 位、wheel-with-modifiers 等)。 +fn sgr_mouse_button_report(button: u8, col: usize, row: usize, pressed: bool) -> String { + let suffix = if pressed { 'M' } else { 'm' }; + format!("\x1b[<{};{};{}{}", button, col + 1, row + 1, suffix) +} + +/// 将 GPUI 鼠标按钮映射为 xterm 按钮基础编码:左=0、中=1、右=2。 +/// 其它按钮(X1/X2 等)当前未在 SGR 报告中使用,返回 None。 +fn mouse_button_code(button: MouseButton) -> Option { + match button { + MouseButton::Left => Some(0), + MouseButton::Middle => Some(1), + MouseButton::Right => Some(2), + _ => None, + } +} + +/// 将修饰键编码到 xterm 鼠标按钮的高位:shift=4、alt=8、control=16。 +fn encode_mouse_modifiers(modifiers: Modifiers) -> u8 { + let mut bits = 0u8; + if modifiers.shift { + bits |= 4; + } + if modifiers.alt { + bits |= 8; + } + if modifiers.control { + bits |= 16; + } + bits +} + fn should_scroll_to_bottom_on_user_input( display_offset: usize, pending_display_offset: &StdCell>, @@ -3477,13 +3515,45 @@ impl TerminalView { } } + /// 当终端启用 SGR 鼠标 + 任意鼠标报告模式时,把按钮按下/释放事件以 SGR 形式 + /// 回报给 PTY。返回 true 表示已经处理,调用方应跳过 selection/dismiss/paste 等本地行为。 + fn try_report_sgr_mouse_button( + &mut self, + button: MouseButton, + position: Point, + modifiers: Modifiers, + pressed: bool, + cx: &mut Context, + ) -> bool { + let mode = self.terminal.read(cx).mode(); + if !(mode.contains(TermMode::SGR_MOUSE) && mode.intersects(TermMode::MOUSE_MODE)) { + return false; + } + let Some(base) = mouse_button_code(button) else { + return false; + }; + let point = self.pixel_to_point(position, self.terminal_bounds, cx); + let encoded = base | encode_mouse_modifiers(modifiers); + let report = + sgr_mouse_button_report(encoded, point.column.0, point.line.0 as usize, pressed); + self.write_to_pty(report.into_bytes(), cx); + true + } + fn handle_mouse_down( &mut self, event: &MouseDownEvent, window: &mut Window, cx: &mut Context, ) { - window.focus(&self.focus_handle, cx); + if self.terminal.read(cx).ssh_mfa_request().is_none() { + window.focus(&self.focus_handle, cx); + } + // SGR 鼠标模式下把按钮按下事件交给 TUI,跳过 selection/URL/dismiss + if self.try_report_sgr_mouse_button(event.button, event.position, event.modifiers, true, cx) + { + return; + } tracing::debug!( target: "terminal.history_prompt", reason = "mouse_down", @@ -3571,10 +3641,20 @@ impl TerminalView { fn handle_middle_mouse_down( &mut self, - _event: &MouseDownEvent, + event: &MouseDownEvent, window: &mut Window, cx: &mut Context, ) { + // SGR 鼠标模式下中键按下走 TUI 报告而不是 middle-click paste + if self.try_report_sgr_mouse_button( + MouseButton::Middle, + event.position, + event.modifiers, + true, + cx, + ) { + return; + } if !self.middle_click_paste { return; } @@ -3643,6 +3723,16 @@ impl TerminalView { _window: &mut Window, cx: &mut Context, ) { + // SGR 鼠标模式下:先回报释放,然后跳过 selection 收尾 + if self.try_report_sgr_mouse_button( + event.button, + event.position, + event.modifiers, + false, + cx, + ) { + return; + } if event.button != MouseButton::Left { return; } @@ -4472,9 +4562,10 @@ mod tests { #[cfg(target_os = "macos")] use super::TerminalView; use super::{ - UnbracketedPasteHazard, alt_screen_scroll_arrow, detect_unbracketed_paste_hazard, has_trailing_line_continuation, - has_unterminated_shell_quote, history_prompt_available, history_prompt_dropdown_origin, - history_prompt_overlay_bounds, multiline_non_empty_line_count, preserve_theme_typography, sgr_mouse_wheel_report, + UnbracketedPasteHazard, alt_screen_scroll_arrow, detect_unbracketed_paste_hazard, encode_mouse_modifiers, + has_trailing_line_continuation, has_unterminated_shell_quote, history_prompt_available, + history_prompt_dropdown_origin, history_prompt_overlay_bounds, mouse_button_code, + multiline_non_empty_line_count, preserve_theme_typography, sgr_mouse_button_report, sgr_mouse_wheel_report, should_defer_inline_history_prompt_input_to_text_system, should_dismiss_history_prompt_for_keystroke, should_dismiss_history_prompt_for_mouse, should_dismiss_history_prompt_for_scroll, should_reset_history_prompt_for_terminal_event, @@ -4486,7 +4577,7 @@ mod tests { use alacritty_terminal::term::TermMode; #[cfg(target_os = "macos")] use gpui::TestAppContext; - use gpui::{px, size, Bounds, Keystroke, MouseButton, Point, SharedString}; + use gpui::{px, size, Bounds, Keystroke, Modifiers, MouseButton, Point, SharedString}; use std::cell::Cell as StdCell; #[cfg(target_os = "macos")] use std::{ @@ -4520,11 +4611,12 @@ mod tests { } #[test] -<<<<<<< HEAD fn alt_screen_scroll_arrow_maps_positive_lines_to_up() { assert_eq!(alt_screen_scroll_arrow(1, false), Some("\x1b[A")); assert_eq!(alt_screen_scroll_arrow(1, true), Some("\x1bOA")); -======= + } + + #[test] fn terminal_keybindings_bind_ctrl_zero_to_reset_font() { let source = include_str!("view.rs"); let binding = format!("{}{}", r#"KeyBinding::new("ctrl-0", "#, "ResetFont"); @@ -4553,7 +4645,6 @@ mod tests { sgr_mouse_wheel_report(1, 4, 2).as_deref(), Some("\x1b[<64;5;3M") ); ->>>>>>> 690937ef (feat(terminal): 添加终端鼠标滚轮 SGR 模式支持及 Vim 鼠标增强) } #[test] @@ -4565,6 +4656,74 @@ mod tests { assert_eq!(sgr_mouse_wheel_report(0, 4, 2), None); } + #[test] + fn sgr_mouse_button_report_uses_capital_m_on_press() { + // 左键按下,列 0、行 0 -> 转 1-based + let s = sgr_mouse_button_report(0, 0, 0, true); + assert_eq!(s, "\x1b[<0;1;1M"); + } + + #[test] + fn sgr_mouse_button_report_uses_lowercase_m_on_release() { + let s = sgr_mouse_button_report(2, 9, 4, false); + // 右键 (button=2) 释放在 1-based col=10 row=5 + assert_eq!(s, "\x1b[<2;10;5m"); + } + + #[test] + fn sgr_mouse_button_report_supports_modifier_encoded_buttons() { + // 左键 + shift (4) + ctrl (16) -> button=20 + let s = sgr_mouse_button_report(20, 0, 0, true); + assert_eq!(s, "\x1b[<20;1;1M"); + } + + #[test] + fn sgr_mouse_button_report_supports_drag_button_codes() { + // 拖动事件:button + 32(xterm 拖动位) + // 左键拖动 = 32 + let s = sgr_mouse_button_report(32, 7, 11, true); + assert_eq!(s, "\x1b[<32;8;12M"); + } + + #[test] + fn mouse_button_code_maps_three_main_buttons() { + assert_eq!(mouse_button_code(MouseButton::Left), Some(0)); + assert_eq!(mouse_button_code(MouseButton::Middle), Some(1)); + assert_eq!(mouse_button_code(MouseButton::Right), Some(2)); + } + + #[test] + fn encode_mouse_modifiers_packs_shift_alt_control() { + let none = Modifiers::default(); + assert_eq!(encode_mouse_modifiers(none), 0); + + let shift = Modifiers { + shift: true, + ..Default::default() + }; + assert_eq!(encode_mouse_modifiers(shift), 4); + + let alt = Modifiers { + alt: true, + ..Default::default() + }; + assert_eq!(encode_mouse_modifiers(alt), 8); + + let ctrl = Modifiers { + control: true, + ..Default::default() + }; + assert_eq!(encode_mouse_modifiers(ctrl), 16); + + let all = Modifiers { + shift: true, + alt: true, + control: true, + ..Default::default() + }; + assert_eq!(encode_mouse_modifiers(all), 28); + } + #[test] fn multiline_non_empty_line_count_ignores_blank_lines() { assert_eq!(multiline_non_empty_line_count("echo 1\n\n echo 2\n"), 2); From db8c4137e867032918f574adcd0274d9d023fc32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=83=A1=E9=A3=9E?= <1835698775@qq.com> Date: Mon, 11 May 2026 17:06:20 +0800 Subject: [PATCH 33/45] =?UTF-8?q?feat(terminal):=20=E8=AE=B0=E5=BD=95?= =?UTF-8?q?=E5=83=8F=E7=B4=A0=E5=B0=BA=E5=AF=B8=E5=B9=B6=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=20nudge=5Fresize=20=E8=A7=A6=E5=8F=91=20SIGWINCH?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 Terminal 结构中新增 pixel_width 和 pixel_height 字段用于记录像素尺寸 - resize 方法中若单元格尺寸未变则仅更新像素尺寸,避免重复调整行列 - 新增 nudge_resize 方法,重新向 PTY 发送当前尺寸以触发 SIGWINCH - 在 View 模块监测 alt screen 模式切换,进入时调用 nudge_resize - 多处终端视图代码添加详细的调试日志,追踪尺寸与重建状态 - 记录底部若干行内容分布,辅助调试 TUI 应用残留旧画面问题 - 在 RenderCache 和绘制函数中添加额外日志,助力性能与渲染分析 --- crates/terminal/src/terminal.rs | 57 ++++++++++++- crates/terminal_view/src/terminal_element.rs | 90 +++++++++++++++++++- crates/terminal_view/src/view.rs | 38 +++++++++ 3 files changed, 181 insertions(+), 4 deletions(-) diff --git a/crates/terminal/src/terminal.rs b/crates/terminal/src/terminal.rs index db19f68041..84fcdce211 100644 --- a/crates/terminal/src/terminal.rs +++ b/crates/terminal/src/terminal.rs @@ -1031,6 +1031,9 @@ pub struct Terminal { /// 终端尺寸 cols: usize, rows: usize, + /// 最近一次同步给 PTY 的像素尺寸,用于 nudge_resize 重发 SIGWINCH + pixel_width: u16, + pixel_height: u16, /// SSH 配置(用于重连) ssh_config: Option, @@ -1164,6 +1167,8 @@ impl Terminal { connection_wait_started_at: None, cols: DEFAULT_COLS, rows: DEFAULT_ROWS, + pixel_width: 0, + pixel_height: 0, ssh_config: None, ssh_session_manager: None, ssh_process_state: Cell::new(SshProcessState::Unknown), @@ -1280,6 +1285,8 @@ impl Terminal { connection_wait_started_at: None, cols: DEFAULT_COLS, rows: DEFAULT_ROWS, + pixel_width: 0, + pixel_height: 0, ssh_config: None, ssh_session_manager: None, ssh_process_state: Cell::new(SshProcessState::Unknown), @@ -1644,7 +1651,9 @@ impl Terminal { connection_wait_started_at: Some(Instant::now()), cols, rows, - ssh_config: Some(config.clone()), + pixel_width: 0, + pixel_height: 0, + ssh_config: Some(config), ssh_session_manager: Some(ssh_session_manager), ssh_process_state: Cell::new(SshProcessState::Unknown), ssh_prompt_detected: false, @@ -1701,6 +1710,8 @@ impl Terminal { connection_wait_started_at: None, cols: DEFAULT_COLS, rows: DEFAULT_ROWS, + pixel_width: 0, + pixel_height: 0, ssh_config: None, ssh_session_manager: None, ssh_process_state: Cell::new(SshProcessState::Unknown), @@ -2535,21 +2546,33 @@ impl Terminal { /// 调整终端大小 pub fn resize(&mut self, cols: usize, rows: usize, pixel_width: u16, pixel_height: u16) { if self.cols == cols && self.rows == rows { + // 单元格行列数未变,但仍记录最新像素尺寸,供 nudge_resize 复用 + self.pixel_width = pixel_width; + self.pixel_height = pixel_height; + tracing::debug!( + target: "terminal_residue", + cols, rows, pixel_width, pixel_height, + "Terminal::resize noop (cells unchanged, pixels cached)" + ); return; } tracing::info!( - "Terminal::resize: {}x{} -> {}x{}, pixel={}x{}", + target: "terminal_residue", + "Terminal::resize: {}x{} -> {}x{}, pixel={}x{}, backend={}", self.cols, self.rows, cols, rows, pixel_width, - pixel_height + pixel_height, + self.backend.is_some() ); self.cols = cols; self.rows = rows; + self.pixel_width = pixel_width; + self.pixel_height = pixel_height; self.term.lock().resize(TermDimensions { cols, rows }); @@ -2563,6 +2586,32 @@ impl Terminal { } } + /// 重新向 PTY 后端发送当前尺寸,不修改 alacritty grid。 + /// + /// 用于在 alt screen 切换等场景下触发 SIGWINCH, + /// 让 TUI 应用(opencode/lazygit/vim 等)重新查询尺寸并刷新整屏画面, + /// 避免出现底部残留旧画面的问题。 + pub fn nudge_resize(&self) { + let Some(ref backend) = self.backend else { + tracing::warn!(target: "terminal_residue", "nudge_resize skipped: no backend"); + return; + }; + tracing::info!( + target: "terminal_residue", + cols = self.cols, + rows = self.rows, + pixel_width = self.pixel_width, + pixel_height = self.pixel_height, + "Terminal::nudge_resize -> backend.resize" + ); + backend.resize(TerminalSize { + rows: self.rows as u16, + cols: self.cols as u16, + pixel_width: self.pixel_width, + pixel_height: self.pixel_height, + }); + } + /// 重新连接 SSH 或串口 pub fn reconnect(&mut self, cx: &mut Context) { self.reconnect_internal(false, cx); @@ -3110,6 +3159,8 @@ mod tests { connection_state: ConnectionState::Connected, cols: 80, rows: 24, + pixel_width: 0, + pixel_height: 0, ssh_config: None, ssh_session_manager: None, serial_params: None, diff --git a/crates/terminal_view/src/terminal_element.rs b/crates/terminal_view/src/terminal_element.rs index a216b94751..ee88d65436 100644 --- a/crates/terminal_view/src/terminal_element.rs +++ b/crates/terminal_view/src/terminal_element.rs @@ -12,7 +12,7 @@ use alacritty_terminal::grid::Dimensions; use alacritty_terminal::selection::SelectionRange; use alacritty_terminal::term::cell::Flags; use alacritty_terminal::term::color::Colors; -use alacritty_terminal::term::{RenderableContent, Term, TermDamage}; +use alacritty_terminal::term::{RenderableContent, Term, TermDamage, TermMode}; use alacritty_terminal::vte::ansi::{Color, CursorShape, NamedColor, Rgb}; use gpui::*; use std::collections::HashMap; @@ -399,6 +399,14 @@ impl RenderCache { // Handle resize if num_lines != self.num_lines || num_cols != self.num_cols { + tracing::info!( + target: "terminal_residue", + old_lines = self.num_lines, + old_cols = self.num_cols, + new_lines = num_lines, + new_cols = num_cols, + "RenderCache::resize" + ); self.resize(num_lines, num_cols); } @@ -435,6 +443,15 @@ impl RenderCache { // 主题颜色变化或存在装饰时保守全量重建。 let has_decorations = !self.decoration_manager.decorations_by_line.is_empty(); if fg_changed || bg_changed || colors_changed || has_decorations { + tracing::debug!( + target: "terminal_residue", + fg_changed, + bg_changed, + colors_changed, + has_decorations, + num_lines, + "rebuild_all (forced by theme/decoration)" + ); self.rebuild_all_and_update_state(term); return; } @@ -442,10 +459,23 @@ impl RenderCache { let mut dirty_lines: std::collections::HashSet = std::collections::HashSet::new(); match damage { DamageSnapshot::Full => { + tracing::debug!( + target: "terminal_residue", + num_lines, + "rebuild_all (TermDamage::Full)" + ); self.rebuild_all_and_update_state(term); return; } DamageSnapshot::Partial(lines) => { + if !lines.is_empty() { + tracing::debug!( + target: "terminal_residue", + damaged = ?lines, + num_lines, + "Partial damage" + ); + } dirty_lines.extend(lines); } } @@ -552,6 +582,34 @@ impl RenderCache { // Update cursor from a fresh content let content = term.renderable_content(); self.update_cursor_from_content(&content); + + // 调试日志:统计 cache 重建后各行的内容分布。 + // 关注底部最后 8 行,若 TUI 仅画了上半部,底部 8 行的 text/bg 应该为空。 + let total = self.lines.len(); + let non_empty_lines = self + .lines + .iter() + .filter(|l| !l.text_runs.is_empty() || !l.background_rects.is_empty()) + .count(); + let mut tail_summary = Vec::new(); + let tail_start = total.saturating_sub(8); + for idx in tail_start..total { + let l = &self.lines[idx]; + tail_summary.push(format!( + "[{idx}] bg={} text={} chars={}", + l.background_rects.len(), + l.text_runs.len(), + l.text_runs.iter().map(|r| r.char_count).sum::(), + )); + } + tracing::debug!( + target: "terminal_residue", + total_lines = total, + non_empty_lines, + in_alt_screen = content.mode.contains(TermMode::ALT_SCREEN), + tail = tail_summary.join(" | "), + "rebuild_all done" + ); } /// Rebuild specified lines @@ -1123,6 +1181,16 @@ impl Element for TerminalElementImpl { let intersection = content_mask.intersect(&terminal_bounds); if intersection.size.height <= px(0.) || intersection.size.width <= px(0.) { + tracing::debug!( + target: "terminal_residue", + lines = self.lines.len(), + num_cols = self.num_cols, + cell_w = ?tb.cell_width, + cell_h = ?tb.cell_height, + origin = ?tb.origin, + content_mask = ?content_mask, + "paint skipped (no intersection)" + ); return; // 完全不可见,跳过渲染 } @@ -1140,6 +1208,26 @@ impl Element for TerminalElementImpl { .ceil() as usize; let visible_end = last_visible.min(self.lines.len()); + // 仅在统计行数 / 像素差异时记录一次,避免每帧爆量 + let cm_h: f32 = content_mask.size.height.into(); + let tb_h: f32 = terminal_height.into(); + if (cm_h - tb_h).abs() > 0.5 || self.lines.len() < visible_end { + tracing::debug!( + target: "terminal_residue", + lines = self.lines.len(), + num_cols = self.num_cols, + cell_w = ?tb.cell_width, + cell_h = ?tb.cell_height, + origin = ?tb.origin, + terminal_bounds_h = ?terminal_height, + content_mask = ?content_mask, + first_visible, + visible_end, + bg_alpha = self.custom_background.a, + "paint metrics" + ); + } + // Paint backgrounds (only visible lines) for line_idx in first_visible..visible_end { let line = &self.lines[line_idx]; diff --git a/crates/terminal_view/src/view.rs b/crates/terminal_view/src/view.rs index c6e412dee6..bf5f406cf6 100644 --- a/crates/terminal_view/src/view.rs +++ b/crates/terminal_view/src/view.rs @@ -602,6 +602,12 @@ pub struct TerminalView { cell_width: Pixels, last_size: Option<(usize, usize)>, + /// 上一帧 alacritty 是否处于 alt screen 模式。 + /// + /// 用于检测主屏与备用屏切换:进入 alt screen 时主动调用 nudge_resize + /// 重发当前尺寸给 PTY,触发 SIGWINCH,让 TUI 应用刷新整屏画面, + /// 避免出现底部残留上一次渲染内容的问题。 + last_alt_screen: bool, scroll_lines_accumulated: f32, mouse_state: MouseState, @@ -1039,6 +1045,7 @@ impl TerminalView { // 初始化为 None,确保首次渲染时会触发 resize, // 将正确的终端尺寸发送给 PTY last_size: None, + last_alt_screen: false, scroll_lines_accumulated: 0.0, mouse_state: MouseState::default(), addon_manager: Self::create_addon_manager(), @@ -3048,6 +3055,16 @@ impl TerminalView { let new_size = (cols, rows); if self.last_size != Some(new_size) { + tracing::info!( + target: "terminal_residue", + old = ?self.last_size, + new = ?new_size, + bounds_w = ?bounds.size.width, + bounds_h = ?bounds.size.height, + cell_width = ?self.cell_width, + line_height = ?self.line_height, + "resize_if_needed -> Terminal::resize" + ); self.last_size = Some(new_size); self.terminal.update(cx, |terminal, _| { terminal.resize( @@ -4195,6 +4212,27 @@ impl Render for TerminalView { let view = cx.entity().clone(); let show_scrollbar = !terminal_mode.contains(TermMode::ALT_SCREEN) && history_size > 0; + // 检测主屏 ↔ alt screen 切换。 + // 进入 alt screen 时(opencode/lazygit/vim 等 TUI 启动),主动重发当前尺寸到 PTY, + // 触发 SIGWINCH 让 TUI 重新查询尺寸并刷新整屏,避免底部残留旧画面。 + // 仅在 last_size 已就绪时(说明 PTY 已收到过正确尺寸)才 nudge, + // 避免覆盖即将到来的首次 resize_if_needed。 + let alt_screen = terminal_mode.contains(TermMode::ALT_SCREEN); + if alt_screen != self.last_alt_screen { + tracing::info!( + target: "terminal_residue", + from = self.last_alt_screen, + to = alt_screen, + last_size = ?self.last_size, + "alt_screen mode transition" + ); + self.last_alt_screen = alt_screen; + if alt_screen && self.last_size.is_some() { + tracing::info!(target: "terminal_residue", "nudge_resize fired on enter alt_screen"); + self.terminal.update(cx, |terminal, _| terminal.nudge_resize()); + } + } + div() .size_full() .flex() From 559d38c9539ed64b137afbb84b16a9c69981023e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=83=A1=E9=A3=9E?= <1835698775@qq.com> Date: Mon, 11 May 2026 18:32:22 +0800 Subject: [PATCH 34/45] chore(main): bump version to 0.4.1 --- main/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main/Cargo.toml b/main/Cargo.toml index afe74cd8cb..e52601a14d 100644 --- a/main/Cargo.toml +++ b/main/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "main" -version = "0.4.0" +version = "0.4.1" publish.workspace = true edition.workspace = true From 660080d88e9ffbcbdb2621af393e5b937451bf31 Mon Sep 17 00:00:00 2001 From: swz128 Date: Mon, 11 May 2026 19:41:25 +0800 Subject: [PATCH 35/45] =?UTF-8?q?fix(edit=5Ftable):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E9=80=89=E4=B8=AD=E5=8D=95=E5=85=83=E6=A0=BC=E6=97=B6=E5=86=85?= =?UTF-8?q?=E5=AE=B9=E5=9B=A0=E8=BE=B9=E6=A1=86=E6=8C=A4=E5=8E=8B=E4=BA=A7?= =?UTF-8?q?=E7=94=9F=E5=81=8F=E7=A7=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 选中单元格使用 border_2 绘制高亮边框,边框会占用盒模型内部空间, 通过 content_box_inset 挤压内容区,导致文字位置跳动。改为在施加 选中边框后等量减少对应方向的 padding,保持内容区原点不变。 --- crates/one_ui/src/edit_table/state.rs | 32 +++++++++++++++++++++------ 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/crates/one_ui/src/edit_table/state.rs b/crates/one_ui/src/edit_table/state.rs index efa383d23d..7dbccb310c 100644 --- a/crates/one_ui/src/edit_table/state.rs +++ b/crates/one_ui/src/edit_table/state.rs @@ -1992,6 +1992,9 @@ where let is_editing = row_ix.is_some() && self.editing_cell == Some((row_ix.unwrap(), col_ix)); let selection_border_color = cx.theme().table_active_border; + let is_single_select_active = + (is_active_cell || is_select_cell) && !is_editing && !is_multi_selection; + let mut cell = div() .id(cell_id) .w(col_width) @@ -2042,14 +2045,29 @@ where } } else { cell = cell.table_cell_size(self.options.size); - cell = match col_padding { - Some(padding) => cell - .pl(padding.left) - .pr(padding.right) - .pt(padding.top) - .pb(padding.bottom), - None => cell, + + let size_pad = self.options.size.table_cell_padding(); + let (target_pt, target_pb, target_pl, target_pr) = match col_padding { + Some(p) => (p.top, p.bottom, p.left, p.right), + None => ( + size_pad.top, + size_pad.bottom, + size_pad.left, + size_pad.right, + ), }; + + // 选中时 border 占用内部空间会挤压内容区,减少等量 padding 补偿 + let has_t = border_top || is_single_select_active; + let has_b = border_bottom || is_single_select_active; + let has_l = border_left || is_single_select_active; + let has_r = border_right || is_single_select_active; + let b = px(2.); + cell = cell + .pt(if has_t { (target_pt - b).max(px(0.)) } else { target_pt }) + .pb(if has_b { (target_pb - b).max(px(0.)) } else { target_pb }) + .pl(if has_l { (target_pl - b).max(px(0.)) } else { target_pl }) + .pr(if has_r { (target_pr - b).max(px(0.)) } else { target_pr }); } cell From 05b1326d8c95dac3c05a453cc536ca4bd1880367 Mon Sep 17 00:00:00 2001 From: swz128 Date: Tue, 12 May 2026 17:09:15 +0800 Subject: [PATCH 36/45] =?UTF-8?q?fix(edit=5Ftable):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=8F=8C=E5=87=BB=E7=BC=96=E8=BE=91=E6=97=B6=E5=8D=95=E5=85=83?= =?UTF-8?q?=E6=A0=BC=E5=86=85=E5=AE=B9=E4=BD=8D=E7=A7=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 编辑模式和显示模式走了两套不同的布局路径,前者完全跳过 table_cell_size 和边框补偿,渲染的 Input 组件又自带 py/px, 导致文本位置不一致。 改动: - 统一 render_cell 的容器布局,两种模式共用 table_cell_size + 边框补偿 - Input 新增 bare() 模式,跳过自带的 padding/height/items_center, 让父容器完全控制布局,保留所有键盘/鼠标事件处理 --- crates/one_ui/src/edit_table/delegate.rs | 4 ++ crates/one_ui/src/edit_table/state.rs | 58 ++++++++++++++---------- crates/ui/src/input/input.rs | 23 +++++++--- 3 files changed, 53 insertions(+), 32 deletions(-) diff --git a/crates/one_ui/src/edit_table/delegate.rs b/crates/one_ui/src/edit_table/delegate.rs index c047088e15..6d419f19a3 100644 --- a/crates/one_ui/src/edit_table/delegate.rs +++ b/crates/one_ui/src/edit_table/delegate.rs @@ -44,6 +44,7 @@ impl CellEditor { .h_full() .text_base() .appearance(false) +<<<<<<< HEAD .px_2() .py_1() .ml(px(1.)) @@ -60,6 +61,9 @@ impl CellEditor { .ml(px(1.)) .mt(px(1.)) .items_center() +======= + .bare() +>>>>>>> ab8afce4 (fix(edit_table): 修复双击编辑时单元格内容位移) .into_any_element(), CellEditor::DatePicker(picker) => DatePicker::new(picker) .w_full() diff --git a/crates/one_ui/src/edit_table/state.rs b/crates/one_ui/src/edit_table/state.rs index 7dbccb310c..cc6170aa9c 100644 --- a/crates/one_ui/src/edit_table/state.rs +++ b/crates/one_ui/src/edit_table/state.rs @@ -2039,35 +2039,43 @@ where this.bg(cx.theme().warning.opacity(0.15)) }); + // 统一布局:编辑和显示模式使用相同的容器 padding + cell = cell.table_cell_size(self.options.size); + + let size_pad = self.options.size.table_cell_padding(); + let (target_pt, target_pb, target_pl, target_pr) = match col_padding { + Some(p) => (p.top, p.bottom, p.left, p.right), + None => ( + size_pad.top, + size_pad.bottom, + size_pad.left, + size_pad.right, + ), + }; + + // 边框补偿:编辑态始终有 border_2;显示态仅选中时有 + let (has_t, has_b, has_l, has_r) = if is_editing { + (true, true, true, true) + } else { + ( + border_top || is_single_select_active, + border_bottom || is_single_select_active, + border_left || is_single_select_active, + border_right || is_single_select_active, + ) + }; + let b = px(2.); + cell = cell + .pt(if has_t { (target_pt - b).max(px(0.)) } else { target_pt }) + .pb(if has_b { (target_pb - b).max(px(0.)) } else { target_pb }) + .pl(if has_l { (target_pl - b).max(px(0.)) } else { target_pl }) + .pr(if has_r { (target_pr - b).max(px(0.)) } else { target_pr }); + + // 编辑模式:嵌入轻量编辑器(无自带样式,由容器控制布局) if is_editing { if let Some(editor) = &self.editing_input { cell = cell.child(editor.render(window, cx)); } - } else { - cell = cell.table_cell_size(self.options.size); - - let size_pad = self.options.size.table_cell_padding(); - let (target_pt, target_pb, target_pl, target_pr) = match col_padding { - Some(p) => (p.top, p.bottom, p.left, p.right), - None => ( - size_pad.top, - size_pad.bottom, - size_pad.left, - size_pad.right, - ), - }; - - // 选中时 border 占用内部空间会挤压内容区,减少等量 padding 补偿 - let has_t = border_top || is_single_select_active; - let has_b = border_bottom || is_single_select_active; - let has_l = border_left || is_single_select_active; - let has_r = border_right || is_single_select_active; - let b = px(2.); - cell = cell - .pt(if has_t { (target_pt - b).max(px(0.)) } else { target_pt }) - .pb(if has_b { (target_pb - b).max(px(0.)) } else { target_pb }) - .pl(if has_l { (target_pl - b).max(px(0.)) } else { target_pl }) - .pr(if has_r { (target_pr - b).max(px(0.)) } else { target_pr }); } cell diff --git a/crates/ui/src/input/input.rs b/crates/ui/src/input/input.rs index db8d0ebd38..4f6788f97a 100644 --- a/crates/ui/src/input/input.rs +++ b/crates/ui/src/input/input.rs @@ -52,6 +52,7 @@ pub struct Input { selected: bool, disable_ime: bool, rounded: Option, + bare: bool, } impl Sizable for Input { @@ -93,6 +94,7 @@ impl Input { selected: false, disable_ime: false, rounded: None, + bare: false, } } @@ -168,6 +170,13 @@ impl Input { self } + /// 纯编辑器模式:去掉 Input 自带的 padding、height、items_center 等布局样式, + /// 完全由父容器控制布局。用于嵌入表格单元格等场景。 + pub fn bare(mut self) -> Self { + self.bare = true; + self + } + /// Set the tab index for the input, default is 0. pub fn tab_index(mut self, index: isize) -> Self { self.tab_index = index; @@ -400,14 +409,14 @@ impl RenderOnce for Input { .on_mouse_move(window.listener_for(&self.state, InputState::on_mouse_move)) .on_scroll_wheel(window.listener_for(&self.state, InputState::on_scroll_wheel)) .size_full() - .line_height(LINE_HEIGHT) - .input_px(self.size) - .input_py(self.size) - .input_h(self.size) + .when(!self.bare, |this| this.line_height(LINE_HEIGHT)) .input_text_size(self.size) + .when(!self.bare, |this| this.input_px(self.size)) + .when(!self.bare, |this| this.input_py(self.size)) + .when(!self.bare, |this| this.input_h(self.size)) .when(!self.disabled, |this| this.cursor_text()) - .items_center() - .when(state.mode.is_multi_line(), |this| { + .when(!self.bare, |this| this.items_center()) + .when(state.mode.is_multi_line() && !self.bare, |this| { this.h_auto() .when_some(self.height, |this, height| this.h(height)) }) @@ -425,7 +434,7 @@ impl RenderOnce for Input { this.rounded(cx.theme().radius) }) }) - .items_center() + .when(!self.bare, |this| this.items_center()) .gap(gap_x) .refine_style(&self.style) .when( From 6e46fca49b1d8e061d654c3a7a177a73dee4ebda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=83=A1=E9=A3=9E?= <1835698775@qq.com> Date: Tue, 12 May 2026 17:22:36 +0800 Subject: [PATCH 37/45] =?UTF-8?q?feat(terminal):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E5=85=B3=E9=97=AD=20shell=20integration=20=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 SSH 连接配置中新增 disable_shell_integration 字段支持用户关闭 shell 集成注入 - 修改 ssh_backend 逻辑,关闭时跳过安装 shell integration,走裸 request_shell 路径 - 设计 zsh 和 bash wrapper,保留完整用户 shell 行为并集成环境恢复和 source 机制 - 对关闭集成场景增加单测,确保只启动交互 shell channel 不写缓存 - 终端 UI 界面新增禁用 shell 集成功能选项及描述提示 - 修正鼠标 SGR 事件处理,支持 shift 拖拽文本选区穿透,兼容多终端约定 - 更新 Cargo 版本号至 v0.4.1 --- Cargo.lock | 2 +- crates/core/src/storage/models.rs | 6 + crates/terminal/src/ssh_backend.rs | 360 ++++++++++++++++-- crates/terminal/src/terminal.rs | 4 + .../terminal_view/locales/terminal_view.yml | 11 + crates/terminal_view/src/ssh_form_window.rs | 41 +- crates/terminal_view/src/view.rs | 13 +- 7 files changed, 405 insertions(+), 32 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5e602db821..cf1e710683 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6049,7 +6049,7 @@ checksum = "670fdfda89751bc4a84ac13eaa63e205cf0fd22b4c9a5fbfa085b63c1f1d3a30" [[package]] name = "main" -version = "0.4.0" +version = "0.4.1" dependencies = [ "anyhow", "base64 0.22.1", diff --git a/crates/core/src/storage/models.rs b/crates/core/src/storage/models.rs index 8c7586c775..de65b89191 100644 --- a/crates/core/src/storage/models.rs +++ b/crates/core/src/storage/models.rs @@ -193,12 +193,18 @@ pub struct SshParams { /// 初始化脚本 #[serde(skip_serializing_if = "Option::is_none")] pub init_script: Option, +<<<<<<< HEAD /// SFTP 本地目录(留空则使用用户主目录) #[serde(skip_serializing_if = "Option::is_none")] pub sftp_local_directory: Option, /// SFTP 远程目录(留空则使用服务器默认目录) #[serde(skip_serializing_if = "Option::is_none")] pub sftp_remote_directory: Option, +======= + /// 关闭 shell integration 注入(走裸 request_shell,牺牲 prompt hook / 命令记录 / vim 鼠标) + #[serde(skip_serializing_if = "Option::is_none")] + pub disable_shell_integration: Option, +>>>>>>> bf9b852a (feat(terminal): 新增关闭 shell integration 功能) /// 跳板机配置 #[serde(skip_serializing_if = "Option::is_none")] pub jump_server: Option, diff --git a/crates/terminal/src/ssh_backend.rs b/crates/terminal/src/ssh_backend.rs index e74e483b92..e45b6088a3 100644 --- a/crates/terminal/src/ssh_backend.rs +++ b/crates/terminal/src/ssh_backend.rs @@ -90,22 +90,43 @@ fn build_shell_integration_setup_script( let home_marker = shell_single_quote(home_marker); let session_marker = shell_single_quote(session_marker); let shell_marker = shell_single_quote(shell_marker); + + // zsh wrapper 设计:让 ZDOTDIR 始终保持 session_dir/zsh,在该目录下放完整的 4 个 wrapper + // 文件,每个 fan-out 到 $ONETCLI_ORIG_ZDOTDIR 下的同名文件,保留完整 login shell 行为; + // 仅在 .zshrc 末尾追加 integration source,然后还原 ZDOTDIR 给后续 sub-shell。 let zshenv = shell_single_quote( - "_ONETCLI_SESSION_ZDOTDIR=\"$ZDOTDIR\"\n\ - _ONETCLI_ORIG_ZDOTDIR=\"${ONETCLI_ORIG_ZDOTDIR:-$HOME}\"\n\ - [[ -f \"$_ONETCLI_ORIG_ZDOTDIR/.zshenv\" ]] && . \"$_ONETCLI_ORIG_ZDOTDIR/.zshenv\"\n\ - ZDOTDIR=\"$_ONETCLI_SESSION_ZDOTDIR\"\n\ - export ZDOTDIR\n\ - unset _ONETCLI_SESSION_ZDOTDIR _ONETCLI_ORIG_ZDOTDIR\n", + "[[ -n \"${ONETCLI_ORIG_ZDOTDIR:-}\" ]] && [ -f \"$ONETCLI_ORIG_ZDOTDIR/.zshenv\" ] \ + && . \"$ONETCLI_ORIG_ZDOTDIR/.zshenv\"\n", + ); + let zprofile = shell_single_quote( + "[[ -n \"${ONETCLI_ORIG_ZDOTDIR:-}\" ]] && [ -f \"$ONETCLI_ORIG_ZDOTDIR/.zprofile\" ] \ + && . \"$ONETCLI_ORIG_ZDOTDIR/.zprofile\"\n", ); let zshrc = shell_single_quote(&format!( - "_ONETCLI_ORIG_ZDOTDIR=\"${{ONETCLI_ORIG_ZDOTDIR:-$HOME}}\"\n\ - [[ -f \"$_ONETCLI_ORIG_ZDOTDIR/.zshrc\" ]] && . \"$_ONETCLI_ORIG_ZDOTDIR/.zshrc\"\n\ - . \"{integration_source}\"\n" + "[[ -n \"${{ONETCLI_ORIG_ZDOTDIR:-}}\" ]] && [ -f \"$ONETCLI_ORIG_ZDOTDIR/.zshrc\" ] \ + && . \"$ONETCLI_ORIG_ZDOTDIR/.zshrc\"\n\ + . \"{integration_source}\"\n\ + ZDOTDIR=\"${{ONETCLI_ORIG_ZDOTDIR:-$HOME}}\"\n" )); + let zlogin = shell_single_quote( + "[[ -n \"${ONETCLI_ORIG_ZDOTDIR:-}\" ]] && [ -f \"$ONETCLI_ORIG_ZDOTDIR/.zlogin\" ] \ + && . \"$ONETCLI_ORIG_ZDOTDIR/.zlogin\"\n", + ); + // bash wrapper:`exec bash --rcfile X -i` 是 interactive non-login,跳过 /etc/profile 与 + // ~/.bash_profile 等。这里手动模拟 login chain,然后再显式 source ~/.bashrc + integration。 + // ONETCLI_LOGIN_SIMULATED guard 防止 .bash_profile 内 `exec bash -l` 等场景二次进入时重复 + // 加载 profile 链。 let bashrc = shell_single_quote(&format!( - "[ -f \"$HOME/.bashrc\" ] && . \"$HOME/.bashrc\"\n\ - . \"{integration_source}\"\n" + "if [ -z \"${{ONETCLI_LOGIN_SIMULATED:-}}\" ]; then\n\ + \x20\x20\x20\x20export ONETCLI_LOGIN_SIMULATED=1\n\ + \x20\x20\x20\x20[ -r /etc/profile ] && . /etc/profile\n\ + \x20\x20\x20\x20for __onetcli_profile in \"$HOME/.bash_profile\" \"$HOME/.bash_login\" \"$HOME/.profile\"; do\n\ + \x20\x20\x20\x20\x20\x20\x20\x20if [ -r \"$__onetcli_profile\" ]; then . \"$__onetcli_profile\"; break; fi\n\ + \x20\x20\x20\x20done\n\ + \x20\x20\x20\x20unset __onetcli_profile\n\ + fi\n\ + [ -r \"$HOME/.bashrc\" ] && . \"$HOME/.bashrc\"\n\ + . \"{integration_source}\"\n" )); format!( @@ -118,7 +139,9 @@ fn build_shell_integration_setup_script( "mkdir -p \"$zsh_dir\" \"$bash_dir\"\n", "printf %s {script} > \"$integration_path\"\n", "printf %s {zshenv} > \"$zsh_dir/.zshenv\"\n", + "printf %s {zprofile} > \"$zsh_dir/.zprofile\"\n", "printf %s {zshrc} > \"$zsh_dir/.zshrc\"\n", + "printf %s {zlogin} > \"$zsh_dir/.zlogin\"\n", "printf %s {bashrc} > \"$bash_dir/.bashrc\"\n", "printf '%s%s\\n' {home_marker} \"$HOME\"\n", "printf '%s%s\\n' {session_marker} \"$session_dir\"\n", @@ -128,7 +151,9 @@ fn build_shell_integration_setup_script( session_key = session_key, script = script, zshenv = zshenv, + zprofile = zprofile, zshrc = zshrc, + zlogin = zlogin, bashrc = bashrc, success_marker = success_marker, home_marker = home_marker, @@ -190,11 +215,16 @@ impl SshBackend { notify_tx: UnboundedSender<()>, on_disconnect: Option>, init_commands: Option, + disable_shell_integration: bool, ) -> anyhow::Result { - let (client, mut channel) = - Self::establish_channel(&session_manager, &pty_config, connection_id) - .await - .map_err(add_connect_error_context)?; + let (client, mut channel) = Self::establish_channel( + &session_manager, + &pty_config, + connection_id, + disable_shell_integration, + ) + .await + .map_err(add_connect_error_context)?; // 关联变量,避免 clippy 警告未使用。 let _keep_client = client; @@ -472,6 +502,7 @@ impl SshBackend { session_manager: &Arc, pty_config: &PtyConfig, connection_id: Option, + disable_shell_integration: bool, ) -> anyhow::Result<(Arc>, ssh::RusshChannel)> { let mut attempt = 0usize; loop { @@ -480,7 +511,14 @@ impl SshBackend { let result = { let mut guard = client.lock().await; - Self::prepare_ssh_channel(&mut *guard, pty_config, connection_id, cached).await + Self::prepare_ssh_channel( + &mut *guard, + pty_config, + connection_id, + cached, + disable_shell_integration, + ) + .await }; match result { @@ -510,8 +548,13 @@ impl SshBackend { pty_config: &PtyConfig, connection_id: Option, cached: Option, + disable_shell_integration: bool, ) -> anyhow::Result<(C::Channel, Option)> { - let (setup, new_setup) = if let Some(cached) = cached { + let (setup, new_setup) = if disable_shell_integration { + // 用户在连接配置里显式关闭了 shell integration:跳过安装,走裸 request_shell 路径, + // 不向 manager 写入任何缓存,确保下次连接如果用户改回开启时还能正常走 setup。 + (None, None) + } else if let Some(cached) = cached { (Some(cached), None) } else { // 首次连接:尝试安装 integration,失败降级为"无 integration"分支。 @@ -920,9 +963,14 @@ mod tests { let (interactive_channel, interactive_state) = MockChannel::new([], false); let mut client = MockClient::new([setup_channel, interactive_channel]); - let result = - SshBackend::prepare_ssh_channel(&mut client, &PtyConfig::default(), Some(42), None) - .await; + let result = SshBackend::prepare_ssh_channel( + &mut client, + &PtyConfig::default(), + Some(42), + None, + false, + ) + .await; let (_channel, new_setup) = result.expect("安装 shell integration 不应占用交互 shell 的 channel"); @@ -979,9 +1027,14 @@ mod tests { let (interactive_channel, interactive_state) = MockChannel::new([], false); let mut client = MockClient::new([setup_channel, interactive_channel]); - let result = - SshBackend::prepare_ssh_channel(&mut client, &PtyConfig::default(), Some(42), None) - .await; + let result = SshBackend::prepare_ssh_channel( + &mut client, + &PtyConfig::default(), + Some(42), + None, + false, + ) + .await; let (_channel, new_setup) = result.expect("bash shell wrapper 应通过独立交互 channel 启动"); assert!(new_setup.is_some()); @@ -1084,10 +1137,15 @@ mod tests { let (interactive_channel, interactive_state) = MockChannel::new([], false); let mut client = MockClient::new([setup_channel, interactive_channel]); - let (_ch, new_setup) = - SshBackend::prepare_ssh_channel(&mut client, &PtyConfig::default(), Some(42), None) - .await - .expect("setup 失败时 prepare_ssh_channel 不应整体失败"); + let (_ch, new_setup) = SshBackend::prepare_ssh_channel( + &mut client, + &PtyConfig::default(), + Some(42), + None, + false, + ) + .await + .expect("setup 失败时 prepare_ssh_channel 不应整体失败"); assert!( new_setup.is_none(), @@ -1122,6 +1180,7 @@ mod tests { &PtyConfig::default(), Some(42), Some(cached), + false, ) .await .expect("缓存命中时应直接复用 setup 结果"); @@ -1145,6 +1204,34 @@ mod tests { ); } + #[tokio::test] + async fn prepare_ssh_channel_skips_setup_when_disabled() { + // 用户在连接配置里显式关闭 shell integration:不开 setup channel,只开 1 个 interactive + // channel 走裸 PTY + shell;且不向 manager 写入任何缓存。 + let (interactive_channel, interactive_state) = MockChannel::new([], false); + let mut client = MockClient::new([interactive_channel]); + + let (_ch, new_setup) = SshBackend::prepare_ssh_channel( + &mut client, + &PtyConfig::default(), + Some(42), + None, + true, + ) + .await + .expect("禁用 shell integration 时仍应建立 interactive channel"); + + assert!( + new_setup.is_none(), + "禁用路径不应向 manager 写入任何 integration 缓存" + ); + assert_eq!( + recorded_ops(&interactive_state), + vec![ChannelOp::RequestPty, ChannelOp::RequestShell], + "禁用路径只跑 pty + shell,不调 set_env / exec wrapper" + ); + } + #[tokio::test] async fn try_install_shell_integration_times_out_in_ten_seconds() { // 测试里用短 timeout 验证逻辑;生产路径仍走 10s 常量。 @@ -1239,16 +1326,50 @@ mod tests { fs::read_to_string(session_dir.join("zsh/.zshrc")).expect("应读取 zshrc wrapper"); assert!( session_dir.join("zsh/.zshenv").is_file(), - "应写入 zsh session wrapper" + "应写入 zsh session wrapper (.zshenv)" + ); + assert!( + session_dir.join("zsh/.zprofile").is_file(), + "应写入 zsh session wrapper (.zprofile)" ); assert!( session_dir.join("zsh/.zshrc").is_file(), "应写入 zshrc session wrapper" ); + assert!( + session_dir.join("zsh/.zlogin").is_file(), + "应写入 zsh session wrapper (.zlogin)" + ); assert!( session_dir.join("bash/.bashrc").is_file(), "应写入 bash session wrapper" ); + + let zshrc_wrapper = + fs::read_to_string(session_dir.join("zsh/.zshrc")).expect("应读取 zshrc wrapper"); + assert!( + zshrc_wrapper.contains("shell_integration.sh"), + ".zshrc wrapper 应在末尾 source integration: {zshrc_wrapper}" + ); + assert!( + zshrc_wrapper.contains("ZDOTDIR=\"${ONETCLI_ORIG_ZDOTDIR:-$HOME}\""), + ".zshrc wrapper 应在末尾还原 ZDOTDIR: {zshrc_wrapper}" + ); + + let bashrc_wrapper = + fs::read_to_string(session_dir.join("bash/.bashrc")).expect("应读取 bashrc wrapper"); + assert!( + bashrc_wrapper.contains("ONETCLI_LOGIN_SIMULATED"), + ".bashrc wrapper 应包含 ONETCLI_LOGIN_SIMULATED guard 模拟 login chain: {bashrc_wrapper}" + ); + assert!( + bashrc_wrapper.contains("/etc/profile"), + ".bashrc wrapper 应模拟 login shell 加载 /etc/profile: {bashrc_wrapper}" + ); + assert!( + bashrc_wrapper.contains(".bash_profile"), + ".bashrc wrapper 应模拟 login shell 尝试 ~/.bash_profile: {bashrc_wrapper}" + ); assert_eq!( fs::read_to_string(&bashrc_path).expect("应保留用户 bashrc"), "# user bashrc\n" @@ -1336,6 +1457,189 @@ mod tests { let _ = fs::remove_dir_all(&temp_dir); } + #[cfg(unix)] + #[test] + fn bash_wrapper_runs_bash_profile_chain_and_integration() { + if Command::new("bash").arg("--version").output().is_err() { + eprintln!("跳过 bash wrapper 测试:当前环境未安装 bash"); + return; + } + let temp_dir = std::env::temp_dir().join(format!( + "onetcli-bash-wrapper-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system time should be after unix epoch") + .as_nanos() + )); + fs::create_dir_all(&temp_dir).expect("应创建临时目录"); + + let home_dir = temp_dir.join("home"); + fs::create_dir_all(&home_dir).expect("应创建 home 目录"); + fs::write( + home_dir.join(".bash_profile"), + "export __ONETCLI_BASH_PROFILE_LOADED=1\n", + ) + .expect("应写入用户 .bash_profile"); + fs::write( + home_dir.join(".bashrc"), + "[[ $- != *i* ]] && return\nexport __ONETCLI_USER_BASHRC=1\n", + ) + .expect("应写入用户 .bashrc"); + + let script = "export __ONETCLI_INTEGRATION_LOADED=1\n"; + let command = build_shell_integration_setup_script( + script, + "42", + "__TEST_OK__", + "__HOME__=", + "__SESSION__=", + "__SHELL__=", + ); + let setup = Command::new("sh") + .arg("-c") + .arg(&command) + .env("HOME", &home_dir) + .output() + .expect("应执行 setup 脚本"); + assert!( + setup.status.success(), + "setup 脚本应成功: {}", + String::from_utf8_lossy(&setup.stderr) + ); + + let wrapper = home_dir.join(".config/onetcli/sessions/42/bash/.bashrc"); + let output = Command::new("bash") + .arg("--rcfile") + .arg(&wrapper) + .arg("-i") + .arg("-c") + .arg( + "echo profile=$__ONETCLI_BASH_PROFILE_LOADED \ + rc=$__ONETCLI_USER_BASHRC \ + integration=$__ONETCLI_INTEGRATION_LOADED \ + login=$ONETCLI_LOGIN_SIMULATED", + ) + .env("HOME", &home_dir) + .env("PS1", "$ ") + .output() + .expect("应执行 bash wrapper"); + + assert!( + output.status.success(), + "bash wrapper 应成功执行: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("profile=1"), + "bash wrapper 应模拟 login shell 加载 .bash_profile,实际: {stdout}" + ); + assert!( + stdout.contains("rc=1"), + "bash wrapper 应显式 source 用户 .bashrc,实际: {stdout}" + ); + assert!( + stdout.contains("integration=1"), + "bash wrapper 应在末尾 source shell integration,实际: {stdout}" + ); + assert!( + stdout.contains("login=1"), + "bash wrapper 应设置 ONETCLI_LOGIN_SIMULATED guard,实际: {stdout}" + ); + + let _ = fs::remove_dir_all(&temp_dir); + } + + #[cfg(unix)] + #[test] + fn zsh_wrapper_loads_user_files_integration_and_restores_zdotdir() { + if Command::new("zsh").arg("--version").output().is_err() { + eprintln!("跳过 zsh wrapper 测试:当前环境未安装 zsh"); + return; + } + let temp_dir = std::env::temp_dir().join(format!( + "onetcli-zsh-wrapper-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system time should be after unix epoch") + .as_nanos() + )); + fs::create_dir_all(&temp_dir).expect("应创建临时目录"); + + let home_dir = temp_dir.join("home"); + fs::create_dir_all(&home_dir).expect("应创建 home 目录"); + fs::write(home_dir.join(".zshenv"), "export __ONETCLI_USER_ZSHENV=1\n") + .expect("应写入用户 .zshenv"); + fs::write(home_dir.join(".zshrc"), "export __ONETCLI_USER_ZSHRC=1\n") + .expect("应写入用户 .zshrc"); + + let script = "export __ONETCLI_INTEGRATION_LOADED=1\n"; + let command = build_shell_integration_setup_script( + script, + "42", + "__TEST_OK__", + "__HOME__=", + "__SESSION__=", + "__SHELL__=", + ); + let setup = Command::new("sh") + .arg("-c") + .arg(&command) + .env("HOME", &home_dir) + .output() + .expect("应执行 setup 脚本"); + assert!( + setup.status.success(), + "setup 脚本应成功: {}", + String::from_utf8_lossy(&setup.stderr) + ); + + let zsh_dir = home_dir.join(".config/onetcli/sessions/42/zsh"); + let output = Command::new("zsh") + .arg("-i") + .arg("-c") + .arg( + "echo zshenv=$__ONETCLI_USER_ZSHENV \ + zshrc=$__ONETCLI_USER_ZSHRC \ + integration=$__ONETCLI_INTEGRATION_LOADED \ + zdotdir=$ZDOTDIR", + ) + .env("HOME", &home_dir) + .env("ZDOTDIR", &zsh_dir) + .env("ONETCLI_ORIG_ZDOTDIR", &home_dir) + .output() + .expect("应执行 zsh wrapper"); + + assert!( + output.status.success(), + "zsh wrapper 应成功执行: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("zshenv=1"), + "zsh wrapper 应通过 fan-out 加载用户 .zshenv,实际: {stdout}" + ); + assert!( + stdout.contains("zshrc=1"), + "zsh wrapper 应通过 fan-out 加载用户 .zshrc,实际: {stdout}" + ); + assert!( + stdout.contains("integration=1"), + "zsh wrapper 应在 .zshrc 末尾 source shell integration,实际: {stdout}" + ); + assert!( + stdout.contains(&format!("zdotdir={}", home_dir.display())), + "zsh wrapper 应在 .zshrc 末尾把 ZDOTDIR 还原为 $HOME,实际: {stdout}" + ); + + let _ = fs::remove_dir_all(&temp_dir); + } + #[test] fn parse_osc_payload_decodes_recorded_command() { let payload = "1337;Command=Z2l0IHN0YXR1cw=="; diff --git a/crates/terminal/src/terminal.rs b/crates/terminal/src/terminal.rs index 84fcdce211..4f67be52a5 100644 --- a/crates/terminal/src/terminal.rs +++ b/crates/terminal/src/terminal.rs @@ -332,6 +332,8 @@ pub enum TerminalConnectionKind { pub struct SshTerminalConfig { pub ssh_config: SshConnectConfig, pub pty_config: PtyConfig, + /// 关闭 shell integration 注入:走裸 request_shell,失去 OSC 集成。 + pub disable_shell_integration: bool, } #[derive(Clone, Copy, PartialEq, Eq, Debug)] @@ -1600,6 +1602,7 @@ impl Terminal { let config = SshTerminalConfig { ssh_config, pty_config, + disable_shell_integration: ssh_params.disable_shell_integration.unwrap_or(false), }; let cols = config.pty_config.width as usize; @@ -2017,6 +2020,7 @@ impl Terminal { move |stage| { let _ = progress_tx.send(stage); }, + config.disable_shell_integration, ) .await }); diff --git a/crates/terminal_view/locales/terminal_view.yml b/crates/terminal_view/locales/terminal_view.yml index 0c4e227f4a..a8d7983540 100644 --- a/crates/terminal_view/locales/terminal_view.yml +++ b/crates/terminal_view/locales/terminal_view.yml @@ -344,6 +344,7 @@ SSH: en: Default working directory zh-CN: 默认工作目录 zh-HK: 默認工作目錄 +<<<<<<< HEAD sftp_local_directory: en: SFTP Local Directory zh-CN: SFTP 本地目录 @@ -360,6 +361,16 @@ SSH: en: Leave empty to use server default directory zh-CN: 留空则使用服务器默认目录 zh-HK: 留空則使用伺服器預設目錄 +======= + disable_shell_integration: + en: Disable Shell Integration + zh-CN: 禁用 Shell 集成 + zh-HK: 禁用 Shell 集成 + disable_shell_integration_desc: + en: Run native login shell without OSC injection (no prompt hook, command recording, or vim mouse) + zh-CN: 走裸 login shell,不注入 OSC(失去命令记录 / prompt hook / vim 鼠标) + zh-HK: 走裸 login shell,不注入 OSC(失去命令記錄 / prompt hook / vim 鼠標) +>>>>>>> bf9b852a (feat(terminal): 新增关闭 shell integration 功能) # 其他设置 remark: en: Remark diff --git a/crates/terminal_view/src/ssh_form_window.rs b/crates/terminal_view/src/ssh_form_window.rs index d2977f218e..c526581f8b 100644 --- a/crates/terminal_view/src/ssh_form_window.rs +++ b/crates/terminal_view/src/ssh_form_window.rs @@ -171,6 +171,9 @@ pub struct SshFormWindow { // 云同步开关 sync_enabled: bool, + // 关闭 shell integration 注入(走裸 request_shell,失去 OSC 集成) + disable_shell_integration: bool, + is_testing: bool, test_status_message: Option, test_started_at: Option, @@ -348,6 +351,7 @@ impl SshFormWindow { let mut enable_legacy_kex = false; let mut sync_enabled = true; // 默认启用云同步 let mut editing_credential_ref: Option = None; + let mut disable_shell_integration = false; if let Some(ref conn) = config.editing_connection { // 加载同步状态 @@ -413,6 +417,7 @@ impl SshFormWindow { if let Some(ref dir) = params.sftp_remote_directory { sftp_remote_directory_input.update(cx, |s, cx| s.set_value(dir, window, cx)); } + disable_shell_integration = params.disable_shell_integration.unwrap_or(false); // 加载跳板机设置 if let Some(ref jump) = params.jump_server { @@ -500,6 +505,7 @@ impl SshFormWindow { pending_key_content, last_tested_signature: None, sync_enabled, + disable_shell_integration, is_testing: false, test_status_message: None, test_started_at: None, @@ -839,6 +845,11 @@ impl SshFormWindow { init_script, sftp_local_directory, sftp_remote_directory, + disable_shell_integration: if self.disable_shell_integration { + Some(true) + } else { + None + }, jump_server, proxy, }) @@ -1298,7 +1309,7 @@ impl SshFormWindow { } /// 渲染初始化标签页 - fn render_init_tab(&self) -> impl IntoElement { + fn render_init_tab(&self, cx: &mut Context) -> impl IntoElement { v_flex() .gap_2() .child(self.render_form_row( @@ -1317,6 +1328,31 @@ impl SshFormWindow { &t!("SSH.sftp_remote_directory"), self.styled_input(Input::new(&self.sftp_remote_directory_input)), )) + .child( + self.render_form_row(&t!("SSH.init_script"), Input::new(&self.init_script_input)), + ) + .child( + self.render_form_row( + &t!("SSH.disable_shell_integration"), + h_flex() + .gap_2() + .child( + Checkbox::new("disable-shell-integration") + .checked(self.disable_shell_integration) + .on_click(cx.listener(|this, _, _, cx| { + this.disable_shell_integration = + !this.disable_shell_integration; + cx.notify(); + })), + ) + .child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child(t!("SSH.disable_shell_integration_desc").to_string()), + ), + ), + ) } /// 渲染跳板机标签页 @@ -1631,7 +1667,7 @@ impl Render for SshFormWindow { .overflow_y_scroll() .child(match active_tab { 0 => self.render_basic_tab(cx).into_any_element(), - 1 => self.render_init_tab().into_any_element(), + 1 => self.render_init_tab(cx).into_any_element(), 2 => self.render_jump_server_tab(cx).into_any_element(), 3 => self.render_proxy_tab(cx).into_any_element(), 4 => self.render_advanced_tab(cx).into_any_element(), @@ -1724,6 +1760,7 @@ mod tests { init_script: Some("pwd".to_string()), sftp_local_directory: None, sftp_remote_directory: None, + disable_shell_integration: None, jump_server: None, proxy: None, } diff --git a/crates/terminal_view/src/view.rs b/crates/terminal_view/src/view.rs index bf5f406cf6..b4d3643d5a 100644 --- a/crates/terminal_view/src/view.rs +++ b/crates/terminal_view/src/view.rs @@ -3534,6 +3534,11 @@ impl TerminalView { /// 当终端启用 SGR 鼠标 + 任意鼠标报告模式时,把按钮按下/释放事件以 SGR 形式 /// 回报给 PTY。返回 true 表示已经处理,调用方应跳过 selection/dismiss/paste 等本地行为。 + /// + /// 特殊穿透:Shift+Left 永远走终端自身的文本选区,不向 TUI 转发 —— 这是 xterm/iTerm/ + /// kitty/wezterm 等的通用约定,让用户在 vim/tmux 等捕获鼠标的应用里仍能复制文本。 + /// 同理 mouse_up 时,如果当前正在终端选区(由 shift+drag 启动),也跳过 release 回报, + /// 避免在 release 阶段 shift 已松开就把 release 事件错发给 TUI、丢掉 selection 收尾。 fn try_report_sgr_mouse_button( &mut self, button: MouseButton, @@ -3542,6 +3547,11 @@ impl TerminalView { pressed: bool, cx: &mut Context, ) -> bool { + if button == MouseButton::Left + && (modifiers.shift || (!pressed && self.mouse_state.selecting)) + { + return false; + } let mode = self.terminal.read(cx).mode(); if !(mode.contains(TermMode::SGR_MOUSE) && mode.intersects(TermMode::MOUSE_MODE)) { return false; @@ -4229,7 +4239,8 @@ impl Render for TerminalView { self.last_alt_screen = alt_screen; if alt_screen && self.last_size.is_some() { tracing::info!(target: "terminal_residue", "nudge_resize fired on enter alt_screen"); - self.terminal.update(cx, |terminal, _| terminal.nudge_resize()); + self.terminal + .update(cx, |terminal, _| terminal.nudge_resize()); } } From 83b435dba7377695671439785ab99f1f72e481eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=9C=E5=86=9C?= Date: Wed, 13 May 2026 01:19:09 +0800 Subject: [PATCH 38/45] =?UTF-8?q?merge(dev):=20=E5=90=8C=E6=AD=A5=20dev=20?= =?UTF-8?q?=E6=9C=80=E6=96=B0=E6=9B=B4=E6=94=B9=E5=B9=B6=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=86=B2=E7=AA=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - edit_table: 修复边框补偿逻辑和单元格编辑 - terminal: 增强 PTY 后端、shell integration 控制 - remote_file_editor: 保留当前分支实现 - popup_window: 保持当前分支 API 兼容性 - 解决 SshParams disable_shell_integration 字段冲突 Co-Authored-By: Claude Opus 4.7 --- Cargo.lock | 20 +- Cargo.toml | 2 +- crates/core/src/lib.rs | 1 - crates/core/src/storage/models.rs | 3 - crates/one_ui/src/edit_table/delegate.rs | 14 +- crates/one_ui/src/edit_table/state.rs | 20 +- .../locales/remote_file_editor.yml | 4 - crates/remote_file_editor/src/close_guard.rs | 87 +-- .../remote_file_editor/src/editor_window.rs | 737 ++++-------------- crates/remote_file_editor/src/lib.rs | 5 +- crates/terminal/src/pty_backend.rs | 5 +- crates/terminal/src/serial_backend.rs | 4 +- crates/terminal/src/ssh_backend.rs | 390 ++------- crates/terminal/src/terminal.rs | 122 +-- .../terminal_view/locales/terminal_view.yml | 11 - crates/terminal_view/src/keys.rs | 156 ---- crates/terminal_view/src/ssh_form_window.rs | 42 +- crates/terminal_view/src/terminal_element.rs | 444 ++--------- crates/terminal_view/src/view.rs | 294 +------ themes/codium_dark.jsonc | 16 +- 20 files changed, 349 insertions(+), 2028 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cf1e710683..4dc43f0583 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3133,7 +3133,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -5682,7 +5682,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07033963ba89ebaf1584d767badaa2e8fcec21aedea6b8c0346d487d49c28667" dependencies = [ "cfg-if", - "windows-targets 0.48.5", + "windows-targets 0.53.3", ] [[package]] @@ -8247,7 +8247,7 @@ dependencies = [ "once_cell", "socket2 0.5.10", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -9220,7 +9220,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -9233,7 +9233,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.9.4", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -9345,7 +9345,7 @@ dependencies = [ "security-framework 3.6.0", "security-framework-sys", "webpki-root-certs 0.26.11", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -9982,7 +9982,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b1fdf65dd6331831494dd616b30351c38e96e45921a27745cf98490458b90bb" dependencies = [ - "dirs 5.0.1", + "dirs 6.0.0", ] [[package]] @@ -10314,7 +10314,7 @@ dependencies = [ "cfg-if", "libc", "psm", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -10826,7 +10826,7 @@ dependencies = [ "getrandom 0.3.3", "once_cell", "rustix 1.0.8", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -12818,7 +12818,7 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.59.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 13bc73b180..f6684e34c7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,7 +23,7 @@ members = [ "crates/er_flow", "crates/terminal", "crates/terminal_view", - "main", "crates/ssh", "crates/sftp", "crates/sftp_view", "crates/one_ui", "crates/redis_view", "crates/license_tool", "crates/mongodb_view", "crates/remote_file_editor"] + "main", "crates/ssh", "crates/sftp", "crates/sftp_view", "crates/one_ui", "crates/redis_view", "crates/mongodb_view", "crates/remote_file_editor"] resolver = "2" [workspace.package] diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 27c20278ef..f9f8585cbf 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -47,5 +47,4 @@ pub fn init(cx: &mut App) { agent::init(cx); connection_notifier::init(cx); certificate_notifier::init(cx); - popup_window::init(cx); } diff --git a/crates/core/src/storage/models.rs b/crates/core/src/storage/models.rs index de65b89191..b83c7e9c00 100644 --- a/crates/core/src/storage/models.rs +++ b/crates/core/src/storage/models.rs @@ -193,18 +193,15 @@ pub struct SshParams { /// 初始化脚本 #[serde(skip_serializing_if = "Option::is_none")] pub init_script: Option, -<<<<<<< HEAD /// SFTP 本地目录(留空则使用用户主目录) #[serde(skip_serializing_if = "Option::is_none")] pub sftp_local_directory: Option, /// SFTP 远程目录(留空则使用服务器默认目录) #[serde(skip_serializing_if = "Option::is_none")] pub sftp_remote_directory: Option, -======= /// 关闭 shell integration 注入(走裸 request_shell,牺牲 prompt hook / 命令记录 / vim 鼠标) #[serde(skip_serializing_if = "Option::is_none")] pub disable_shell_integration: Option, ->>>>>>> bf9b852a (feat(terminal): 新增关闭 shell integration 功能) /// 跳板机配置 #[serde(skip_serializing_if = "Option::is_none")] pub jump_server: Option, diff --git a/crates/one_ui/src/edit_table/delegate.rs b/crates/one_ui/src/edit_table/delegate.rs index 6d419f19a3..87b4400f04 100644 --- a/crates/one_ui/src/edit_table/delegate.rs +++ b/crates/one_ui/src/edit_table/delegate.rs @@ -44,26 +44,14 @@ impl CellEditor { .h_full() .text_base() .appearance(false) -<<<<<<< HEAD - .px_2() - .py_1() - .ml(px(1.)) - .mt(px(3.)) - .items_center() + .bare() .into_any_element(), CellEditor::NumberInput(input) => Input::new(input) .w_full() .h_full() .text_base() .appearance(false) - .px_2() - .py_1() - .ml(px(1.)) - .mt(px(1.)) - .items_center() -======= .bare() ->>>>>>> ab8afce4 (fix(edit_table): 修复双击编辑时单元格内容位移) .into_any_element(), CellEditor::DatePicker(picker) => DatePicker::new(picker) .w_full() diff --git a/crates/one_ui/src/edit_table/state.rs b/crates/one_ui/src/edit_table/state.rs index cc6170aa9c..5001459016 100644 --- a/crates/one_ui/src/edit_table/state.rs +++ b/crates/one_ui/src/edit_table/state.rs @@ -2053,23 +2053,11 @@ where ), }; - // 边框补偿:编辑态始终有 border_2;显示态仅选中时有 - let (has_t, has_b, has_l, has_r) = if is_editing { - (true, true, true, true) - } else { - ( - border_top || is_single_select_active, - border_bottom || is_single_select_active, - border_left || is_single_select_active, - border_right || is_single_select_active, - ) - }; - let b = px(2.); cell = cell - .pt(if has_t { (target_pt - b).max(px(0.)) } else { target_pt }) - .pb(if has_b { (target_pb - b).max(px(0.)) } else { target_pb }) - .pl(if has_l { (target_pl - b).max(px(0.)) } else { target_pl }) - .pr(if has_r { (target_pr - b).max(px(0.)) } else { target_pr }); + .pt(target_pt) + .pb(target_pb) + .pl(target_pl) + .pr(target_pr); // 编辑模式:嵌入轻量编辑器(无自带样式,由容器控制布局) if is_editing { diff --git a/crates/remote_file_editor/locales/remote_file_editor.yml b/crates/remote_file_editor/locales/remote_file_editor.yml index a3f1c3829b..933185e79f 100644 --- a/crates/remote_file_editor/locales/remote_file_editor.yml +++ b/crates/remote_file_editor/locales/remote_file_editor.yml @@ -26,10 +26,6 @@ RemoteFileEditor: en: Soft Wrap zh-CN: 自动换行 zh-HK: 自動換行 - close_tab: - en: Close Tab - zh-CN: 关闭页签 - zh-HK: 關閉頁籤 discard: en: Discard zh-CN: 放弃更改 diff --git a/crates/remote_file_editor/src/close_guard.rs b/crates/remote_file_editor/src/close_guard.rs index 1afe57b8d0..0422298160 100644 --- a/crates/remote_file_editor/src/close_guard.rs +++ b/crates/remote_file_editor/src/close_guard.rs @@ -15,42 +15,9 @@ pub fn decide_close_intercept(is_dirty: bool, prompt_open: bool) -> CloseInterce } } -pub fn find_tab_index(paths: &[String], remote_path: &str) -> Option { - paths.iter().position(|path| path == remote_path) -} - -pub fn active_index_after_open(paths: &[String], remote_path: &str) -> usize { - find_tab_index(paths, remote_path).unwrap_or(paths.len()) -} - -pub fn active_index_after_close( - active_index: usize, - closed_index: usize, - tab_count: usize, -) -> Option { - if tab_count <= 1 || closed_index >= tab_count { - return None; - } - - if closed_index < active_index { - Some(active_index - 1) - } else if closed_index == active_index && active_index >= tab_count - 1 { - Some(active_index - 1) - } else { - Some(active_index) - } -} - -pub fn has_dirty_tabs(dirty_tabs: &[bool]) -> bool { - dirty_tabs.iter().any(|dirty| *dirty) -} - #[cfg(test)] mod tests { - use super::{ - CloseIntercept, active_index_after_close, active_index_after_open, decide_close_intercept, - find_tab_index, has_dirty_tabs, - }; + use super::{CloseIntercept, decide_close_intercept}; #[test] fn allows_close_when_editor_is_clean() { @@ -66,56 +33,4 @@ mod tests { fn ignores_repeated_close_while_prompt_is_open() { assert_eq!(decide_close_intercept(true, true), CloseIntercept::Ignore); } - - #[test] - fn finds_existing_tab_index_by_remote_path() { - let paths = vec!["/tmp/a.txt".to_string(), "/tmp/b.txt".to_string()]; - - assert_eq!(find_tab_index(&paths, "/tmp/b.txt"), Some(1)); - } - - #[test] - fn returns_next_index_for_new_remote_path() { - let paths = vec!["/tmp/a.txt".to_string(), "/tmp/b.txt".to_string()]; - - assert_eq!(active_index_after_open(&paths, "/tmp/c.txt"), 2); - } - - #[test] - fn reuses_existing_index_for_existing_remote_path() { - let paths = vec!["/tmp/a.txt".to_string(), "/tmp/b.txt".to_string()]; - - assert_eq!(active_index_after_open(&paths, "/tmp/a.txt"), 0); - } - - #[test] - fn keeps_active_index_when_closing_tab_after_active_tab() { - assert_eq!(active_index_after_close(0, 2, 3), Some(0)); - } - - #[test] - fn shifts_active_index_left_when_closing_tab_before_active_tab() { - assert_eq!(active_index_after_close(2, 0, 3), Some(1)); - } - - #[test] - fn activates_left_tab_when_closing_last_active_tab() { - assert_eq!(active_index_after_close(2, 2, 3), Some(1)); - } - - #[test] - fn keeps_same_index_when_closing_middle_active_tab_with_right_neighbor() { - assert_eq!(active_index_after_close(1, 1, 3), Some(1)); - } - - #[test] - fn returns_none_when_closing_last_remaining_tab() { - assert_eq!(active_index_after_close(0, 0, 1), None); - } - - #[test] - fn detects_any_dirty_tab() { - assert!(has_dirty_tabs(&[false, true, false])); - assert!(!has_dirty_tabs(&[false, false])); - } } diff --git a/crates/remote_file_editor/src/editor_window.rs b/crates/remote_file_editor/src/editor_window.rs index 32a008c573..6d7c4a77cf 100644 --- a/crates/remote_file_editor/src/editor_window.rs +++ b/crates/remote_file_editor/src/editor_window.rs @@ -2,29 +2,25 @@ use crate::file_policy::{ EditorMode, FilePolicy, MAX_EDITABLE_FILE_SIZE, decode_text_content, determine_file_policy, }; use crate::language::language_for_path; -use crate::{ - CloseIntercept, active_index_after_close, active_index_after_open, decide_close_intercept, -}; +use crate::{CloseIntercept, decide_close_intercept}; use gpui::{ - AnyWindowHandle, App, AppContext, Context, Entity, InteractiveElement as _, IntoElement, - KeyBinding, ParentElement, PromptLevel, Render, Styled, WeakEntity, Window, actions, div, px, + App, AppContext, Bounds, Context, Entity, InteractiveElement as _, IntoElement, KeyBinding, + ParentElement, PromptLevel, Render, Size as GpuiSize, Styled, Window, WindowBounds, WindowKind, + WindowOptions, actions, div, px, size, }; use gpui_component::{ - ActiveTheme as _, Disableable as _, Selectable as _, Sizable as _, Size, TitleBar, WindowExt, + ActiveTheme as _, Disableable as _, Root, Selectable as _, Sizable as _, Size, TitleBar, + WindowExt, button::Button, h_flex, input::{Input, InputEvent, InputState, Search}, notification::Notification, - tab::{Tab, TabBar}, v_flex, }; -use one_core::{ - gpui_tokio::Tokio, - popup_window::{PopupWindowOptions, open_popup_window}, -}; +use one_core::gpui_tokio::Tokio; use rust_i18n::t; use sftp::{RusshSftpClient, SftpClient}; -use std::sync::{Arc, Mutex as StdMutex, Once, OnceLock}; +use std::sync::{Arc, Once}; use tokio::sync::Mutex; actions!(remote_file_editor, [OpenSearch, OpenReplace]); @@ -40,13 +36,6 @@ const REMOTE_EDITOR_REPLACE_SHORTCUT: &str = "cmd-r"; const REMOTE_EDITOR_REPLACE_SHORTCUT: &str = "ctrl-r"; static REMOTE_EDITOR_KEYBINDINGS_INIT: Once = Once::new(); -static REMOTE_EDITOR_WINDOW: OnceLock>> = OnceLock::new(); - -#[derive(Clone)] -struct RemoteEditorWindowRef { - window: AnyWindowHandle, - view: WeakEntity, -} pub fn open_remote_file_editor( remote_path: String, @@ -54,28 +43,44 @@ pub fn open_remote_file_editor( cx: &mut Context, ) { init_keybindings(cx); + let title = t!( + "RemoteFileEditor.title", + name = display_name_from_path(&remote_path) + ) + .to_string(); cx.spawn(async move |_this, cx| { + let title = title.clone(); let remote_path_for_log = remote_path.clone(); let result = cx.update(|cx| { - if open_in_existing_window(remote_path.clone(), cx)? { - return Ok(()); + let mut window_size = size(px(960.0), px(720.0)); + if let Some(display) = cx.primary_display() { + let display_size = display.bounds().size; + window_size.width = window_size.width.min(display_size.width * 0.85); + window_size.height = window_size.height.min(display_size.height * 0.85); } + let window_bounds = Bounds::centered(None, window_size, cx); + let window_opts = WindowOptions { + window_bounds: Some(WindowBounds::Windowed(window_bounds)), + titlebar: Some(TitleBar::title_bar_options()), + window_min_size: Some(GpuiSize { + width: px(640.0), + height: px(480.0), + }), + kind: WindowKind::Normal, + #[cfg(target_os = "linux")] + window_background: gpui::WindowBackgroundAppearance::Transparent, + #[cfg(target_os = "linux")] + window_decorations: Some(gpui::WindowDecorations::Client), + ..Default::default() + }; - let title = editor_window_title(&remote_path); - open_popup_window( - PopupWindowOptions::new(title).size(960.0, 720.0).min_width(640.0).min_height(480.0), - move |window, cx| { - let view = cx.new(|cx| { - RemoteFileEditorWindow::new(remote_path, client, window, cx) - }); - set_editor_window(RemoteEditorWindowRef { - window: window.window_handle(), - view: view.downgrade(), - }); - view - }, - cx, - ); + cx.open_window(window_opts, move |window, cx| { + window.activate_window(); + window.set_window_title(&title); + let view = + cx.new(|cx| RemoteFileEditorWindow::new(remote_path, client, window, cx)); + cx.new(|cx| Root::new(view, window, cx)) + })?; Ok::<_, anyhow::Error>(()) }); @@ -87,50 +92,6 @@ pub fn open_remote_file_editor( .detach(); } -fn open_in_existing_window(remote_path: String, cx: &mut App) -> anyhow::Result { - let Some(editor_window) = current_editor_window() else { - return Ok(false); - }; - - let result = cx.update_window(editor_window.window, |_, window, cx| { - window.activate_window(); - editor_window - .view - .update(cx, |this, cx| { - this.open_or_focus_tab(remote_path, window, cx); - }) - .is_ok() - }); - - match result { - Ok(true) => Ok(true), - Ok(false) | Err(_) => { - clear_editor_window(); - Ok(false) - } - } -} - -fn editor_window_slot() -> &'static StdMutex> { - REMOTE_EDITOR_WINDOW.get_or_init(|| StdMutex::new(None)) -} - -fn current_editor_window() -> Option { - editor_window_slot().lock().ok()?.clone() -} - -fn set_editor_window(window: RemoteEditorWindowRef) { - if let Ok(mut slot) = editor_window_slot().lock() { - *slot = Some(window); - } -} - -fn clear_editor_window() { - if let Ok(mut slot) = editor_window_slot().lock() { - *slot = None; - } -} - fn init_keybindings(cx: &mut App) { REMOTE_EDITOR_KEYBINDINGS_INIT.call_once(|| { cx.bind_keys([ @@ -156,14 +117,6 @@ fn replace_shortcut() -> &'static str { REMOTE_EDITOR_REPLACE_SHORTCUT } -fn editor_window_title(remote_path: &str) -> String { - t!( - "RemoteFileEditor.title", - name = display_name_from_path(remote_path) - ) - .to_string() -} - struct LoadedFile { text: String, policy: FilePolicy, @@ -171,16 +124,10 @@ struct LoadedFile { language: String, } -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum PendingCloseAction { - Window, - Tab(usize), -} - -struct RemoteEditorTab { - id: u64, +struct RemoteFileEditorWindow { remote_path: String, display_name: String, + client: Arc>, editor: Option>, subscriptions: Vec, saved_text: String, @@ -189,16 +136,23 @@ struct RemoteEditorTab { loading: bool, saving: bool, soft_wrap: bool, + close_prompt_open: bool, + close_after_save: bool, status_message: String, load_error: Option, } -impl RemoteEditorTab { - fn new(id: u64, remote_path: String) -> Self { - Self { - id, +impl RemoteFileEditorWindow { + fn new( + remote_path: String, + client: Arc>, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let mut this = Self { display_name: display_name_from_path(&remote_path), remote_path, + client, editor: None, subscriptions: Vec::new(), saved_text: String::new(), @@ -210,54 +164,13 @@ impl RemoteEditorTab { loading: true, saving: false, soft_wrap: false, + close_prompt_open: false, + close_after_save: false, status_message: t!("RemoteFileEditor.status.loading").to_string(), load_error: None, - } - } - - fn is_dirty(&self, cx: &App) -> bool { - self.editor - .as_ref() - .map(|editor| editor.read(cx).text() != self.saved_text.as_str()) - .unwrap_or(false) - } - - fn policy_label(&self) -> String { - match self.policy.mode { - EditorMode::Code => t!("RemoteFileEditor.policy.code").to_string(), - EditorMode::PlainText => t!("RemoteFileEditor.policy.plain_text").to_string(), - } - } -} - -struct RemoteFileEditorWindow { - client: Arc>, - tabs: Vec, - active_tab: usize, - close_prompt_open: bool, - pending_close_action: Option, - close_window_after_saves: bool, - next_tab_id: u64, -} - -impl RemoteFileEditorWindow { - fn new( - remote_path: String, - client: Arc>, - window: &mut Window, - cx: &mut Context, - ) -> Self { - let mut this = Self { - client, - tabs: Vec::new(), - active_tab: 0, - close_prompt_open: false, - pending_close_action: None, - close_window_after_saves: false, - next_tab_id: 1, }; this.register_close_guard(window, cx); - this.open_or_focus_tab(remote_path, window, cx); + this.reload(window, cx); this } @@ -269,84 +182,25 @@ impl RemoteFileEditorWindow { }); } - fn open_or_focus_tab( - &mut self, - remote_path: String, - window: &mut Window, - cx: &mut Context, - ) { - let paths = self.tab_paths(); - let active_index = active_index_after_open(&paths, &remote_path); - if active_index == self.tabs.len() { - let tab_id = self.next_tab_id; - self.next_tab_id += 1; - self.tabs.push(RemoteEditorTab::new(tab_id, remote_path)); - self.active_tab = active_index; - self.reload_tab(active_index, window, cx); - } else { - self.active_tab = active_index; - self.focus_editor(window, cx); - cx.notify(); - } - self.update_window_title(window); - } - - fn tab_paths(&self) -> Vec { - self.tabs - .iter() - .map(|tab| tab.remote_path.clone()) - .collect() - } - - fn tab_index_by_identity(&self, tab_id: u64, remote_path: &str) -> Option { - self.tabs - .iter() - .position(|tab| tab.id == tab_id && tab.remote_path == remote_path) - } - - fn active_tab(&self) -> Option<&RemoteEditorTab> { - self.tabs.get(self.active_tab) - } - - fn active_tab_mut(&mut self) -> Option<&mut RemoteEditorTab> { - self.tabs.get_mut(self.active_tab) - } - - fn update_window_title(&self, window: &mut Window) { - if let Some(tab) = self.active_tab() { - window.set_window_title(&editor_window_title(&tab.remote_path)); - } - } - fn reload(&mut self, window: &mut Window, cx: &mut Context) { - self.reload_tab(self.active_tab, window, cx); - } - - fn reload_tab(&mut self, index: usize, window: &mut Window, cx: &mut Context) { - let Some(tab) = self.tabs.get_mut(index) else { - return; - }; - - tab.loading = true; - tab.load_error = None; - tab.status_message = t!("RemoteFileEditor.status.loading").to_string(); + self.loading = true; + self.load_error = None; + self.status_message = t!("RemoteFileEditor.status.loading").to_string(); cx.notify(); - let tab_id = tab.id; - let remote_path = tab.remote_path.clone(); - let task_remote_path = remote_path.clone(); + let remote_path = self.remote_path.clone(); let client = self.client.clone(); let task = Tokio::spawn(cx, async move { let bytes = { let mut client = client.lock().await; client - .read_file(&task_remote_path, MAX_EDITABLE_FILE_SIZE) + .read_file(&remote_path, MAX_EDITABLE_FILE_SIZE) .await? }; let file_size = bytes.len(); let policy = determine_file_policy(file_size)?; let text = decode_text_content(&bytes)?; - let language = language_for_path(&task_remote_path, policy.is_large_file).to_string(); + let language = language_for_path(&remote_path, policy.is_large_file).to_string(); Ok::<_, anyhow::Error>(LoadedFile { text, policy, @@ -360,20 +214,24 @@ impl RemoteFileEditorWindow { .spawn(cx, async move |cx| match task.await { Ok(Ok(loaded)) => { let _ = view.update_in(cx, |this, window, cx| { - this.apply_loaded_file(tab_id, &remote_path, loaded, window, cx); + this.apply_loaded_file(loaded, window, cx); }); } Ok(Err(error)) => { let message = error.to_string(); let _ = view.update_in(cx, |this, window, cx| { - this.apply_load_error(tab_id, &remote_path, message.clone(), cx); + this.loading = false; + this.load_error = Some(message.clone()); + this.status_message = t!("RemoteFileEditor.status.load_failed").to_string(); window.push_notification(Notification::error(message), cx); }); } Err(error) => { let message = error.to_string(); let _ = view.update_in(cx, |this, window, cx| { - this.apply_load_error(tab_id, &remote_path, message.clone(), cx); + this.loading = false; + this.load_error = Some(message.clone()); + this.status_message = t!("RemoteFileEditor.status.load_failed").to_string(); window.push_notification(Notification::error(message), cx); }); } @@ -383,18 +241,10 @@ impl RemoteFileEditorWindow { fn apply_loaded_file( &mut self, - tab_id: u64, - remote_path: &str, loaded: LoadedFile, window: &mut Window, cx: &mut Context, ) { - let Some(index) = self.tab_index_by_identity(tab_id, remote_path) else { - return; - }; - let Some(tab) = self.tabs.get_mut(index) else { - return; - }; let LoadedFile { text, policy, @@ -403,7 +253,7 @@ impl RemoteFileEditorWindow { } = loaded; let initial_text = text.clone(); - let soft_wrap = tab.soft_wrap; + let soft_wrap = self.soft_wrap; let editor = cx.new(|cx| { let mut state = InputState::new(window, cx) .code_editor(language) @@ -414,8 +264,8 @@ impl RemoteFileEditorWindow { state }); - tab.subscriptions.clear(); - tab.subscriptions.push( + self.subscriptions.clear(); + self.subscriptions.push( cx.subscribe(&editor, |_this, _input, event: &InputEvent, cx| { if matches!(event, InputEvent::Change) { cx.notify(); @@ -423,20 +273,18 @@ impl RemoteFileEditorWindow { }), ); - if index == self.active_tab { - editor.update(cx, |state: &mut InputState, cx| { - state.focus(window, cx); - }); - } + editor.update(cx, |state: &mut InputState, cx| { + state.focus(window, cx); + }); - tab.editor = Some(editor); - tab.saved_text = text; - tab.file_size = file_size; - tab.policy = policy; - tab.loading = false; - tab.saving = false; - tab.load_error = None; - tab.status_message = if policy.is_large_file { + self.editor = Some(editor); + self.saved_text = text; + self.file_size = file_size; + self.policy = policy; + self.loading = false; + self.saving = false; + self.load_error = None; + self.status_message = if policy.is_large_file { t!("RemoteFileEditor.status.loaded_plain_text").to_string() } else { t!("RemoteFileEditor.status.loaded").to_string() @@ -444,64 +292,30 @@ impl RemoteFileEditorWindow { cx.notify(); } - fn apply_load_error( - &mut self, - tab_id: u64, - remote_path: &str, - message: String, - cx: &mut Context, - ) { - let Some(index) = self.tab_index_by_identity(tab_id, remote_path) else { - return; - }; - let Some(tab) = self.tabs.get_mut(index) else { - return; - }; - tab.loading = false; - tab.load_error = Some(message); - tab.status_message = t!("RemoteFileEditor.status.load_failed").to_string(); - cx.notify(); - } - fn save(&mut self, close_after_save: bool, window: &mut Window, cx: &mut Context) { - self.save_tab(self.active_tab, close_after_save, window, cx); - } - - fn save_tab( - &mut self, - index: usize, - close_after_save: bool, - window: &mut Window, - cx: &mut Context, - ) { - let Some(tab) = self.tabs.get_mut(index) else { - return; - }; - let Some(editor) = tab.editor.clone() else { - if close_after_save { - self.close_clean_tab(index, window, cx); + self.close_after_save |= close_after_save; + let Some(editor) = self.editor.clone() else { + if self.close_after_save { + self.close_after_save = false; + window.remove_window(); } return; }; - if tab.saving { + if self.saving { return; } let text = editor.read(cx).text().to_string(); - tab.saving = true; - tab.status_message = t!("RemoteFileEditor.status.saving").to_string(); + self.saving = true; + self.status_message = t!("RemoteFileEditor.status.saving").to_string(); cx.notify(); - let tab_id = tab.id; - let remote_path = tab.remote_path.clone(); - let task_remote_path = remote_path.clone(); + let remote_path = self.remote_path.clone(); let client = self.client.clone(); let task = Tokio::spawn(cx, async move { let mut client = client.lock().await; - client - .write_file(&task_remote_path, text.as_bytes()) - .await?; + client.write_file(&remote_path, text.as_bytes()).await?; Ok::<_, anyhow::Error>(text) }); @@ -510,27 +324,40 @@ impl RemoteFileEditorWindow { .spawn(cx, async move |cx| match task.await { Ok(Ok(saved_text)) => { let _ = view.update_in(cx, |this, window, cx| { - this.apply_saved_file( - tab_id, - &remote_path, - saved_text, - close_after_save, - window, - cx, - ); + this.saved_text = saved_text; + this.file_size = this.saved_text.len(); + this.saving = false; + this.status_message = t!("RemoteFileEditor.status.saved").to_string(); + let close_after_save = this.close_after_save; + this.close_after_save = false; + if close_after_save { + window.remove_window(); + } else { + window.push_notification( + Notification::success( + t!("RemoteFileEditor.notification.saved").to_string(), + ), + cx, + ); + } + cx.notify(); }); } Ok(Err(error)) => { let message = error.to_string(); let _ = view.update_in(cx, |this, window, cx| { - this.apply_save_error(tab_id, &remote_path, message.clone(), cx); + this.saving = false; + this.close_after_save = false; + this.status_message = t!("RemoteFileEditor.status.save_failed").to_string(); window.push_notification(Notification::error(message), cx); }); } Err(error) => { let message = error.to_string(); let _ = view.update_in(cx, |this, window, cx| { - this.apply_save_error(tab_id, &remote_path, message.clone(), cx); + this.saving = false; + this.close_after_save = false; + this.status_message = t!("RemoteFileEditor.status.save_failed").to_string(); window.push_notification(Notification::error(message), cx); }); } @@ -538,170 +365,19 @@ impl RemoteFileEditorWindow { .detach(); } - fn apply_saved_file( - &mut self, - tab_id: u64, - remote_path: &str, - saved_text: String, - close_after_save: bool, - window: &mut Window, - cx: &mut Context, - ) { - let Some(index) = self.tab_index_by_identity(tab_id, remote_path) else { - return; - }; - let Some(tab) = self.tabs.get_mut(index) else { - return; - }; - tab.saved_text = saved_text; - tab.file_size = tab.saved_text.len(); - tab.saving = false; - tab.status_message = t!("RemoteFileEditor.status.saved").to_string(); - - if self.close_window_after_saves && !self.has_dirty_tabs(cx) { - self.close_window_after_saves = false; - clear_editor_window(); - window.remove_window(); - } else if close_after_save { - self.close_clean_tab(index, window, cx); - } else { - window.push_notification( - Notification::success(t!("RemoteFileEditor.notification.saved").to_string()), - cx, - ); - cx.notify(); - } - } - - fn apply_save_error( - &mut self, - tab_id: u64, - remote_path: &str, - _message: String, - cx: &mut Context, - ) { - let Some(index) = self.tab_index_by_identity(tab_id, remote_path) else { - return; - }; - let Some(tab) = self.tabs.get_mut(index) else { - return; - }; - tab.saving = false; - tab.status_message = t!("RemoteFileEditor.status.save_failed").to_string(); - self.close_window_after_saves = false; - cx.notify(); - } - fn handle_window_should_close(&mut self, window: &mut Window, cx: &mut Context) -> bool { - match decide_close_intercept(self.has_dirty_tabs(cx), self.close_prompt_open) { - CloseIntercept::Allow => { - clear_editor_window(); - true - } + match decide_close_intercept(self.is_dirty(cx), self.close_prompt_open) { + CloseIntercept::Allow => true, CloseIntercept::Ignore => false, CloseIntercept::Prompt => { - if let Some(index) = self.first_dirty_tab(cx) { - self.active_tab = index; - self.update_window_title(window); - self.focus_editor(window, cx); - } - self.show_unsaved_changes_prompt(PendingCloseAction::Window, window, cx); + self.show_unsaved_changes_prompt(window, cx); false } } } - fn request_close_active_tab(&mut self, window: &mut Window, cx: &mut Context) { - self.request_close_tab(self.active_tab, window, cx); - } - - fn request_close_tab(&mut self, index: usize, window: &mut Window, cx: &mut Context) { - if index >= self.tabs.len() { - return; - } - - match decide_close_intercept(self.is_tab_dirty(index, cx), self.close_prompt_open) { - CloseIntercept::Allow => self.close_clean_tab(index, window, cx), - CloseIntercept::Ignore => {} - CloseIntercept::Prompt => { - self.show_unsaved_changes_prompt(PendingCloseAction::Tab(index), window, cx); - } - } - } - - fn close_clean_tab(&mut self, index: usize, window: &mut Window, cx: &mut Context) { - if index >= self.tabs.len() { - return; - } - - let next_active = active_index_after_close(self.active_tab, index, self.tabs.len()); - self.tabs.remove(index); - if let Some(next_active) = next_active { - self.active_tab = next_active; - self.update_window_title(window); - self.focus_editor(window, cx); - cx.notify(); - } else { - clear_editor_window(); - window.remove_window(); - } - } - - fn discard_close_action( - &mut self, - action: PendingCloseAction, - window: &mut Window, - cx: &mut Context, - ) { - match action { - PendingCloseAction::Window => { - clear_editor_window(); - window.remove_window(); - } - PendingCloseAction::Tab(index) => self.close_clean_tab(index, window, cx), - } - } - - fn save_close_action( - &mut self, - action: PendingCloseAction, - window: &mut Window, - cx: &mut Context, - ) { - match action { - PendingCloseAction::Window => self.save_dirty_tabs_and_close_window(window, cx), - PendingCloseAction::Tab(index) => self.save_tab(index, true, window, cx), - } - } - - fn save_dirty_tabs_and_close_window(&mut self, window: &mut Window, cx: &mut Context) { - let dirty_indexes = self - .tabs - .iter() - .enumerate() - .filter_map(|(index, tab)| tab.is_dirty(cx).then_some(index)) - .collect::>(); - - if dirty_indexes.is_empty() { - clear_editor_window(); - window.remove_window(); - return; - } - - self.close_window_after_saves = true; - for index in dirty_indexes { - self.save_tab(index, false, window, cx); - } - } - - fn show_unsaved_changes_prompt( - &mut self, - action: PendingCloseAction, - window: &mut Window, - cx: &mut Context, - ) { + fn show_unsaved_changes_prompt(&mut self, window: &mut Window, cx: &mut Context) { self.close_prompt_open = true; - self.pending_close_action = Some(action); let prompt_title = t!("RemoteFileEditor.prompt.unsaved_title").to_string(); let prompt_message = t!("RemoteFileEditor.prompt.unsaved_message").to_string(); let save_label = t!("RemoteFileEditor.action.save").to_string(); @@ -725,12 +401,13 @@ impl RemoteFileEditorWindow { let selection = answer.await.ok(); let _ = cx.update_window(window_handle, |_, window, cx| { let _ = this.update(cx, |this, cx| { - let action = this.pending_close_action.take(); this.close_prompt_open = false; - match (selection, action) { - (Some(0), Some(action)) => this.save_close_action(action, window, cx), - (Some(1), Some(action)) => this.discard_close_action(action, window, cx), - _ => {} + match selection { + Some(0) => this.save(true, window, cx), + Some(1) => window.remove_window(), + _ => { + this.close_after_save = false; + } } }); }); @@ -762,7 +439,7 @@ impl RemoteFileEditorWindow { } fn trigger_replace(&mut self, window: &mut Window, cx: &mut Context) { - let Some(editor) = self.active_tab().and_then(|tab| tab.editor.as_ref()) else { + let Some(editor) = self.editor.as_ref() else { return; }; @@ -772,7 +449,7 @@ impl RemoteFileEditorWindow { } fn focus_editor(&mut self, window: &mut Window, cx: &mut Context) { - let Some(editor) = self.active_tab().and_then(|tab| tab.editor.as_ref()) else { + let Some(editor) = self.editor.as_ref() else { return; }; @@ -782,92 +459,25 @@ impl RemoteFileEditorWindow { } fn toggle_soft_wrap(&mut self, window: &mut Window, cx: &mut Context) { - let Some(tab) = self.active_tab_mut() else { - return; - }; - tab.soft_wrap = !tab.soft_wrap; - if let Some(editor) = tab.editor.as_ref() { + self.soft_wrap = !self.soft_wrap; + if let Some(editor) = self.editor.as_ref() { editor.update(cx, |state, cx| { - state.set_soft_wrap(tab.soft_wrap, window, cx); + state.set_soft_wrap(self.soft_wrap, window, cx); }); } cx.notify(); } - fn switch_tab(&mut self, index: usize, window: &mut Window, cx: &mut Context) { - if index >= self.tabs.len() || index == self.active_tab { - return; - } - - self.active_tab = index; - self.update_window_title(window); - self.focus_editor(window, cx); - cx.notify(); - } - - fn is_tab_dirty(&self, index: usize, cx: &App) -> bool { - self.tabs - .get(index) - .map(|tab| tab.is_dirty(cx)) + fn is_dirty(&self, cx: &App) -> bool { + self.editor + .as_ref() + .map(|editor| editor.read(cx).text().to_string() != self.saved_text) .unwrap_or(false) } - fn has_dirty_tabs(&self, cx: &App) -> bool { - self.tabs.iter().any(|tab| tab.is_dirty(cx)) - } - - fn first_dirty_tab(&self, cx: &App) -> Option { - self.tabs.iter().position(|tab| tab.is_dirty(cx)) - } - - fn render_tabs(&self, cx: &mut Context) -> impl IntoElement { - let mut tab_bar = TabBar::new("remote-file-editor-tabs") - .menu(true) - .with_size(Size::Small) - .selected_index(self.active_tab) - .on_click({ - let view = cx.entity().clone(); - move |index, window, cx| { - let _ = view.update(cx, |this, cx| { - this.switch_tab(*index, window, cx); - }); - } - }); - - for (index, tab) in self.tabs.iter().enumerate() { - let label = if tab.is_dirty(cx) { - format!("* {}", tab.display_name) - } else { - tab.display_name.clone() - }; - tab_bar = tab_bar.child( - Tab::new().label(label).suffix( - Button::new(format!("remote-file-close-tab-{index}")) - .label("×") - .with_size(Size::XSmall) - .disabled(tab.saving) - .on_click(cx.listener(move |this, _, window, cx| { - this.request_close_tab(index, window, cx); - })), - ), - ); - } - - h_flex() - .border_b_1() - .border_color(cx.theme().border) - .bg(cx.theme().tab_bar) - .child(tab_bar) - } - fn render_toolbar(&self, cx: &mut Context) -> impl IntoElement { - let tab = self.active_tab(); - let dirty = tab.map(|tab| tab.is_dirty(cx)).unwrap_or(false); - let disabled = tab - .map(|tab| tab.loading || tab.saving || tab.editor.is_none()) - .unwrap_or(true); - let loading_or_saving = tab.map(|tab| tab.loading || tab.saving).unwrap_or(true); - let soft_wrap = tab.map(|tab| tab.soft_wrap).unwrap_or(false); + let dirty = self.is_dirty(cx); + let disabled = self.loading || self.saving || self.editor.is_none(); h_flex() .gap_2() @@ -908,7 +518,7 @@ impl RemoteFileEditorWindow { Button::new("remote-file-reload") .label(t!("RemoteFileEditor.action.reload")) .with_size(Size::Small) - .disabled(loading_or_saving) + .disabled(self.loading || self.saving) .on_click(cx.listener(|this, _, window, cx| { this.reload(window, cx); })), @@ -916,28 +526,19 @@ impl RemoteFileEditorWindow { .child( Button::new("remote-file-soft-wrap") .label(t!("RemoteFileEditor.action.soft_wrap")) - .selected(soft_wrap) + .selected(self.soft_wrap) .with_size(Size::Small) .disabled(disabled) .on_click(cx.listener(|this, _, window, cx| { this.toggle_soft_wrap(window, cx); })), ) - .child( - Button::new("remote-file-close-active-tab") - .label(t!("RemoteFileEditor.action.close_tab")) - .with_size(Size::Small) - .disabled(loading_or_saving || self.tabs.is_empty()) - .on_click(cx.listener(|this, _, window, cx| { - this.request_close_active_tab(window, cx); - })), - ) .child(div().flex_1()) .child( div() .text_sm() .text_color(cx.theme().muted_foreground) - .child(tab.map(RemoteEditorTab::policy_label).unwrap_or_default()), + .child(self.policy_label()), ) .child( div() @@ -956,19 +557,6 @@ impl RemoteFileEditorWindow { } fn render_status_bar(&self, cx: &mut Context) -> impl IntoElement { - let remote_path = self - .active_tab() - .map(|tab| tab.remote_path.clone()) - .unwrap_or_default(); - let file_size = self - .active_tab() - .map(|tab| tab.file_size) - .unwrap_or_default(); - let status_message = self - .active_tab() - .map(|tab| tab.status_message.clone()) - .unwrap_or_default(); - h_flex() .gap_2() .items_center() @@ -981,29 +569,25 @@ impl RemoteFileEditorWindow { div() .text_sm() .text_color(cx.theme().muted_foreground) - .child(remote_path), + .child(self.remote_path.clone()), ) .child(div().flex_1()) .child( div() .text_sm() .text_color(cx.theme().muted_foreground) - .child(format_size(file_size)), + .child(format_size(self.file_size)), ) .child( div() .text_sm() .text_color(cx.theme().muted_foreground) - .child(status_message), + .child(self.status_message.clone()), ) } fn render_body(&self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let Some(tab) = self.active_tab() else { - return v_flex().size_full().into_any_element(); - }; - - if tab.loading { + if self.loading { return v_flex() .size_full() .items_center() @@ -1012,7 +596,7 @@ impl RemoteFileEditorWindow { .into_any_element(); } - if let Some(error) = tab.load_error.as_ref() { + if let Some(error) = self.load_error.as_ref() { return v_flex() .size_full() .items_center() @@ -1033,7 +617,7 @@ impl RemoteFileEditorWindow { .into_any_element(); } - match tab.editor.as_ref() { + match self.editor.as_ref() { Some(editor) => v_flex() .size_full() .child(Input::new(editor).size_full()) @@ -1041,15 +625,17 @@ impl RemoteFileEditorWindow { None => v_flex().size_full().into_any_element(), } } + + fn policy_label(&self) -> String { + match self.policy.mode { + EditorMode::Code => t!("RemoteFileEditor.policy.code").to_string(), + EditorMode::PlainText => t!("RemoteFileEditor.policy.plain_text").to_string(), + } + } } impl Render for RemoteFileEditorWindow { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let title = self - .active_tab() - .map(|tab| tab.display_name.clone()) - .unwrap_or_default(); - v_flex() .size_full() .key_context(REMOTE_FILE_EDITOR_CONTEXT) @@ -1064,10 +650,9 @@ impl Render for RemoteFileEditorWindow { .justify_center() .flex_1() .text_sm() - .child(title), + .child(self.display_name.clone()), ), ) - .child(self.render_tabs(cx)) .child(self.render_toolbar(cx)) .child(v_flex().flex_1().child(self.render_body(window, cx))) .child(self.render_status_bar(cx)) @@ -1113,16 +698,4 @@ mod tests { assert_eq!(search_shortcut(), EXPECTED_SEARCH_SHORTCUT); assert_eq!(replace_shortcut(), EXPECTED_REPLACE_SHORTCUT); } - - #[test] - fn display_name_ignores_trailing_slash() { - assert_eq!(display_name_from_path("/tmp/example/"), "example"); - } - - #[test] - fn format_size_uses_binary_units() { - assert_eq!(format_size(42), "42 B"); - assert_eq!(format_size(1024), "1.0 KiB"); - assert_eq!(format_size(1024 * 1024), "1.0 MiB"); - } } diff --git a/crates/remote_file_editor/src/lib.rs b/crates/remote_file_editor/src/lib.rs index 83c5b36367..e84ca7edf9 100644 --- a/crates/remote_file_editor/src/lib.rs +++ b/crates/remote_file_editor/src/lib.rs @@ -9,10 +9,7 @@ mod language; #[cfg(feature = "ui")] pub use editor_window::open_remote_file_editor; -pub use close_guard::{ - CloseIntercept, active_index_after_close, active_index_after_open, decide_close_intercept, - find_tab_index, has_dirty_tabs, -}; +pub use close_guard::{CloseIntercept, decide_close_intercept}; pub use file_policy::{ EditorMode, FilePolicy, LARGE_FILE_PLAIN_TEXT_THRESHOLD, MAX_EDITABLE_FILE_SIZE, decode_text_content, determine_file_policy, diff --git a/crates/terminal/src/pty_backend.rs b/crates/terminal/src/pty_backend.rs index 1e7b6f5a3c..758e1a4c4d 100644 --- a/crates/terminal/src/pty_backend.rs +++ b/crates/terminal/src/pty_backend.rs @@ -311,7 +311,6 @@ impl GpuiEventProxy { self.set_write_back(PtyWriteBack::Ssh(sender)); } -<<<<<<< HEAD /// 设置 Hosted 本地 PTY 回写通道 #[allow(dead_code)] pub(crate) fn set_hosted_write_back( @@ -320,7 +319,8 @@ impl GpuiEventProxy { session_id: String, ) { self.set_write_back(PtyWriteBack::Hosted { sender, session_id }); -======= + } + /// 同步当前真实窗口尺寸(含 cell 像素),后续 TextAreaSizeRequest 将以此回复 pub(crate) fn set_window_size(&self, size: WindowSize) { *self.window_size.lock().unwrap() = size; @@ -339,7 +339,6 @@ impl GpuiEventProxy { fn current_window_size(&self) -> WindowSize { *self.window_size.lock().unwrap() ->>>>>>> d0e858e4 (feat(terminal): 优化终端事件转发和块字符渲染) } fn write_back(&self, data: Vec) { diff --git a/crates/terminal/src/serial_backend.rs b/crates/terminal/src/serial_backend.rs index 813f4a0223..18959fe810 100644 --- a/crates/terminal/src/serial_backend.rs +++ b/crates/terminal/src/serial_backend.rs @@ -26,7 +26,7 @@ impl SerialBackend { params: SerialParams, term: Arc>>, event_tx: UnboundedSender, - on_disconnect: Option>, + on_disconnect: Option>, ) -> anyhow::Result { let data_bits = match params.data_bits { 5 => serialport::DataBits::Five, @@ -99,7 +99,7 @@ impl SerialBackend { } } if let Some(tx) = on_disconnect { - let _ = tx.send(()); + let _ = tx.send(true); } })?; diff --git a/crates/terminal/src/ssh_backend.rs b/crates/terminal/src/ssh_backend.rs index e45b6088a3..903be33828 100644 --- a/crates/terminal/src/ssh_backend.rs +++ b/crates/terminal/src/ssh_backend.rs @@ -90,43 +90,22 @@ fn build_shell_integration_setup_script( let home_marker = shell_single_quote(home_marker); let session_marker = shell_single_quote(session_marker); let shell_marker = shell_single_quote(shell_marker); - - // zsh wrapper 设计:让 ZDOTDIR 始终保持 session_dir/zsh,在该目录下放完整的 4 个 wrapper - // 文件,每个 fan-out 到 $ONETCLI_ORIG_ZDOTDIR 下的同名文件,保留完整 login shell 行为; - // 仅在 .zshrc 末尾追加 integration source,然后还原 ZDOTDIR 给后续 sub-shell。 let zshenv = shell_single_quote( - "[[ -n \"${ONETCLI_ORIG_ZDOTDIR:-}\" ]] && [ -f \"$ONETCLI_ORIG_ZDOTDIR/.zshenv\" ] \ - && . \"$ONETCLI_ORIG_ZDOTDIR/.zshenv\"\n", - ); - let zprofile = shell_single_quote( - "[[ -n \"${ONETCLI_ORIG_ZDOTDIR:-}\" ]] && [ -f \"$ONETCLI_ORIG_ZDOTDIR/.zprofile\" ] \ - && . \"$ONETCLI_ORIG_ZDOTDIR/.zprofile\"\n", + "_ONETCLI_SESSION_ZDOTDIR=\"$ZDOTDIR\"\n\ + _ONETCLI_ORIG_ZDOTDIR=\"${ONETCLI_ORIG_ZDOTDIR:-$HOME}\"\n\ + [[ -f \"$_ONETCLI_ORIG_ZDOTDIR/.zshenv\" ]] && . \"$_ONETCLI_ORIG_ZDOTDIR/.zshenv\"\n\ + ZDOTDIR=\"$_ONETCLI_SESSION_ZDOTDIR\"\n\ + export ZDOTDIR\n\ + unset _ONETCLI_SESSION_ZDOTDIR _ONETCLI_ORIG_ZDOTDIR\n", ); let zshrc = shell_single_quote(&format!( - "[[ -n \"${{ONETCLI_ORIG_ZDOTDIR:-}}\" ]] && [ -f \"$ONETCLI_ORIG_ZDOTDIR/.zshrc\" ] \ - && . \"$ONETCLI_ORIG_ZDOTDIR/.zshrc\"\n\ - . \"{integration_source}\"\n\ - ZDOTDIR=\"${{ONETCLI_ORIG_ZDOTDIR:-$HOME}}\"\n" + "_ONETCLI_ORIG_ZDOTDIR=\"${{ONETCLI_ORIG_ZDOTDIR:-$HOME}}\"\n\ + [[ -f \"$_ONETCLI_ORIG_ZDOTDIR/.zshrc\" ]] && . \"$_ONETCLI_ORIG_ZDOTDIR/.zshrc\"\n\ + . \"{integration_source}\"\n" )); - let zlogin = shell_single_quote( - "[[ -n \"${ONETCLI_ORIG_ZDOTDIR:-}\" ]] && [ -f \"$ONETCLI_ORIG_ZDOTDIR/.zlogin\" ] \ - && . \"$ONETCLI_ORIG_ZDOTDIR/.zlogin\"\n", - ); - // bash wrapper:`exec bash --rcfile X -i` 是 interactive non-login,跳过 /etc/profile 与 - // ~/.bash_profile 等。这里手动模拟 login chain,然后再显式 source ~/.bashrc + integration。 - // ONETCLI_LOGIN_SIMULATED guard 防止 .bash_profile 内 `exec bash -l` 等场景二次进入时重复 - // 加载 profile 链。 let bashrc = shell_single_quote(&format!( - "if [ -z \"${{ONETCLI_LOGIN_SIMULATED:-}}\" ]; then\n\ - \x20\x20\x20\x20export ONETCLI_LOGIN_SIMULATED=1\n\ - \x20\x20\x20\x20[ -r /etc/profile ] && . /etc/profile\n\ - \x20\x20\x20\x20for __onetcli_profile in \"$HOME/.bash_profile\" \"$HOME/.bash_login\" \"$HOME/.profile\"; do\n\ - \x20\x20\x20\x20\x20\x20\x20\x20if [ -r \"$__onetcli_profile\" ]; then . \"$__onetcli_profile\"; break; fi\n\ - \x20\x20\x20\x20done\n\ - \x20\x20\x20\x20unset __onetcli_profile\n\ - fi\n\ - [ -r \"$HOME/.bashrc\" ] && . \"$HOME/.bashrc\"\n\ - . \"{integration_source}\"\n" + "[ -f \"$HOME/.bashrc\" ] && . \"$HOME/.bashrc\"\n\ + . \"{integration_source}\"\n" )); format!( @@ -139,9 +118,7 @@ fn build_shell_integration_setup_script( "mkdir -p \"$zsh_dir\" \"$bash_dir\"\n", "printf %s {script} > \"$integration_path\"\n", "printf %s {zshenv} > \"$zsh_dir/.zshenv\"\n", - "printf %s {zprofile} > \"$zsh_dir/.zprofile\"\n", "printf %s {zshrc} > \"$zsh_dir/.zshrc\"\n", - "printf %s {zlogin} > \"$zsh_dir/.zlogin\"\n", "printf %s {bashrc} > \"$bash_dir/.bashrc\"\n", "printf '%s%s\\n' {home_marker} \"$HOME\"\n", "printf '%s%s\\n' {session_marker} \"$session_dir\"\n", @@ -151,9 +128,7 @@ fn build_shell_integration_setup_script( session_key = session_key, script = script, zshenv = zshenv, - zprofile = zprofile, zshrc = zshrc, - zlogin = zlogin, bashrc = bashrc, success_marker = success_marker, home_marker = home_marker, @@ -213,18 +188,13 @@ impl SshBackend { event_proxy: GpuiEventProxy, event_tx: UnboundedSender, notify_tx: UnboundedSender<()>, - on_disconnect: Option>, + on_disconnect: Option>, init_commands: Option, - disable_shell_integration: bool, ) -> anyhow::Result { - let (client, mut channel) = Self::establish_channel( - &session_manager, - &pty_config, - connection_id, - disable_shell_integration, - ) - .await - .map_err(add_connect_error_context)?; + let (client, mut channel) = + Self::establish_channel(&session_manager, &pty_config, connection_id) + .await + .map_err(add_connect_error_context)?; // 关联变量,避免 clippy 警告未使用。 let _keep_client = client; @@ -239,6 +209,7 @@ impl SshBackend { tokio::spawn(async move { let mut shutdown = false; + let mut is_graceful = false; let mut processor: Processor = Processor::new(); // 用来判断 shell 是否已经 ready(收到第一个 133;B 后才发 init_commands) let mut shell_ready = false; @@ -255,6 +226,7 @@ impl SshBackend { channel.send_data(&data) ).await; if send_result.is_err() || send_result.is_ok_and(|r| r.is_err()) { + is_graceful = false; break; } } @@ -264,6 +236,7 @@ impl SshBackend { SshCommand::Shutdown => { shutdown = true; let _ = channel.close().await; + is_graceful = true; break; } } @@ -274,6 +247,7 @@ impl SshBackend { channel.send_data(&data) ).await; if send_result.is_err() || send_result.is_ok_and(|r| r.is_err()) { + is_graceful = false; break; } } @@ -339,7 +313,12 @@ impl SshBackend { processor.advance(&mut *term.lock(), &data); let _ = notify_tx.send(()); } - Some(ChannelEvent::Eof) | Some(ChannelEvent::Close) | None => { + Some(ChannelEvent::Eof) | Some(ChannelEvent::Close) => { + is_graceful = true; + break; + } + None => { + is_graceful = false; break; } _ => {} @@ -352,7 +331,7 @@ impl SshBackend { let _ = session_manager.invalidate().await; } if let Some(tx) = on_disconnect { - let _ = tx.send(()); + let _ = tx.send(is_graceful); } }); @@ -367,7 +346,7 @@ impl SshBackend { event_proxy: GpuiEventProxy, event_tx: UnboundedSender, notify_tx: UnboundedSender<()>, - on_disconnect: Option>, + on_disconnect: Option>, init_commands: Option, _on_progress: impl FnMut(SshConnectionStage) + Send + 'static, ) -> anyhow::Result { @@ -384,6 +363,7 @@ impl SshBackend { tokio::spawn(async move { let mut shutdown = false; + let mut is_graceful = false; let mut processor: Processor = Processor::new(); let mut shell_ready = false; let mut init_sent = false; @@ -399,6 +379,7 @@ impl SshBackend { channel.send_data(&data) ).await; if send_result.is_err() || send_result.is_ok_and(|r| r.is_err()) { + is_graceful = false; break; } } @@ -408,6 +389,7 @@ impl SshBackend { SshCommand::Shutdown => { shutdown = true; let _ = channel.close().await; + is_graceful = true; break; } } @@ -418,6 +400,7 @@ impl SshBackend { channel.send_data(&data) ).await; if send_result.is_err() || send_result.is_ok_and(|r| r.is_err()) { + is_graceful = false; break; } } @@ -476,7 +459,12 @@ impl SshBackend { processor.advance(&mut *term.lock(), &data); let _ = notify_tx.send(()); } - Some(ChannelEvent::Eof) | Some(ChannelEvent::Close) | None => { + Some(ChannelEvent::Eof) | Some(ChannelEvent::Close) => { + is_graceful = true; + break; + } + None => { + is_graceful = false; break; } _ => {} @@ -489,7 +477,7 @@ impl SshBackend { let _ = session_manager.invalidate().await; } if let Some(tx) = on_disconnect { - let _ = tx.send(()); + let _ = tx.send(is_graceful); } }); @@ -502,7 +490,6 @@ impl SshBackend { session_manager: &Arc, pty_config: &PtyConfig, connection_id: Option, - disable_shell_integration: bool, ) -> anyhow::Result<(Arc>, ssh::RusshChannel)> { let mut attempt = 0usize; loop { @@ -511,14 +498,7 @@ impl SshBackend { let result = { let mut guard = client.lock().await; - Self::prepare_ssh_channel( - &mut *guard, - pty_config, - connection_id, - cached, - disable_shell_integration, - ) - .await + Self::prepare_ssh_channel(&mut *guard, pty_config, connection_id, cached).await }; match result { @@ -548,13 +528,8 @@ impl SshBackend { pty_config: &PtyConfig, connection_id: Option, cached: Option, - disable_shell_integration: bool, ) -> anyhow::Result<(C::Channel, Option)> { - let (setup, new_setup) = if disable_shell_integration { - // 用户在连接配置里显式关闭了 shell integration:跳过安装,走裸 request_shell 路径, - // 不向 manager 写入任何缓存,确保下次连接如果用户改回开启时还能正常走 setup。 - (None, None) - } else if let Some(cached) = cached { + let (setup, new_setup) = if let Some(cached) = cached { (Some(cached), None) } else { // 首次连接:尝试安装 integration,失败降级为"无 integration"分支。 @@ -963,14 +938,9 @@ mod tests { let (interactive_channel, interactive_state) = MockChannel::new([], false); let mut client = MockClient::new([setup_channel, interactive_channel]); - let result = SshBackend::prepare_ssh_channel( - &mut client, - &PtyConfig::default(), - Some(42), - None, - false, - ) - .await; + let result = + SshBackend::prepare_ssh_channel(&mut client, &PtyConfig::default(), Some(42), None) + .await; let (_channel, new_setup) = result.expect("安装 shell integration 不应占用交互 shell 的 channel"); @@ -1027,14 +997,9 @@ mod tests { let (interactive_channel, interactive_state) = MockChannel::new([], false); let mut client = MockClient::new([setup_channel, interactive_channel]); - let result = SshBackend::prepare_ssh_channel( - &mut client, - &PtyConfig::default(), - Some(42), - None, - false, - ) - .await; + let result = + SshBackend::prepare_ssh_channel(&mut client, &PtyConfig::default(), Some(42), None) + .await; let (_channel, new_setup) = result.expect("bash shell wrapper 应通过独立交互 channel 启动"); assert!(new_setup.is_some()); @@ -1137,15 +1102,10 @@ mod tests { let (interactive_channel, interactive_state) = MockChannel::new([], false); let mut client = MockClient::new([setup_channel, interactive_channel]); - let (_ch, new_setup) = SshBackend::prepare_ssh_channel( - &mut client, - &PtyConfig::default(), - Some(42), - None, - false, - ) - .await - .expect("setup 失败时 prepare_ssh_channel 不应整体失败"); + let (_ch, new_setup) = + SshBackend::prepare_ssh_channel(&mut client, &PtyConfig::default(), Some(42), None) + .await + .expect("setup 失败时 prepare_ssh_channel 不应整体失败"); assert!( new_setup.is_none(), @@ -1180,7 +1140,6 @@ mod tests { &PtyConfig::default(), Some(42), Some(cached), - false, ) .await .expect("缓存命中时应直接复用 setup 结果"); @@ -1204,34 +1163,6 @@ mod tests { ); } - #[tokio::test] - async fn prepare_ssh_channel_skips_setup_when_disabled() { - // 用户在连接配置里显式关闭 shell integration:不开 setup channel,只开 1 个 interactive - // channel 走裸 PTY + shell;且不向 manager 写入任何缓存。 - let (interactive_channel, interactive_state) = MockChannel::new([], false); - let mut client = MockClient::new([interactive_channel]); - - let (_ch, new_setup) = SshBackend::prepare_ssh_channel( - &mut client, - &PtyConfig::default(), - Some(42), - None, - true, - ) - .await - .expect("禁用 shell integration 时仍应建立 interactive channel"); - - assert!( - new_setup.is_none(), - "禁用路径不应向 manager 写入任何 integration 缓存" - ); - assert_eq!( - recorded_ops(&interactive_state), - vec![ChannelOp::RequestPty, ChannelOp::RequestShell], - "禁用路径只跑 pty + shell,不调 set_env / exec wrapper" - ); - } - #[tokio::test] async fn try_install_shell_integration_times_out_in_ten_seconds() { // 测试里用短 timeout 验证逻辑;生产路径仍走 10s 常量。 @@ -1326,50 +1257,16 @@ mod tests { fs::read_to_string(session_dir.join("zsh/.zshrc")).expect("应读取 zshrc wrapper"); assert!( session_dir.join("zsh/.zshenv").is_file(), - "应写入 zsh session wrapper (.zshenv)" - ); - assert!( - session_dir.join("zsh/.zprofile").is_file(), - "应写入 zsh session wrapper (.zprofile)" + "应写入 zsh session wrapper" ); assert!( session_dir.join("zsh/.zshrc").is_file(), "应写入 zshrc session wrapper" ); - assert!( - session_dir.join("zsh/.zlogin").is_file(), - "应写入 zsh session wrapper (.zlogin)" - ); assert!( session_dir.join("bash/.bashrc").is_file(), "应写入 bash session wrapper" ); - - let zshrc_wrapper = - fs::read_to_string(session_dir.join("zsh/.zshrc")).expect("应读取 zshrc wrapper"); - assert!( - zshrc_wrapper.contains("shell_integration.sh"), - ".zshrc wrapper 应在末尾 source integration: {zshrc_wrapper}" - ); - assert!( - zshrc_wrapper.contains("ZDOTDIR=\"${ONETCLI_ORIG_ZDOTDIR:-$HOME}\""), - ".zshrc wrapper 应在末尾还原 ZDOTDIR: {zshrc_wrapper}" - ); - - let bashrc_wrapper = - fs::read_to_string(session_dir.join("bash/.bashrc")).expect("应读取 bashrc wrapper"); - assert!( - bashrc_wrapper.contains("ONETCLI_LOGIN_SIMULATED"), - ".bashrc wrapper 应包含 ONETCLI_LOGIN_SIMULATED guard 模拟 login chain: {bashrc_wrapper}" - ); - assert!( - bashrc_wrapper.contains("/etc/profile"), - ".bashrc wrapper 应模拟 login shell 加载 /etc/profile: {bashrc_wrapper}" - ); - assert!( - bashrc_wrapper.contains(".bash_profile"), - ".bashrc wrapper 应模拟 login shell 尝试 ~/.bash_profile: {bashrc_wrapper}" - ); assert_eq!( fs::read_to_string(&bashrc_path).expect("应保留用户 bashrc"), "# user bashrc\n" @@ -1457,189 +1354,6 @@ mod tests { let _ = fs::remove_dir_all(&temp_dir); } - #[cfg(unix)] - #[test] - fn bash_wrapper_runs_bash_profile_chain_and_integration() { - if Command::new("bash").arg("--version").output().is_err() { - eprintln!("跳过 bash wrapper 测试:当前环境未安装 bash"); - return; - } - let temp_dir = std::env::temp_dir().join(format!( - "onetcli-bash-wrapper-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("system time should be after unix epoch") - .as_nanos() - )); - fs::create_dir_all(&temp_dir).expect("应创建临时目录"); - - let home_dir = temp_dir.join("home"); - fs::create_dir_all(&home_dir).expect("应创建 home 目录"); - fs::write( - home_dir.join(".bash_profile"), - "export __ONETCLI_BASH_PROFILE_LOADED=1\n", - ) - .expect("应写入用户 .bash_profile"); - fs::write( - home_dir.join(".bashrc"), - "[[ $- != *i* ]] && return\nexport __ONETCLI_USER_BASHRC=1\n", - ) - .expect("应写入用户 .bashrc"); - - let script = "export __ONETCLI_INTEGRATION_LOADED=1\n"; - let command = build_shell_integration_setup_script( - script, - "42", - "__TEST_OK__", - "__HOME__=", - "__SESSION__=", - "__SHELL__=", - ); - let setup = Command::new("sh") - .arg("-c") - .arg(&command) - .env("HOME", &home_dir) - .output() - .expect("应执行 setup 脚本"); - assert!( - setup.status.success(), - "setup 脚本应成功: {}", - String::from_utf8_lossy(&setup.stderr) - ); - - let wrapper = home_dir.join(".config/onetcli/sessions/42/bash/.bashrc"); - let output = Command::new("bash") - .arg("--rcfile") - .arg(&wrapper) - .arg("-i") - .arg("-c") - .arg( - "echo profile=$__ONETCLI_BASH_PROFILE_LOADED \ - rc=$__ONETCLI_USER_BASHRC \ - integration=$__ONETCLI_INTEGRATION_LOADED \ - login=$ONETCLI_LOGIN_SIMULATED", - ) - .env("HOME", &home_dir) - .env("PS1", "$ ") - .output() - .expect("应执行 bash wrapper"); - - assert!( - output.status.success(), - "bash wrapper 应成功执行: {}", - String::from_utf8_lossy(&output.stderr) - ); - - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("profile=1"), - "bash wrapper 应模拟 login shell 加载 .bash_profile,实际: {stdout}" - ); - assert!( - stdout.contains("rc=1"), - "bash wrapper 应显式 source 用户 .bashrc,实际: {stdout}" - ); - assert!( - stdout.contains("integration=1"), - "bash wrapper 应在末尾 source shell integration,实际: {stdout}" - ); - assert!( - stdout.contains("login=1"), - "bash wrapper 应设置 ONETCLI_LOGIN_SIMULATED guard,实际: {stdout}" - ); - - let _ = fs::remove_dir_all(&temp_dir); - } - - #[cfg(unix)] - #[test] - fn zsh_wrapper_loads_user_files_integration_and_restores_zdotdir() { - if Command::new("zsh").arg("--version").output().is_err() { - eprintln!("跳过 zsh wrapper 测试:当前环境未安装 zsh"); - return; - } - let temp_dir = std::env::temp_dir().join(format!( - "onetcli-zsh-wrapper-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("system time should be after unix epoch") - .as_nanos() - )); - fs::create_dir_all(&temp_dir).expect("应创建临时目录"); - - let home_dir = temp_dir.join("home"); - fs::create_dir_all(&home_dir).expect("应创建 home 目录"); - fs::write(home_dir.join(".zshenv"), "export __ONETCLI_USER_ZSHENV=1\n") - .expect("应写入用户 .zshenv"); - fs::write(home_dir.join(".zshrc"), "export __ONETCLI_USER_ZSHRC=1\n") - .expect("应写入用户 .zshrc"); - - let script = "export __ONETCLI_INTEGRATION_LOADED=1\n"; - let command = build_shell_integration_setup_script( - script, - "42", - "__TEST_OK__", - "__HOME__=", - "__SESSION__=", - "__SHELL__=", - ); - let setup = Command::new("sh") - .arg("-c") - .arg(&command) - .env("HOME", &home_dir) - .output() - .expect("应执行 setup 脚本"); - assert!( - setup.status.success(), - "setup 脚本应成功: {}", - String::from_utf8_lossy(&setup.stderr) - ); - - let zsh_dir = home_dir.join(".config/onetcli/sessions/42/zsh"); - let output = Command::new("zsh") - .arg("-i") - .arg("-c") - .arg( - "echo zshenv=$__ONETCLI_USER_ZSHENV \ - zshrc=$__ONETCLI_USER_ZSHRC \ - integration=$__ONETCLI_INTEGRATION_LOADED \ - zdotdir=$ZDOTDIR", - ) - .env("HOME", &home_dir) - .env("ZDOTDIR", &zsh_dir) - .env("ONETCLI_ORIG_ZDOTDIR", &home_dir) - .output() - .expect("应执行 zsh wrapper"); - - assert!( - output.status.success(), - "zsh wrapper 应成功执行: {}", - String::from_utf8_lossy(&output.stderr) - ); - - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("zshenv=1"), - "zsh wrapper 应通过 fan-out 加载用户 .zshenv,实际: {stdout}" - ); - assert!( - stdout.contains("zshrc=1"), - "zsh wrapper 应通过 fan-out 加载用户 .zshrc,实际: {stdout}" - ); - assert!( - stdout.contains("integration=1"), - "zsh wrapper 应在 .zshrc 末尾 source shell integration,实际: {stdout}" - ); - assert!( - stdout.contains(&format!("zdotdir={}", home_dir.display())), - "zsh wrapper 应在 .zshrc 末尾把 ZDOTDIR 还原为 $HOME,实际: {stdout}" - ); - - let _ = fs::remove_dir_all(&temp_dir); - } - #[test] fn parse_osc_payload_decodes_recorded_command() { let payload = "1337;Command=Z2l0IHN0YXR1cw=="; diff --git a/crates/terminal/src/terminal.rs b/crates/terminal/src/terminal.rs index 4f67be52a5..813aad91e2 100644 --- a/crates/terminal/src/terminal.rs +++ b/crates/terminal/src/terminal.rs @@ -30,11 +30,9 @@ use std::collections::HashSet; use std::collections::VecDeque; use std::fs; use std::path::PathBuf; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Arc, Mutex as StdMutex}; +use std::sync::Arc; use std::time::{Duration, Instant}; use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender}; -use tokio::sync::oneshot; use tokio::time::interval; #[cfg(any(test, target_os = "windows"))] @@ -332,8 +330,6 @@ pub enum TerminalConnectionKind { pub struct SshTerminalConfig { pub ssh_config: SshConnectConfig, pub pty_config: PtyConfig, - /// 关闭 shell integration 注入:走裸 request_shell,失去 OSC 集成。 - pub disable_shell_integration: bool, } #[derive(Clone, Copy, PartialEq, Eq, Debug)] @@ -1033,9 +1029,6 @@ pub struct Terminal { /// 终端尺寸 cols: usize, rows: usize, - /// 最近一次同步给 PTY 的像素尺寸,用于 nudge_resize 重发 SIGWINCH - pixel_width: u16, - pixel_height: u16, /// SSH 配置(用于重连) ssh_config: Option, @@ -1149,10 +1142,10 @@ impl TerminalScrollProxy { impl Terminal { fn new_local_disconnected(error: String, cx: &mut Context) -> Self { let (event_tx, event_rx) = unbounded_channel::(); - let (term, event_proxy, _colors) = + let (term, _event_proxy, _colors) = Self::create_term(DEFAULT_COLS, DEFAULT_ROWS, event_tx.clone()); - Self::spawn_event_loop(event_rx, event_proxy.wakeup_pending_handle(), cx); + Self::spawn_event_loop(event_rx, cx); Self { term, @@ -1169,8 +1162,6 @@ impl Terminal { connection_wait_started_at: None, cols: DEFAULT_COLS, rows: DEFAULT_ROWS, - pixel_width: 0, - pixel_height: 0, ssh_config: None, ssh_session_manager: None, ssh_process_state: Cell::new(SshProcessState::Unknown), @@ -1264,10 +1255,10 @@ impl Terminal { #[cfg(target_os = "windows")] escape_args: true, }; - let local_backend = LocalPtyBackend::new(term.clone(), event_proxy.clone(), pty_options)?; + let local_backend = LocalPtyBackend::new(term.clone(), event_proxy, pty_options)?; let local_shell_pid = local_backend.child_pid(); - Self::spawn_event_loop(event_rx, event_proxy.wakeup_pending_handle(), cx); + Self::spawn_event_loop(event_rx, cx); #[cfg(target_os = "macos")] Self::spawn_local_process_tree_settler(cx); Self::spawn_local_history_loader(history_shell.as_deref(), cx); @@ -1287,8 +1278,6 @@ impl Terminal { connection_wait_started_at: None, cols: DEFAULT_COLS, rows: DEFAULT_ROWS, - pixel_width: 0, - pixel_height: 0, ssh_config: None, ssh_session_manager: None, ssh_process_state: Cell::new(SshProcessState::Unknown), @@ -1602,7 +1591,6 @@ impl Terminal { let config = SshTerminalConfig { ssh_config, pty_config, - disable_shell_integration: ssh_params.disable_shell_integration.unwrap_or(false), }; let cols = config.pty_config.width as usize; @@ -1616,12 +1604,12 @@ impl Terminal { replay_term_output(&term, content.as_bytes(), None); replay_term_output(&term, HISTORY_RESTORED_BANNER.as_bytes(), None); } - let (disconnect_tx, disconnect_rx) = tokio::sync::oneshot::channel::<()>(); + let (disconnect_tx, disconnect_rx) = tokio::sync::oneshot::channel::(); let connection_generation = 1; let ssh_session_manager = Arc::new(SshSessionManager::new(config.ssh_config.clone())); Self::spawn_disconnect_handler(disconnect_rx, connection_generation, cx); - Self::spawn_event_loop(event_rx, event_proxy.wakeup_pending_handle(), cx); + Self::spawn_event_loop(event_rx, cx); Self::spawn_ssh_connect( ssh_session_manager.clone(), config.clone(), @@ -1654,9 +1642,7 @@ impl Terminal { connection_wait_started_at: Some(Instant::now()), cols, rows, - pixel_width: 0, - pixel_height: 0, - ssh_config: Some(config), + ssh_config: Some(config.clone()), ssh_session_manager: Some(ssh_session_manager), ssh_process_state: Cell::new(SshProcessState::Unknown), ssh_prompt_detected: false, @@ -1682,13 +1668,13 @@ impl Terminal { .expect("StoredConnection 应包含有效的 SerialParams"); let (event_tx, event_rx) = unbounded_channel::(); - let (term, event_proxy, _colors) = + let (term, _event_proxy, _colors) = Self::create_term(DEFAULT_COLS, DEFAULT_ROWS, event_tx.clone()); - let (disconnect_tx, disconnect_rx) = tokio::sync::oneshot::channel::<()>(); + let (disconnect_tx, disconnect_rx) = tokio::sync::oneshot::channel::(); let connection_generation = 1; Self::spawn_disconnect_handler(disconnect_rx, connection_generation, cx); - Self::spawn_event_loop(event_rx, event_proxy.wakeup_pending_handle(), cx); + Self::spawn_event_loop(event_rx, cx); Self::spawn_serial_connect( serial_params.clone(), term.clone(), @@ -1713,8 +1699,6 @@ impl Terminal { connection_wait_started_at: None, cols: DEFAULT_COLS, rows: DEFAULT_ROWS, - pixel_width: 0, - pixel_height: 0, ssh_config: None, ssh_session_manager: None, ssh_process_state: Cell::new(SshProcessState::Unknown), @@ -1819,11 +1803,7 @@ impl Terminal { .detach(); } - fn spawn_event_loop( - mut event_rx: UnboundedReceiver, - wakeup_pending: Arc, - cx: &mut Context, - ) { + fn spawn_event_loop(mut event_rx: UnboundedReceiver, cx: &mut Context) { let _entity = cx.entity().downgrade(); let (render_tx, mut render_rx) = futures::channel::mpsc::unbounded::(); @@ -1856,9 +1836,6 @@ impl Terminal { // 最后发送 Wakeup if pending_wakeup { pending_wakeup = false; - // 转发完毕后允许 alacritty 线程的下一次 Wakeup 重新入队, - // 避免高速输出时被 GpuiEventProxy 的去重永久吞掉 - wakeup_pending.store(false, Ordering::Release); if render_tx.unbounded_send(TerminalEvent::Wakeup).is_err() { return; } @@ -1886,25 +1863,27 @@ impl Terminal { } fn spawn_disconnect_handler( - disconnect_rx: tokio::sync::oneshot::Receiver<()>, + disconnect_rx: tokio::sync::oneshot::Receiver, generation: u64, cx: &mut Context, ) { let entity = cx.entity().downgrade(); cx.spawn(async move |_, cx| { - let _ = disconnect_rx.await; + let is_graceful = disconnect_rx.await.unwrap_or(false); let _ = entity.update(cx, |this, cx| { if !this.is_current_connection_generation(generation) { return; } + if is_graceful { + this.child_exited = Some(0); + cx.emit(TerminalModelEvent::ChildExit(0)); + } this.connection_state = ConnectionState::Disconnected { error: None }; this.connection_status_message = None; this.connection_wait_started_at = None; this.backend = None; - this.child_exited = Some(0); this.reset_ssh_process_tracking(); this.set_connection_active(false, cx); - cx.emit(TerminalModelEvent::ChildExit(0)); cx.emit(TerminalModelEvent::Wakeup); }); }) @@ -1980,7 +1959,7 @@ impl Terminal { event_proxy: GpuiEventProxy, event_tx: UnboundedSender, connection_id: Option, - on_disconnect: Option>, + on_disconnect: Option>, init_commands: Option, generation: u64, cx: &mut Context, @@ -1999,10 +1978,10 @@ impl Terminal { }); let disconnect_tx = on_disconnect.map(|tx| { - let (sender, receiver) = tokio::sync::oneshot::channel::<()>(); + let (sender, receiver) = tokio::sync::oneshot::channel::(); tokio::spawn(async move { - if receiver.await.is_ok() { - let _ = tx.send(()); + if let Ok(is_graceful) = receiver.await { + let _ = tx.send(is_graceful); } }); sender @@ -2020,7 +1999,6 @@ impl Terminal { move |stage| { let _ = progress_tx.send(stage); }, - config.disable_shell_integration, ) .await }); @@ -2123,15 +2101,15 @@ impl Terminal { params: SerialParams, term: Arc>>, event_tx: UnboundedSender, - on_disconnect: Option>, + on_disconnect: Option>, generation: u64, cx: &mut Context, ) { let disconnect_tx = on_disconnect.map(|tx| { - let (sender, receiver) = tokio::sync::oneshot::channel::<()>(); + let (sender, receiver) = tokio::sync::oneshot::channel::(); Tokio::spawn(cx, async move { - if receiver.await.is_ok() { - let _ = tx.send(()); + if let Ok(is_graceful) = receiver.await { + let _ = tx.send(is_graceful); } }) .detach(); @@ -2550,33 +2528,21 @@ impl Terminal { /// 调整终端大小 pub fn resize(&mut self, cols: usize, rows: usize, pixel_width: u16, pixel_height: u16) { if self.cols == cols && self.rows == rows { - // 单元格行列数未变,但仍记录最新像素尺寸,供 nudge_resize 复用 - self.pixel_width = pixel_width; - self.pixel_height = pixel_height; - tracing::debug!( - target: "terminal_residue", - cols, rows, pixel_width, pixel_height, - "Terminal::resize noop (cells unchanged, pixels cached)" - ); return; } tracing::info!( - target: "terminal_residue", - "Terminal::resize: {}x{} -> {}x{}, pixel={}x{}, backend={}", + "Terminal::resize: {}x{} -> {}x{}, pixel={}x{}", self.cols, self.rows, cols, rows, pixel_width, - pixel_height, - self.backend.is_some() + pixel_height ); self.cols = cols; self.rows = rows; - self.pixel_width = pixel_width; - self.pixel_height = pixel_height; self.term.lock().resize(TermDimensions { cols, rows }); @@ -2590,32 +2556,6 @@ impl Terminal { } } - /// 重新向 PTY 后端发送当前尺寸,不修改 alacritty grid。 - /// - /// 用于在 alt screen 切换等场景下触发 SIGWINCH, - /// 让 TUI 应用(opencode/lazygit/vim 等)重新查询尺寸并刷新整屏画面, - /// 避免出现底部残留旧画面的问题。 - pub fn nudge_resize(&self) { - let Some(ref backend) = self.backend else { - tracing::warn!(target: "terminal_residue", "nudge_resize skipped: no backend"); - return; - }; - tracing::info!( - target: "terminal_residue", - cols = self.cols, - rows = self.rows, - pixel_width = self.pixel_width, - pixel_height = self.pixel_height, - "Terminal::nudge_resize -> backend.resize" - ); - backend.resize(TerminalSize { - rows: self.rows as u16, - cols: self.cols as u16, - pixel_width: self.pixel_width, - pixel_height: self.pixel_height, - }); - } - /// 重新连接 SSH 或串口 pub fn reconnect(&mut self, cx: &mut Context) { self.reconnect_internal(false, cx); @@ -2672,7 +2612,7 @@ impl Terminal { return; } - let (disconnect_tx, disconnect_rx) = tokio::sync::oneshot::channel::<()>(); + let (disconnect_tx, disconnect_rx) = tokio::sync::oneshot::channel::(); Self::spawn_disconnect_handler(disconnect_rx, generation, cx); Self::spawn_ssh_connect( session_manager.clone(), @@ -2705,7 +2645,7 @@ impl Terminal { self.reset_terminal_surface(); let generation = self.next_connection_generation(); - let (disconnect_tx, disconnect_rx) = tokio::sync::oneshot::channel::<()>(); + let (disconnect_tx, disconnect_rx) = tokio::sync::oneshot::channel::(); Self::spawn_disconnect_handler(disconnect_rx, generation, cx); Self::spawn_serial_connect( params, @@ -3163,8 +3103,6 @@ mod tests { connection_state: ConnectionState::Connected, cols: 80, rows: 24, - pixel_width: 0, - pixel_height: 0, ssh_config: None, ssh_session_manager: None, serial_params: None, diff --git a/crates/terminal_view/locales/terminal_view.yml b/crates/terminal_view/locales/terminal_view.yml index a8d7983540..0c4e227f4a 100644 --- a/crates/terminal_view/locales/terminal_view.yml +++ b/crates/terminal_view/locales/terminal_view.yml @@ -344,7 +344,6 @@ SSH: en: Default working directory zh-CN: 默认工作目录 zh-HK: 默認工作目錄 -<<<<<<< HEAD sftp_local_directory: en: SFTP Local Directory zh-CN: SFTP 本地目录 @@ -361,16 +360,6 @@ SSH: en: Leave empty to use server default directory zh-CN: 留空则使用服务器默认目录 zh-HK: 留空則使用伺服器預設目錄 -======= - disable_shell_integration: - en: Disable Shell Integration - zh-CN: 禁用 Shell 集成 - zh-HK: 禁用 Shell 集成 - disable_shell_integration_desc: - en: Run native login shell without OSC injection (no prompt hook, command recording, or vim mouse) - zh-CN: 走裸 login shell,不注入 OSC(失去命令记录 / prompt hook / vim 鼠标) - zh-HK: 走裸 login shell,不注入 OSC(失去命令記錄 / prompt hook / vim 鼠標) ->>>>>>> bf9b852a (feat(terminal): 新增关闭 shell integration 功能) # 其他设置 remark: en: Remark diff --git a/crates/terminal_view/src/keys.rs b/crates/terminal_view/src/keys.rs index fe39cd9bbf..3453478b88 100644 --- a/crates/terminal_view/src/keys.rs +++ b/crates/terminal_view/src/keys.rs @@ -331,160 +331,4 @@ mod tests { "\x1ba" ); } - - #[test] - fn enter_emits_carriage_return() { - let enter = Keystroke::parse("enter").unwrap(); - assert_eq!( - to_esc_str(&enter, &TermMode::NONE, false).unwrap().as_ref(), - "\x0d" - ); - } - - #[test] - fn backspace_emits_del_by_default() { - let bs = Keystroke::parse("backspace").unwrap(); - assert_eq!( - to_esc_str(&bs, &TermMode::NONE, false).unwrap().as_ref(), - "\x7f" - ); - } - - #[test] - fn ctrl_backspace_emits_bs() { - let bs = Keystroke::parse("ctrl-backspace").unwrap(); - assert_eq!( - to_esc_str(&bs, &TermMode::NONE, false).unwrap().as_ref(), - "\x08" - ); - } - - #[test] - fn shift_tab_emits_csi_z() { - let shift_tab = Keystroke::parse("shift-tab").unwrap(); - assert_eq!( - to_esc_str(&shift_tab, &TermMode::NONE, false) - .unwrap() - .as_ref(), - "\x1b[Z" - ); - } - - #[test] - fn home_app_cursor_mode_emits_ss3() { - let home = Keystroke::parse("home").unwrap(); - assert_eq!( - to_esc_str(&home, &TermMode::NONE, false).unwrap().as_ref(), - "\x1b[H" - ); - assert_eq!( - to_esc_str(&home, &TermMode::APP_CURSOR, false) - .unwrap() - .as_ref(), - "\x1bOH" - ); - } - - #[test] - fn page_up_down_emit_csi_tilde() { - let pageup = Keystroke::parse("pageup").unwrap(); - let pagedown = Keystroke::parse("pagedown").unwrap(); - assert_eq!( - to_esc_str(&pageup, &TermMode::NONE, false) - .unwrap() - .as_ref(), - "\x1b[5~" - ); - assert_eq!( - to_esc_str(&pagedown, &TermMode::NONE, false) - .unwrap() - .as_ref(), - "\x1b[6~" - ); - } - - #[test] - fn insert_delete_emit_csi_tilde() { - let ins = Keystroke::parse("insert").unwrap(); - let del = Keystroke::parse("delete").unwrap(); - assert_eq!( - to_esc_str(&ins, &TermMode::NONE, false).unwrap().as_ref(), - "\x1b[2~" - ); - assert_eq!( - to_esc_str(&del, &TermMode::NONE, false).unwrap().as_ref(), - "\x1b[3~" - ); - } - - #[test] - fn ctrl_letter_covers_full_alphabet() { - // Ctrl-A => 0x01, Ctrl-Z => 0x1a - for (key, expected) in [("ctrl-a", 0x01u8), ("ctrl-m", 0x0d), ("ctrl-z", 0x1a)] { - let ks = Keystroke::parse(key).unwrap(); - let seq = to_esc_str(&ks, &TermMode::NONE, false).unwrap(); - assert_eq!(seq.as_ref().as_bytes(), &[expected], "{key}"); - } - } - - #[test] - fn ctrl_bracket_and_underscore_emit_c0() { - assert_eq!( - to_esc_str(&Keystroke::parse("ctrl-[").unwrap(), &TermMode::NONE, false) - .unwrap() - .as_ref() - .as_bytes(), - b"\x1b" - ); - assert_eq!( - to_esc_str(&Keystroke::parse("ctrl-_").unwrap(), &TermMode::NONE, false) - .unwrap() - .as_ref() - .as_bytes(), - b"\x1f" - ); - } - - #[test] - fn ctrl_space_emits_nul() { - let ks = Keystroke::parse("ctrl-space").unwrap(); - assert_eq!( - to_esc_str(&ks, &TermMode::NONE, false) - .unwrap() - .as_ref() - .as_bytes(), - b"\x00" - ); - } - - #[test] - fn shift_arrow_in_alt_screen_remains_none_in_normal_mode() { - // 锁定当前行为:normal screen 下 shift-arrow 不发送修饰序列 - let shift_up = Keystroke::parse("shift-up").unwrap(); - assert_eq!(to_esc_str(&shift_up, &TermMode::NONE, false), None); - } - - #[test] - fn ctrl_arrow_emits_csi_with_modifier_param_5() { - // xterm modifier param: ctrl=4 => +1 = 5 - let ctrl_right = Keystroke::parse("ctrl-right").unwrap(); - assert_eq!( - to_esc_str(&ctrl_right, &TermMode::NONE, false) - .unwrap() - .as_ref(), - "\x1b[1;5C" - ); - } - - #[test] - fn alt_arrow_emits_csi_with_modifier_param_3() { - // alt=2 => +1 = 3 - let alt_left = Keystroke::parse("alt-left").unwrap(); - assert_eq!( - to_esc_str(&alt_left, &TermMode::NONE, false) - .unwrap() - .as_ref(), - "\x1b[1;3D" - ); - } } diff --git a/crates/terminal_view/src/ssh_form_window.rs b/crates/terminal_view/src/ssh_form_window.rs index c526581f8b..87191dbb58 100644 --- a/crates/terminal_view/src/ssh_form_window.rs +++ b/crates/terminal_view/src/ssh_form_window.rs @@ -171,9 +171,6 @@ pub struct SshFormWindow { // 云同步开关 sync_enabled: bool, - // 关闭 shell integration 注入(走裸 request_shell,失去 OSC 集成) - disable_shell_integration: bool, - is_testing: bool, test_status_message: Option, test_started_at: Option, @@ -351,7 +348,6 @@ impl SshFormWindow { let mut enable_legacy_kex = false; let mut sync_enabled = true; // 默认启用云同步 let mut editing_credential_ref: Option = None; - let mut disable_shell_integration = false; if let Some(ref conn) = config.editing_connection { // 加载同步状态 @@ -417,7 +413,6 @@ impl SshFormWindow { if let Some(ref dir) = params.sftp_remote_directory { sftp_remote_directory_input.update(cx, |s, cx| s.set_value(dir, window, cx)); } - disable_shell_integration = params.disable_shell_integration.unwrap_or(false); // 加载跳板机设置 if let Some(ref jump) = params.jump_server { @@ -505,7 +500,6 @@ impl SshFormWindow { pending_key_content, last_tested_signature: None, sync_enabled, - disable_shell_integration, is_testing: false, test_status_message: None, test_started_at: None, @@ -845,11 +839,7 @@ impl SshFormWindow { init_script, sftp_local_directory, sftp_remote_directory, - disable_shell_integration: if self.disable_shell_integration { - Some(true) - } else { - None - }, + disable_shell_integration: None, jump_server, proxy, }) @@ -1309,7 +1299,7 @@ impl SshFormWindow { } /// 渲染初始化标签页 - fn render_init_tab(&self, cx: &mut Context) -> impl IntoElement { + fn render_init_tab(&self) -> impl IntoElement { v_flex() .gap_2() .child(self.render_form_row( @@ -1328,31 +1318,6 @@ impl SshFormWindow { &t!("SSH.sftp_remote_directory"), self.styled_input(Input::new(&self.sftp_remote_directory_input)), )) - .child( - self.render_form_row(&t!("SSH.init_script"), Input::new(&self.init_script_input)), - ) - .child( - self.render_form_row( - &t!("SSH.disable_shell_integration"), - h_flex() - .gap_2() - .child( - Checkbox::new("disable-shell-integration") - .checked(self.disable_shell_integration) - .on_click(cx.listener(|this, _, _, cx| { - this.disable_shell_integration = - !this.disable_shell_integration; - cx.notify(); - })), - ) - .child( - div() - .text_sm() - .text_color(cx.theme().muted_foreground) - .child(t!("SSH.disable_shell_integration_desc").to_string()), - ), - ), - ) } /// 渲染跳板机标签页 @@ -1667,7 +1632,7 @@ impl Render for SshFormWindow { .overflow_y_scroll() .child(match active_tab { 0 => self.render_basic_tab(cx).into_any_element(), - 1 => self.render_init_tab(cx).into_any_element(), + 1 => self.render_init_tab().into_any_element(), 2 => self.render_jump_server_tab(cx).into_any_element(), 3 => self.render_proxy_tab(cx).into_any_element(), 4 => self.render_advanced_tab(cx).into_any_element(), @@ -1760,7 +1725,6 @@ mod tests { init_script: Some("pwd".to_string()), sftp_local_directory: None, sftp_remote_directory: None, - disable_shell_integration: None, jump_server: None, proxy: None, } diff --git a/crates/terminal_view/src/terminal_element.rs b/crates/terminal_view/src/terminal_element.rs index ee88d65436..341bc715d6 100644 --- a/crates/terminal_view/src/terminal_element.rs +++ b/crates/terminal_view/src/terminal_element.rs @@ -12,7 +12,7 @@ use alacritty_terminal::grid::Dimensions; use alacritty_terminal::selection::SelectionRange; use alacritty_terminal::term::cell::Flags; use alacritty_terminal::term::color::Colors; -use alacritty_terminal::term::{RenderableContent, Term, TermDamage, TermMode}; +use alacritty_terminal::term::{RenderableContent, Term, TermDamage}; use alacritty_terminal::vte::ansi::{Color, CursorShape, NamedColor, Rgb}; use gpui::*; use std::collections::HashMap; @@ -111,79 +111,6 @@ fn is_decorative_character(ch: char) -> bool { ) } -/// 为 Unicode 块字符(U+2580..U+259F)生成几何矩形序列。 -/// -/// 返回的 rect 坐标以 cell 自身宽高的 [0, 1] 归一化系数表示, -/// 调用方在 paint 阶段乘以 cell_width / cell_height 得到像素矩形。 -/// -/// 几何绘制避免依赖字体字形,可解决字体回退时块状字符出现接缝、 -/// 抗锯齿不一致或 line-height gap 导致的视觉断层问题。 -fn block_element_geometry(c: char) -> Option> { - fn rect(x: f32, y: f32, w: f32, h: f32) -> BlockRect { - BlockRect { x, y, w, h } - } - fn lower(fraction: f32) -> Vec { - vec![rect(0.0, 1.0 - fraction, 1.0, fraction)] - } - fn left(fraction: f32) -> Vec { - vec![rect(0.0, 0.0, fraction, 1.0)] - } - const QUAD_UPPER_LEFT: u8 = 1 << 0; - const QUAD_UPPER_RIGHT: u8 = 1 << 1; - const QUAD_LOWER_LEFT: u8 = 1 << 2; - const QUAD_LOWER_RIGHT: u8 = 1 << 3; - fn quadrants(mask: u8) -> Vec { - let mut out = Vec::with_capacity(4); - if mask & QUAD_UPPER_LEFT != 0 { - out.push(rect(0.0, 0.0, 0.5, 0.5)); - } - if mask & QUAD_UPPER_RIGHT != 0 { - out.push(rect(0.5, 0.0, 0.5, 0.5)); - } - if mask & QUAD_LOWER_LEFT != 0 { - out.push(rect(0.0, 0.5, 0.5, 0.5)); - } - if mask & QUAD_LOWER_RIGHT != 0 { - out.push(rect(0.5, 0.5, 0.5, 0.5)); - } - out - } - - Some(match c { - '\u{2580}' => vec![rect(0.0, 0.0, 1.0, 0.5)], // ▀ upper half - '\u{2581}' => lower(1.0 / 8.0), // ▁ - '\u{2582}' => lower(2.0 / 8.0), // ▂ - '\u{2583}' => lower(3.0 / 8.0), // ▃ - '\u{2584}' => lower(4.0 / 8.0), // ▄ - '\u{2585}' => lower(5.0 / 8.0), // ▅ - '\u{2586}' => lower(6.0 / 8.0), // ▆ - '\u{2587}' => lower(7.0 / 8.0), // ▇ - '\u{2588}' => vec![rect(0.0, 0.0, 1.0, 1.0)], // █ full block - '\u{2589}' => left(7.0 / 8.0), // ▉ - '\u{258A}' => left(6.0 / 8.0), // ▊ - '\u{258B}' => left(5.0 / 8.0), // ▋ - '\u{258C}' => left(4.0 / 8.0), // ▌ - '\u{258D}' => left(3.0 / 8.0), // ▍ - '\u{258E}' => left(2.0 / 8.0), // ▎ - '\u{258F}' => left(1.0 / 8.0), // ▏ - '\u{2590}' => vec![rect(0.5, 0.0, 0.5, 1.0)], // ▐ right half - // U+2591..U+2593 阴影块由文本路径处理(依赖字体本身的密度图,更自然) - '\u{2594}' => vec![rect(0.0, 0.0, 1.0, 1.0 / 8.0)], // ▔ upper one-eighth - '\u{2595}' => vec![rect(7.0 / 8.0, 0.0, 1.0 / 8.0, 1.0)], // ▕ right one-eighth - '\u{2596}' => quadrants(QUAD_LOWER_LEFT), - '\u{2597}' => quadrants(QUAD_LOWER_RIGHT), - '\u{2598}' => quadrants(QUAD_UPPER_LEFT), - '\u{2599}' => quadrants(QUAD_UPPER_LEFT | QUAD_LOWER_LEFT | QUAD_LOWER_RIGHT), - '\u{259A}' => quadrants(QUAD_UPPER_LEFT | QUAD_LOWER_RIGHT), - '\u{259B}' => quadrants(QUAD_UPPER_LEFT | QUAD_UPPER_RIGHT | QUAD_LOWER_LEFT), - '\u{259C}' => quadrants(QUAD_UPPER_LEFT | QUAD_UPPER_RIGHT | QUAD_LOWER_RIGHT), - '\u{259D}' => quadrants(QUAD_UPPER_RIGHT), - '\u{259E}' => quadrants(QUAD_UPPER_RIGHT | QUAD_LOWER_LEFT), - '\u{259F}' => quadrants(QUAD_UPPER_RIGHT | QUAD_LOWER_LEFT | QUAD_LOWER_RIGHT), - _ => return None, - }) -} - /// Manages decorations from all addons pub struct DecorationManager { // Decorations indexed by line number @@ -279,8 +206,6 @@ impl DecorationManager { pub struct CachedLine { pub background_rects: Vec<(usize, usize, Hsla)>, pub text_runs: Vec, - /// 块状字符(U+2580..U+259F)使用几何绘制,避免字体回退导致的接缝 - pub block_glyphs: Vec, } #[derive(Clone)] @@ -294,25 +219,6 @@ pub struct CachedTextRun { pub char_count: usize, } -/// 单个 cell 内的几何块字符渲染数据 -/// -/// rects 中的坐标均归一化到 cell 自身的 [0, 1] 范围, -/// paint 时再按当前 cell_width/cell_height 缩放为像素矩形。 -#[derive(Clone)] -pub struct CachedBlockGlyph { - pub column: usize, - pub color: Hsla, - pub rects: Vec, -} - -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct BlockRect { - pub x: f32, - pub y: f32, - pub w: f32, - pub h: f32, -} - /// Terminal rendering cache maintained by TerminalView pub struct RenderCache { lines: Vec, @@ -344,23 +250,6 @@ struct CachedCursor { shape: CursorShape, } -enum DamageSnapshot { - Full, - Partial(Vec), -} - -impl DamageSnapshot { - fn from_term_damage(damage: TermDamage<'_>) -> Self { - match damage { - TermDamage::Full => Self::Full, - TermDamage::Partial(iter) => { - let lines = iter.map(|line_damage| line_damage.line).collect(); - Self::Partial(lines) - } - } - } -} - impl RenderCache { pub fn new(num_lines: usize, num_cols: usize, colors: Colors) -> Self { let default_bg = convert_color(Color::Named(NamedColor::Background), &colors); @@ -368,8 +257,7 @@ impl RenderCache { lines: vec![ CachedLine { background_rects: Vec::new(), - text_runs: Vec::new(), - block_glyphs: Vec::new(), + text_runs: Vec::new() }; num_lines ], @@ -399,20 +287,9 @@ impl RenderCache { // Handle resize if num_lines != self.num_lines || num_cols != self.num_cols { - tracing::info!( - target: "terminal_residue", - old_lines = self.num_lines, - old_cols = self.num_cols, - new_lines = num_lines, - new_cols = num_cols, - "RenderCache::resize" - ); self.resize(num_lines, num_cols); } - let damage = DamageSnapshot::from_term_damage(term.damage()); - term.reset_damage(); - // Collect decorations from all addons let display_offset = term.grid().display_offset(); self.decoration_manager @@ -432,51 +309,37 @@ impl RenderCache { // 同步主题光标颜色 self.custom_cursor = theme.cursor; - // 在任何 full rebuild 早返回之前同步终端调色板。 + // Force full rebuild when theme colors or decorations changed + let has_decorations = !self.decoration_manager.decorations_by_line.is_empty(); + if fg_changed || bg_changed || has_decorations { + self.rebuild_all(term); + self.update_last_selection(term); + return; + } + + // Check terminal color palette changes let colors = term.colors(); - let colors_changed = !colors_equal(&self.colors, colors); - if colors_changed { + if !colors_equal(&self.colors, colors) { self.colors = colors.clone(); self.default_bg = convert_color(Color::Named(NamedColor::Background), &self.colors); - } - - // 主题颜色变化或存在装饰时保守全量重建。 - let has_decorations = !self.decoration_manager.decorations_by_line.is_empty(); - if fg_changed || bg_changed || colors_changed || has_decorations { - tracing::debug!( - target: "terminal_residue", - fg_changed, - bg_changed, - colors_changed, - has_decorations, - num_lines, - "rebuild_all (forced by theme/decoration)" - ); - self.rebuild_all_and_update_state(term); + self.rebuild_all(term); + self.update_last_selection(term); return; } + // Collect dirty lines from terminal damage let mut dirty_lines: std::collections::HashSet = std::collections::HashSet::new(); + let damage = term.damage(); match damage { - DamageSnapshot::Full => { - tracing::debug!( - target: "terminal_residue", - num_lines, - "rebuild_all (TermDamage::Full)" - ); - self.rebuild_all_and_update_state(term); + TermDamage::Full => { + self.rebuild_all(term); + self.update_last_selection(term); return; } - DamageSnapshot::Partial(lines) => { - if !lines.is_empty() { - tracing::debug!( - target: "terminal_residue", - damaged = ?lines, - num_lines, - "Partial damage" - ); + TermDamage::Partial(iter) => { + for line_damage in iter { + dirty_lines.insert(line_damage.line); } - dirty_lines.extend(lines); } } @@ -526,18 +389,11 @@ impl RenderCache { CachedLine { background_rects: Vec::new(), text_runs: Vec::new(), - block_glyphs: Vec::new(), }, ); self.left_edge_fingerprint.resize(num_lines, 0); } - fn rebuild_all_and_update_state(&mut self, term: &Term) { - self.rebuild_all(term); - self.update_last_selection(term); - self.sync_left_edge_fingerprint(term, 4); - } - fn rebuild_all(&mut self, term: &Term) { let content = term.renderable_content(); let display_offset = content.display_offset; @@ -547,7 +403,6 @@ impl RenderCache { for line in &mut self.lines { line.background_rects.clear(); line.text_runs.clear(); - line.block_glyphs.clear(); } // Group cells by screen line @@ -582,34 +437,6 @@ impl RenderCache { // Update cursor from a fresh content let content = term.renderable_content(); self.update_cursor_from_content(&content); - - // 调试日志:统计 cache 重建后各行的内容分布。 - // 关注底部最后 8 行,若 TUI 仅画了上半部,底部 8 行的 text/bg 应该为空。 - let total = self.lines.len(); - let non_empty_lines = self - .lines - .iter() - .filter(|l| !l.text_runs.is_empty() || !l.background_rects.is_empty()) - .count(); - let mut tail_summary = Vec::new(); - let tail_start = total.saturating_sub(8); - for idx in tail_start..total { - let l = &self.lines[idx]; - tail_summary.push(format!( - "[{idx}] bg={} text={} chars={}", - l.background_rects.len(), - l.text_runs.len(), - l.text_runs.iter().map(|r| r.char_count).sum::(), - )); - } - tracing::debug!( - target: "terminal_residue", - total_lines = total, - non_empty_lines, - in_alt_screen = content.mode.contains(TermMode::ALT_SCREEN), - tail = tail_summary.join(" | "), - "rebuild_all done" - ); } /// Rebuild specified lines @@ -654,7 +481,6 @@ impl RenderCache { if line_idx < self.num_lines { self.lines[line_idx].background_rects.clear(); self.lines[line_idx].text_runs.clear(); - self.lines[line_idx].block_glyphs.clear(); let cells = std::mem::take(&mut line_cells[line_idx]); self.build_line_cache(line_idx, cells); } @@ -712,39 +538,8 @@ impl RenderCache { term: &Term, probe_cols: usize, ) -> Vec { - let current = self.compute_left_edge_fingerprint(term, probe_cols); - - if self.left_edge_fingerprint.len() != self.num_lines { - self.left_edge_fingerprint.resize(self.num_lines, 0); - } - - let mut changed = Vec::new(); - for (line_idx, (old, new)) in self - .left_edge_fingerprint - .iter() - .zip(current.iter()) - .enumerate() - { - if old != new { - changed.push(line_idx); - } - } - - self.left_edge_fingerprint = current; - changed - } - - fn sync_left_edge_fingerprint(&mut self, term: &Term, probe_cols: usize) { - self.left_edge_fingerprint = self.compute_left_edge_fingerprint(term, probe_cols); - } - - fn compute_left_edge_fingerprint( - &self, - term: &Term, - probe_cols: usize, - ) -> Vec { if self.num_lines == 0 || probe_cols == 0 { - return vec![0; self.num_lines]; + return Vec::new(); } let mut current = vec![0_u64; self.num_lines]; @@ -772,7 +567,24 @@ impl RenderCache { .wrapping_add(piece.wrapping_add(1469598103934665603)); } - current + if self.left_edge_fingerprint.len() != self.num_lines { + self.left_edge_fingerprint.resize(self.num_lines, 0); + } + + let mut changed = Vec::new(); + for (line_idx, (old, new)) in self + .left_edge_fingerprint + .iter() + .zip(current.iter()) + .enumerate() + { + if old != new { + changed.push(line_idx); + } + } + + self.left_edge_fingerprint = current; + changed } fn build_line_cache(&mut self, line_idx: usize, mut cells: Vec) { @@ -880,19 +692,6 @@ impl RenderCache { continue; } - // 块状字符走几何路径,避免不同字体渲染出现接缝 - if let Some(rects) = block_element_geometry(cell.c) { - if let Some(run) = text_run.take() { - line.text_runs.push(run); - } - line.block_glyphs.push(CachedBlockGlyph { - column: cell.column, - color: fg, - rects, - }); - continue; - } - let bold = cell.flags.contains(Flags::BOLD); let italic = cell.flags.contains(Flags::ITALIC); @@ -1181,16 +980,6 @@ impl Element for TerminalElementImpl { let intersection = content_mask.intersect(&terminal_bounds); if intersection.size.height <= px(0.) || intersection.size.width <= px(0.) { - tracing::debug!( - target: "terminal_residue", - lines = self.lines.len(), - num_cols = self.num_cols, - cell_w = ?tb.cell_width, - cell_h = ?tb.cell_height, - origin = ?tb.origin, - content_mask = ?content_mask, - "paint skipped (no intersection)" - ); return; // 完全不可见,跳过渲染 } @@ -1208,26 +997,6 @@ impl Element for TerminalElementImpl { .ceil() as usize; let visible_end = last_visible.min(self.lines.len()); - // 仅在统计行数 / 像素差异时记录一次,避免每帧爆量 - let cm_h: f32 = content_mask.size.height.into(); - let tb_h: f32 = terminal_height.into(); - if (cm_h - tb_h).abs() > 0.5 || self.lines.len() < visible_end { - tracing::debug!( - target: "terminal_residue", - lines = self.lines.len(), - num_cols = self.num_cols, - cell_w = ?tb.cell_width, - cell_h = ?tb.cell_height, - origin = ?tb.origin, - terminal_bounds_h = ?terminal_height, - content_mask = ?content_mask, - first_visible, - visible_end, - bg_alpha = self.custom_background.a, - "paint metrics" - ); - } - // Paint backgrounds (only visible lines) for line_idx in first_visible..visible_end { let line = &self.lines[line_idx]; @@ -1240,24 +1009,6 @@ impl Element for TerminalElementImpl { } } - // Paint block-element geometry(在文字之前,与背景同样的覆盖关系) - for line_idx in first_visible..visible_end { - let line = &self.lines[line_idx]; - for glyph in &line.block_glyphs { - let cell_origin = tb.cell_origin(line_idx, glyph.column); - for r in &glyph.rects { - let rect = Bounds::new( - Point::new( - cell_origin.x + tb.cell_width * r.x, - cell_origin.y + tb.cell_height * r.y, - ), - size(tb.cell_width * r.w, tb.cell_height * r.h), - ); - window.paint_quad(fill(rect, glyph.color)); - } - } - } - // Paint text (only visible lines, using cached fonts) // 使用 cell_width 确保等宽渲染,避免字符布局漂移 for line_idx in first_visible..visible_end { @@ -1548,23 +1299,23 @@ fn indexed_color_to_hsla(idx: u8) -> Hsla { #[cfg(test)] mod tests { - use super::{BlockRect, block_element_geometry, hsla_eq, terminal_font_features, CellData, RenderCache}; + use super::{hsla_eq, terminal_font_features, CellData, RenderCache}; use alacritty_terminal::term::cell::Flags; use alacritty_terminal::term::color::Colors; use alacritty_terminal::vte::ansi::{Color, NamedColor}; use gpui::hsla; - fn approx_eq(a: f32, b: f32) -> bool { - (a - b).abs() < 1e-5 - } + #[test] + fn terminal_font_features_explicitly_enable_all_ligature_tags() { + let features = terminal_font_features(true); - fn assert_rect(actual: &BlockRect, x: f32, y: f32, w: f32, h: f32) { - assert!( - approx_eq(actual.x, x) - && approx_eq(actual.y, y) - && approx_eq(actual.w, w) - && approx_eq(actual.h, h), - "expected ({x}, {y}, {w}, {h}) got {actual:?}" + assert_eq!( + features.tag_value_list(), + &[ + ("liga".to_string(), 1), + ("clig".to_string(), 1), + ("calt".to_string(), 1), + ] ); } @@ -1639,95 +1390,4 @@ mod tests { cache.custom_background )); } - - #[test] - fn full_block_covers_entire_cell() { - let rects = block_element_geometry('\u{2588}').expect("full block"); - assert_eq!(rects.len(), 1); - assert_rect(&rects[0], 0.0, 0.0, 1.0, 1.0); - } - - #[test] - fn lower_half_block_fills_bottom_half() { - let rects = block_element_geometry('\u{2584}').expect("lower half"); - assert_eq!(rects.len(), 1); - assert_rect(&rects[0], 0.0, 0.5, 1.0, 0.5); - } - - #[test] - fn upper_half_block_fills_top_half() { - let rects = block_element_geometry('\u{2580}').expect("upper half"); - assert_eq!(rects.len(), 1); - assert_rect(&rects[0], 0.0, 0.0, 1.0, 0.5); - } - - #[test] - fn left_half_block_fills_left_half() { - let rects = block_element_geometry('\u{258C}').expect("left half"); - assert_eq!(rects.len(), 1); - assert_rect(&rects[0], 0.0, 0.0, 0.5, 1.0); - } - - #[test] - fn right_half_block_fills_right_half() { - let rects = block_element_geometry('\u{2590}').expect("right half"); - assert_eq!(rects.len(), 1); - assert_rect(&rects[0], 0.5, 0.0, 0.5, 1.0); - } - - #[test] - fn quadrant_block_lower_left_only() { - let rects = block_element_geometry('\u{2596}').expect("quadrant lower left"); - assert_eq!(rects.len(), 1); - assert_rect(&rects[0], 0.0, 0.5, 0.5, 0.5); - } - - #[test] - fn quadrant_block_diagonal_pair() { - let rects = block_element_geometry('\u{259A}').expect("quadrant diagonal"); - assert_eq!(rects.len(), 2); - // 上左 + 下右 - let mut found_upper_left = false; - let mut found_lower_right = false; - for r in &rects { - if approx_eq(r.x, 0.0) && approx_eq(r.y, 0.0) { - found_upper_left = true; - } - if approx_eq(r.x, 0.5) && approx_eq(r.y, 0.5) { - found_lower_right = true; - } - } - assert!(found_upper_left && found_lower_right); - } - - #[test] - fn shade_blocks_fall_back_to_text_path() { - // U+2591..U+2593 阴影块继续走文本路径,避免几何绘制无法表达密度 - assert!(block_element_geometry('\u{2591}').is_none()); - assert!(block_element_geometry('\u{2592}').is_none()); - assert!(block_element_geometry('\u{2593}').is_none()); - } - - #[test] - fn non_block_characters_return_none() { - // Box drawing 不在本批几何路径内 - assert!(block_element_geometry('─').is_none()); - // 普通字符也不返回几何 - assert!(block_element_geometry('A').is_none()); - } - - #[test] - fn eighth_lower_blocks_use_one_eighth_increments() { - for (i, ch) in [ - '\u{2581}', '\u{2582}', '\u{2583}', '\u{2584}', '\u{2585}', '\u{2586}', '\u{2587}', - ] - .iter() - .enumerate() - { - let fraction = (i + 1) as f32 / 8.0; - let rects = block_element_geometry(*ch).expect("lower eighth"); - assert_eq!(rects.len(), 1); - assert_rect(&rects[0], 0.0, 1.0 - fraction, 1.0, fraction); - } - } } diff --git a/crates/terminal_view/src/view.rs b/crates/terminal_view/src/view.rs index b4d3643d5a..7f36826925 100644 --- a/crates/terminal_view/src/view.rs +++ b/crates/terminal_view/src/view.rs @@ -248,51 +248,17 @@ fn take_whole_scroll_lines(scroll_lines_accumulated: &mut f32) -> i32 { lines } -fn sgr_mouse_wheel_report(lines: i32, col: usize, row: usize) -> Option { +fn alt_screen_scroll_arrow(lines: i32, app_cursor: bool) -> Option<&'static str> { if lines == 0 { return None; } - let button = if lines > 0 { 64 } else { 65 }; - Some(format!("\x1b[<{};{};{}M", button, col + 1, row + 1)) -} - -/// 生成 SGR 鼠标按钮报告。 -/// -/// - `button`:xterm 按钮编码(0=左键、1=中键、2=右键,加上 shift/alt/ctrl/拖动等位) -/// - `pressed`:true 用 `M` 表示按下,false 用 `m` 表示释放(SGR 协议规定) -/// - `col` / `row`:0-based,输出转为 1-based -/// -/// 抽出为独立纯函数,便于单元测试和后续扩展(拖动 32 位、wheel-with-modifiers 等)。 -fn sgr_mouse_button_report(button: u8, col: usize, row: usize, pressed: bool) -> String { - let suffix = if pressed { 'M' } else { 'm' }; - format!("\x1b[<{};{};{}{}", button, col + 1, row + 1, suffix) -} - -/// 将 GPUI 鼠标按钮映射为 xterm 按钮基础编码:左=0、中=1、右=2。 -/// 其它按钮(X1/X2 等)当前未在 SGR 报告中使用,返回 None。 -fn mouse_button_code(button: MouseButton) -> Option { - match button { - MouseButton::Left => Some(0), - MouseButton::Middle => Some(1), - MouseButton::Right => Some(2), - _ => None, - } -} - -/// 将修饰键编码到 xterm 鼠标按钮的高位:shift=4、alt=8、control=16。 -fn encode_mouse_modifiers(modifiers: Modifiers) -> u8 { - let mut bits = 0u8; - if modifiers.shift { - bits |= 4; - } - if modifiers.alt { - bits |= 8; - } - if modifiers.control { - bits |= 16; - } - bits + Some(match (lines > 0, app_cursor) { + (true, true) => "\x1bOA", // Up, application mode + (true, false) => "\x1b[A", // Up, normal mode + (false, true) => "\x1bOB", // Down, application mode + (false, false) => "\x1b[B", // Down, normal mode + }) } fn should_scroll_to_bottom_on_user_input( @@ -602,12 +568,6 @@ pub struct TerminalView { cell_width: Pixels, last_size: Option<(usize, usize)>, - /// 上一帧 alacritty 是否处于 alt screen 模式。 - /// - /// 用于检测主屏与备用屏切换:进入 alt screen 时主动调用 nudge_resize - /// 重发当前尺寸给 PTY,触发 SIGWINCH,让 TUI 应用刷新整屏画面, - /// 避免出现底部残留上一次渲染内容的问题。 - last_alt_screen: bool, scroll_lines_accumulated: f32, mouse_state: MouseState, @@ -1045,7 +1005,6 @@ impl TerminalView { // 初始化为 None,确保首次渲染时会触发 resize, // 将正确的终端尺寸发送给 PTY last_size: None, - last_alt_screen: false, scroll_lines_accumulated: 0.0, mouse_state: MouseState::default(), addon_manager: Self::create_addon_manager(), @@ -1825,10 +1784,10 @@ impl TerminalView { // 可选:播放声音或闪烁标签 } TerminalModelEvent::ChildExit(_) => { - // 仅本地终端在 shell 退出时自动关闭标签。 - // SSH / 串口连接失败或远端会话结束时需要保留标签, - // 以便用户查看错误信息或执行重连。 - if self.connection_kind(cx) == TerminalConnectionKind::Local { + // 本地终端 shell 退出,或 SSH / 串口用户主动 exit 时自动关闭标签。 + // 网络故障等异常断开保留标签,以便用户查看错误信息或执行重连。 + let should_close = self.terminal.read(cx).child_exited().is_some(); + if should_close { self.request_close_from_event(_window, cx); } cx.notify(); @@ -3055,16 +3014,6 @@ impl TerminalView { let new_size = (cols, rows); if self.last_size != Some(new_size) { - tracing::info!( - target: "terminal_residue", - old = ?self.last_size, - new = ?new_size, - bounds_w = ?bounds.size.width, - bounds_h = ?bounds.size.height, - cell_width = ?self.cell_width, - line_height = ?self.line_height, - "resize_if_needed -> Terminal::resize" - ); self.last_size = Some(new_size); self.terminal.update(cx, |terminal, _| { terminal.resize( @@ -3406,7 +3355,7 @@ impl TerminalView { this.request_close(window, cx); })), ) - .when(can_reconnect && !is_key_changed, |el| { + .when(can_reconnect && !is_key_changed && !is_user_exit, |el| { el.child( Button::new("reconnect-btn") .label(t!("SshSession.reconnect")) @@ -3458,14 +3407,11 @@ impl TerminalView { } if mode.contains(TermMode::ALT_SCREEN) { - if mode.contains(TermMode::SGR_MOUSE) && mode.intersects(TermMode::MOUSE_MODE) { - let point = self.pixel_to_point(event.position, self.terminal_bounds, cx); - if let Some(report) = - sgr_mouse_wheel_report(lines, point.column.0, point.line.0 as usize) - { - for _ in 0..lines.unsigned_abs() { - self.write_to_pty(report.as_bytes().to_vec(), cx); - } + // ALT_SCREEN(vim、less 等):累计到整行后再转为上下箭头,避免放大小幅滚轮输入 + if let Some(arrow) = alt_screen_scroll_arrow(lines, mode.contains(TermMode::APP_CURSOR)) + { + for _ in 0..lines.abs() { + self.write_to_pty(arrow.as_bytes().to_vec(), cx); } } return; @@ -3532,55 +3478,13 @@ impl TerminalView { } } - /// 当终端启用 SGR 鼠标 + 任意鼠标报告模式时,把按钮按下/释放事件以 SGR 形式 - /// 回报给 PTY。返回 true 表示已经处理,调用方应跳过 selection/dismiss/paste 等本地行为。 - /// - /// 特殊穿透:Shift+Left 永远走终端自身的文本选区,不向 TUI 转发 —— 这是 xterm/iTerm/ - /// kitty/wezterm 等的通用约定,让用户在 vim/tmux 等捕获鼠标的应用里仍能复制文本。 - /// 同理 mouse_up 时,如果当前正在终端选区(由 shift+drag 启动),也跳过 release 回报, - /// 避免在 release 阶段 shift 已松开就把 release 事件错发给 TUI、丢掉 selection 收尾。 - fn try_report_sgr_mouse_button( - &mut self, - button: MouseButton, - position: Point, - modifiers: Modifiers, - pressed: bool, - cx: &mut Context, - ) -> bool { - if button == MouseButton::Left - && (modifiers.shift || (!pressed && self.mouse_state.selecting)) - { - return false; - } - let mode = self.terminal.read(cx).mode(); - if !(mode.contains(TermMode::SGR_MOUSE) && mode.intersects(TermMode::MOUSE_MODE)) { - return false; - } - let Some(base) = mouse_button_code(button) else { - return false; - }; - let point = self.pixel_to_point(position, self.terminal_bounds, cx); - let encoded = base | encode_mouse_modifiers(modifiers); - let report = - sgr_mouse_button_report(encoded, point.column.0, point.line.0 as usize, pressed); - self.write_to_pty(report.into_bytes(), cx); - true - } - fn handle_mouse_down( &mut self, event: &MouseDownEvent, window: &mut Window, cx: &mut Context, ) { - if self.terminal.read(cx).ssh_mfa_request().is_none() { - window.focus(&self.focus_handle, cx); - } - // SGR 鼠标模式下把按钮按下事件交给 TUI,跳过 selection/URL/dismiss - if self.try_report_sgr_mouse_button(event.button, event.position, event.modifiers, true, cx) - { - return; - } + window.focus(&self.focus_handle, cx); tracing::debug!( target: "terminal.history_prompt", reason = "mouse_down", @@ -3668,20 +3572,10 @@ impl TerminalView { fn handle_middle_mouse_down( &mut self, - event: &MouseDownEvent, + _event: &MouseDownEvent, window: &mut Window, cx: &mut Context, ) { - // SGR 鼠标模式下中键按下走 TUI 报告而不是 middle-click paste - if self.try_report_sgr_mouse_button( - MouseButton::Middle, - event.position, - event.modifiers, - true, - cx, - ) { - return; - } if !self.middle_click_paste { return; } @@ -3750,16 +3644,6 @@ impl TerminalView { _window: &mut Window, cx: &mut Context, ) { - // SGR 鼠标模式下:先回报释放,然后跳过 selection 收尾 - if self.try_report_sgr_mouse_button( - event.button, - event.position, - event.modifiers, - false, - cx, - ) { - return; - } if event.button != MouseButton::Left { return; } @@ -4222,28 +4106,6 @@ impl Render for TerminalView { let view = cx.entity().clone(); let show_scrollbar = !terminal_mode.contains(TermMode::ALT_SCREEN) && history_size > 0; - // 检测主屏 ↔ alt screen 切换。 - // 进入 alt screen 时(opencode/lazygit/vim 等 TUI 启动),主动重发当前尺寸到 PTY, - // 触发 SIGWINCH 让 TUI 重新查询尺寸并刷新整屏,避免底部残留旧画面。 - // 仅在 last_size 已就绪时(说明 PTY 已收到过正确尺寸)才 nudge, - // 避免覆盖即将到来的首次 resize_if_needed。 - let alt_screen = terminal_mode.contains(TermMode::ALT_SCREEN); - if alt_screen != self.last_alt_screen { - tracing::info!( - target: "terminal_residue", - from = self.last_alt_screen, - to = alt_screen, - last_size = ?self.last_size, - "alt_screen mode transition" - ); - self.last_alt_screen = alt_screen; - if alt_screen && self.last_size.is_some() { - tracing::info!(target: "terminal_residue", "nudge_resize fired on enter alt_screen"); - self.terminal - .update(cx, |terminal, _| terminal.nudge_resize()); - } - } - div() .size_full() .flex() @@ -4611,10 +4473,9 @@ mod tests { #[cfg(target_os = "macos")] use super::TerminalView; use super::{ - UnbracketedPasteHazard, alt_screen_scroll_arrow, detect_unbracketed_paste_hazard, encode_mouse_modifiers, - has_trailing_line_continuation, has_unterminated_shell_quote, history_prompt_available, - history_prompt_dropdown_origin, history_prompt_overlay_bounds, mouse_button_code, - multiline_non_empty_line_count, preserve_theme_typography, sgr_mouse_button_report, sgr_mouse_wheel_report, + alt_screen_scroll_arrow, detect_unbracketed_paste_hazard, has_trailing_line_continuation, + has_unterminated_shell_quote, history_prompt_available, history_prompt_dropdown_origin, + history_prompt_overlay_bounds, multiline_non_empty_line_count, preserve_theme_typography, should_defer_inline_history_prompt_input_to_text_system, should_dismiss_history_prompt_for_keystroke, should_dismiss_history_prompt_for_mouse, should_dismiss_history_prompt_for_scroll, should_reset_history_prompt_for_terminal_event, @@ -4626,7 +4487,7 @@ mod tests { use alacritty_terminal::term::TermMode; #[cfg(target_os = "macos")] use gpui::TestAppContext; - use gpui::{px, size, Bounds, Keystroke, Modifiers, MouseButton, Point, SharedString}; + use gpui::{px, size, Bounds, Keystroke, MouseButton, Point, SharedString}; use std::cell::Cell as StdCell; #[cfg(target_os = "macos")] use std::{ @@ -4666,111 +4527,10 @@ mod tests { } #[test] - fn terminal_keybindings_bind_ctrl_zero_to_reset_font() { - let source = include_str!("view.rs"); - let binding = format!("{}{}", r#"KeyBinding::new("ctrl-0", "#, "ResetFont"); - - assert!(source.contains(&binding)); - } - - #[test] - fn terminal_reset_font_size_is_fifteen() { - assert_eq!(super::TERMINAL_RESET_FONT_SIZE, 15.0); - } - - #[test] - fn terminal_theme_source_does_not_define_font_settings() { - let source = include_str!("theme.rs"); - - assert!(!source.contains("pub font_size")); - assert!(!source.contains("pub font_family")); - assert!(!source.contains("pub font_fallbacks")); - assert!(!source.contains("pub line_height_scale")); - } - - #[test] - fn sgr_mouse_wheel_report_maps_positive_lines_to_wheel_up() { - assert_eq!( - sgr_mouse_wheel_report(1, 4, 2).as_deref(), - Some("\x1b[<64;5;3M") - ); - } - - #[test] - fn sgr_mouse_wheel_report_maps_negative_lines_to_wheel_down() { - assert_eq!( - sgr_mouse_wheel_report(-1, 4, 2).as_deref(), - Some("\x1b[<65;5;3M") - ); - assert_eq!(sgr_mouse_wheel_report(0, 4, 2), None); - } - - #[test] - fn sgr_mouse_button_report_uses_capital_m_on_press() { - // 左键按下,列 0、行 0 -> 转 1-based - let s = sgr_mouse_button_report(0, 0, 0, true); - assert_eq!(s, "\x1b[<0;1;1M"); - } - - #[test] - fn sgr_mouse_button_report_uses_lowercase_m_on_release() { - let s = sgr_mouse_button_report(2, 9, 4, false); - // 右键 (button=2) 释放在 1-based col=10 row=5 - assert_eq!(s, "\x1b[<2;10;5m"); - } - - #[test] - fn sgr_mouse_button_report_supports_modifier_encoded_buttons() { - // 左键 + shift (4) + ctrl (16) -> button=20 - let s = sgr_mouse_button_report(20, 0, 0, true); - assert_eq!(s, "\x1b[<20;1;1M"); - } - - #[test] - fn sgr_mouse_button_report_supports_drag_button_codes() { - // 拖动事件:button + 32(xterm 拖动位) - // 左键拖动 = 32 - let s = sgr_mouse_button_report(32, 7, 11, true); - assert_eq!(s, "\x1b[<32;8;12M"); - } - - #[test] - fn mouse_button_code_maps_three_main_buttons() { - assert_eq!(mouse_button_code(MouseButton::Left), Some(0)); - assert_eq!(mouse_button_code(MouseButton::Middle), Some(1)); - assert_eq!(mouse_button_code(MouseButton::Right), Some(2)); - } - - #[test] - fn encode_mouse_modifiers_packs_shift_alt_control() { - let none = Modifiers::default(); - assert_eq!(encode_mouse_modifiers(none), 0); - - let shift = Modifiers { - shift: true, - ..Default::default() - }; - assert_eq!(encode_mouse_modifiers(shift), 4); - - let alt = Modifiers { - alt: true, - ..Default::default() - }; - assert_eq!(encode_mouse_modifiers(alt), 8); - - let ctrl = Modifiers { - control: true, - ..Default::default() - }; - assert_eq!(encode_mouse_modifiers(ctrl), 16); - - let all = Modifiers { - shift: true, - alt: true, - control: true, - ..Default::default() - }; - assert_eq!(encode_mouse_modifiers(all), 28); + fn alt_screen_scroll_arrow_maps_negative_lines_to_down() { + assert_eq!(alt_screen_scroll_arrow(-1, false), Some("\x1b[B")); + assert_eq!(alt_screen_scroll_arrow(-1, true), Some("\x1bOB")); + assert_eq!(alt_screen_scroll_arrow(0, false), None); } #[test] diff --git a/themes/codium_dark.jsonc b/themes/codium_dark.jsonc index ea28ca2d12..af6dcd3dda 100644 --- a/themes/codium_dark.jsonc +++ b/themes/codium_dark.jsonc @@ -84,18 +84,18 @@ // 列表默认背景 "list.background": "#2c1438", // 列表项激活背景 - "list.active.background": "#75219433", + "list.active.background": "#752194", // 列表项激活边框 "list.active.border": "#c678dd", // 列表偶数行背景 "list.even.background": "#321548", // 列表头部背景 // 列表项悬停背景 - "list.hover.background": "#3d204966", + "list.hover.background": "#3d2049", // === 弱化色 === // 次要/禁用区域背景 - "muted.background": "#3d204966", + "muted.background": "#3d2049", // 次要/禁用文字 "muted.foreground": "#9a7a9f", @@ -150,11 +150,11 @@ // === 侧边栏 === // 侧边栏强调色背景 - "sidebar.accent.background": "#2e0f3866", + "sidebar.accent.background": "#2e0f38", // 侧边栏强调色文字 "sidebar.accent.foreground": "#785485", // 侧边栏背景 - "sidebar.background": "#4d325733", + "sidebar.background": "#4d3257", // 侧边栏边框 "sidebar.border": "#230431", // 侧边栏文字 @@ -162,7 +162,7 @@ // === 骨架屏 === // 加载骨架背景 - "skeleton.background": "#3d204966", + "skeleton.background": "#3d2049", // === 滑块 === // 滑块轨道背景 @@ -207,11 +207,11 @@ // === 表格 === // 表格默认背景 // 表格选中行背景 - "table.active.background": "#75219433", + "table.active.background": "#752194", // 表格选中行边框 "table.active.border": "#5e0679", // 表格偶数行背景 - "table.even.background": "#3d204966", + "table.even.background": "#3d2049", // 表格头部背景 "table.head.background": "#2d0c2e", // 表格头部文字 From 7370a91bd37cd6b7929283b675b27d0586c4b022 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=9C=E5=86=9C?= Date: Wed, 13 May 2026 11:40:21 +0800 Subject: [PATCH 39/45] =?UTF-8?q?refactor:=20=E7=A7=BB=E9=99=A4=E6=9C=AA?= =?UTF-8?q?=E4=BD=BF=E7=94=A8=E7=9A=84=20TableModel=20trait=20=E5=B9=B6?= =?UTF-8?q?=E6=B8=85=E7=90=86=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除 table/mod.rs 中未使用的 TableModel trait 定义 - 移除 table/delegate.rs 中对 TableModel 的 blanket impl - 移除 ssh.rs 中多余的 #[cfg(test)] 和 #[cfg(unix)] 限制 - 修复 home 页面 rounded 值使用 theme.radius_lg 替代硬编码 - 清理 model_settings.rs 多余空行 - 更新 Cargo.lock (serial2 版本升级) Co-Authored-By: Claude Opus 4.7 --- Cargo.lock | 6 +- .../src/ai_chat/components/model_settings.rs | 2 - crates/ssh/src/ssh.rs | 5 -- crates/ui/src/table/delegate.rs | 55 +--------------- crates/ui/src/table/mod.rs | 62 ------------------- main/src/home/home_connection_quick_open.rs | 2 +- main/src/home/home_workspace_filter.rs | 2 +- 7 files changed, 6 insertions(+), 128 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4dc43f0583..2bd7acadd5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9819,13 +9819,13 @@ dependencies = [ [[package]] name = "serial2" -version = "0.2.36" +version = "0.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcdbc46aa3882ec3d48ec2b5abcb4f0d863a13d7599265f3faa6d851f23c12f3" +checksum = "9eb6ea5562eeaed6936b8b54e086aa0f88b9e5b1bef45beb038e2519fa1185b1" dependencies = [ "cfg-if", "libc", - "winapi", + "windows-sys 0.61.2", ] [[package]] diff --git a/crates/core/src/ai_chat/components/model_settings.rs b/crates/core/src/ai_chat/components/model_settings.rs index 1981f39ece..ef4ae145d5 100644 --- a/crates/core/src/ai_chat/components/model_settings.rs +++ b/crates/core/src/ai_chat/components/model_settings.rs @@ -137,8 +137,6 @@ impl ModelSettingsPanel { Self::with_labels(settings, ModelSettingsLabels::default(), window, cx) } - - /// 使用自定义标签创建模型设置面板 pub fn with_labels( settings: ModelSettings, diff --git a/crates/ssh/src/ssh.rs b/crates/ssh/src/ssh.rs index 08eee6c536..faff0f9dec 100644 --- a/crates/ssh/src/ssh.rs +++ b/crates/ssh/src/ssh.rs @@ -1,6 +1,5 @@ use std::borrow::Cow; use std::net::SocketAddr; -#[cfg(test)] use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; @@ -750,7 +749,6 @@ async fn request_keyboard_interactive_responses( .context(t!("Ssh.auth_keyboard_interactive_cancelled").to_string()) } -#[cfg(test)] pub fn discover_default_private_keys() -> Vec { let Some(home_dir) = dirs::home_dir() else { return Vec::new(); @@ -856,7 +854,6 @@ fn build_auto_publickey_failure_message( parts.join(": ") } -#[cfg(test)] fn path_to_string(path: PathBuf) -> String { path.to_string_lossy().to_string() } @@ -1082,7 +1079,6 @@ mod tests { ); } - #[cfg(unix)] #[test] fn discover_default_private_keys_returns_expected_order() { static ENV_LOCK: OnceLock> = OnceLock::new(); @@ -1129,7 +1125,6 @@ mod tests { ); } - #[cfg(unix)] #[test] fn expand_auto_publickey_auth_contains_agent_and_default_keys() { static ENV_LOCK: OnceLock> = OnceLock::new(); diff --git a/crates/ui/src/table/delegate.rs b/crates/ui/src/table/delegate.rs index 2ea2cd5863..257a0fa611 100644 --- a/crates/ui/src/table/delegate.rs +++ b/crates/ui/src/table/delegate.rs @@ -8,7 +8,7 @@ use gpui::{ use crate::{ ActiveTheme as _, Icon, IconName, Size, h_flex, menu::PopupMenu, - table::{Column, ColumnSort, TableModel, TableState, loading::Loading}, + table::{Column, ColumnSort, TableState, loading::Loading}, }; /// A delegate trait for providing data and rendering for a table. @@ -198,56 +198,3 @@ pub trait TableDelegate: Sized + 'static { String::new() } } - -/// Blanket implementation of TableModel for all TableDelegate types. -/// This allows both TableDelegate and EditTableDelegate to be used through the TableModel trait. -impl TableModel for D { - type Column = Column; - - fn columns_count(&self) -> usize { - // Default implementation - requires App context in actual use - 0 - } - - fn rows_count(&self) -> usize { - // Default implementation - requires App context in actual use - 0 - } - - fn column(&self, _index: usize) -> Column { - // Default implementation - requires App context in actual use - Column::default() - } - - fn perform_sort(&mut self, _column: usize, _ascending: bool) { - // Full implementation requires Window and Context - } - - fn move_column(&mut self, _from: usize, _to: usize) { - // Full implementation requires Window and Context - } - - fn loading(&self) -> bool { - false - } - - fn has_more(&self) -> bool { - false - } - - fn load_more_threshold(&self) -> Option { - Some(20) - } - - fn load_more(&mut self) { - // Full implementation requires Window and Context - } - - fn visible_rows_changed(&mut self, _range: Range) { - // Full implementation requires Window and Context - } - - fn visible_columns_changed(&mut self, _range: Range) { - // Full implementation requires Window and Context - } -} diff --git a/crates/ui/src/table/mod.rs b/crates/ui/src/table/mod.rs index 0f7f1878cc..7608072bd6 100644 --- a/crates/ui/src/table/mod.rs +++ b/crates/ui/src/table/mod.rs @@ -1,5 +1,3 @@ -use std::ops::Range; - use crate::{ ActiveTheme, Sizable, Size, actions::{ @@ -22,66 +20,6 @@ pub use delegate::*; pub use state::*; const CONTEXT: &'static str = "Table"; - -/// A trait that defines the data model interface for tables. -/// -/// This trait extracts the common data-related functionality from both -/// `TableDelegate` and `EditTableDelegate`, allowing for shared behavior -/// between the basic `Table` and the editable `EditTable` components. -/// -/// The trait uses an associated type `Column` to allow each implementation -/// to use its own column type without requiring a unified Column struct. -#[allow(unused)] -pub trait TableModel: Send { - /// The column type used by this table model. - type Column; - - /// Return the number of columns in the table. - fn columns_count(&self) -> usize; - - /// Return the number of rows in the table. - fn rows_count(&self) -> usize; - - /// Returns the table column at the given index. - fn column(&self, index: usize) -> Self::Column; - - /// Perform sort on the column at the given index. - fn perform_sort(&mut self, column: usize, ascending: bool); - - /// Move the column at the given index to a new position. - fn move_column(&mut self, from: usize, to: usize); - - /// Return true if the table is currently loading data. - fn loading(&self) -> bool; - - /// Return a view to display while loading, if any. - fn render_loading(&self) -> Option { - None - } - - /// Return true if there is more data to load (for infinite scroll). - fn has_more(&self) -> bool { - false - } - - /// Returns the threshold (in rows) that triggers loading more data. - /// - /// When the visible range is within this many rows from the end, - /// `load_more` will be called. - fn load_more_threshold(&self) -> Option { - Some(20) - } - - /// Load more data when triggered by scroll position. - fn load_more(&mut self) {} - - /// Called when the visible range of rows changes. - fn visible_rows_changed(&mut self, range: Range) {} - - /// Called when the visible range of columns changes. - fn visible_columns_changed(&mut self, range: Range) {} -} - pub(crate) fn init(cx: &mut App) { cx.bind_keys([ KeyBinding::new("escape", Cancel, Some(CONTEXT)), diff --git a/main/src/home/home_connection_quick_open.rs b/main/src/home/home_connection_quick_open.rs index 04b630aaad..df07b1001c 100644 --- a/main/src/home/home_connection_quick_open.rs +++ b/main/src/home/home_connection_quick_open.rs @@ -84,7 +84,7 @@ impl ListDelegate for ConnectionQuickOpenDelegate { ListItem::new(ix) .px_3() .py_2() - .rounded(Radius::Md.px()) + .rounded(cx.theme().radius_lg) .on_click(move |_, window, cx| { parent.update(cx, |this, cx| { this.open_connection_from_quick(&connection_for_open, window, cx); diff --git a/main/src/home/home_workspace_filter.rs b/main/src/home/home_workspace_filter.rs index 9e697a681e..2152c8b6b0 100644 --- a/main/src/home/home_workspace_filter.rs +++ b/main/src/home/home_workspace_filter.rs @@ -182,7 +182,7 @@ impl ListDelegate for WorkspaceFilterDelegate { ListItem::new(ix) .px_3() .py_2() - .rounded(Radius::Sm.px()) + .rounded(cx.theme().radius_lg) .on_click(move |_, _, cx| { parent.update(cx, |this, cx| { this.toggle_workspace_filter(item_id, cx); From fe4cca131acd33c15b07b7e71c863b7d8234e259 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=9C=E5=86=9C?= Date: Wed, 13 May 2026 12:04:10 +0800 Subject: [PATCH 40/45] =?UTF-8?q?fix(terminal):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=90=88=E5=B9=B6=20dev=20=E5=90=8E=E7=9A=84=E7=BC=96=E8=AF=91?= =?UTF-8?q?=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SshTerminalConfig 添加 disable_shell_integration 字段 - spawn_ssh_connect 改用 SshBackend::connect 替代不存在的 connect_with_progress - SshBackend 实现 TerminalBackend::close 方法 - ssh_backend 处理 OscEvent::SshPromptReady 事件 - editor_window.rs 补充 cx 类型注解 Co-Authored-By: Claude Opus 4.7 --- .../remote_file_editor/src/editor_window.rs | 2 +- crates/terminal/src/ssh_backend.rs | 9 ++++- crates/terminal/src/terminal.rs | 34 ++++--------------- 3 files changed, 16 insertions(+), 29 deletions(-) diff --git a/crates/remote_file_editor/src/editor_window.rs b/crates/remote_file_editor/src/editor_window.rs index 32a008c573..7d309e2bdc 100644 --- a/crates/remote_file_editor/src/editor_window.rs +++ b/crates/remote_file_editor/src/editor_window.rs @@ -64,7 +64,7 @@ pub fn open_remote_file_editor( let title = editor_window_title(&remote_path); open_popup_window( PopupWindowOptions::new(title).size(960.0, 720.0).min_width(640.0).min_height(480.0), - move |window, cx| { + move |window, cx: &mut App| { let view = cx.new(|cx| { RemoteFileEditorWindow::new(remote_path, client, window, cx) }); diff --git a/crates/terminal/src/ssh_backend.rs b/crates/terminal/src/ssh_backend.rs index 87940ce0c9..136034b170 100644 --- a/crates/terminal/src/ssh_backend.rs +++ b/crates/terminal/src/ssh_backend.rs @@ -15,7 +15,7 @@ use crate::pty_backend::{GpuiEventProxy, TerminalEvent}; use crate::shell_integration::{ embedded_shell_integration_script, normalized_shell_integration_script, }; -use crate::{TerminalBackend, TerminalSize}; +use crate::{TerminalBackend, TerminalCloseMode, TerminalSize}; /// 整个 shell integration 安装流程的硬超时,避免远端受限或挂死卡住连接。 const SHELL_INTEGRATION_SETUP_TIMEOUT: Duration = Duration::from_secs(10); @@ -302,6 +302,9 @@ impl SshBackend { TerminalEvent::CommandRecorded(command) ); } + OscEvent::SshPromptReady => { + let _ = event_tx.send(TerminalEvent::SshPromptReady); + } } } @@ -1483,6 +1486,10 @@ impl TerminalBackend for SshBackend { let _ = self.command_tx.send(SshCommand::Resize(size)); } + fn close(&self, _mode: TerminalCloseMode) { + let _ = self.command_tx.send(SshCommand::Shutdown); + } + fn shutdown(&self) { let _ = self.command_tx.send(SshCommand::Shutdown); } diff --git a/crates/terminal/src/terminal.rs b/crates/terminal/src/terminal.rs index 813aad91e2..f1312e741d 100644 --- a/crates/terminal/src/terminal.rs +++ b/crates/terminal/src/terminal.rs @@ -330,6 +330,7 @@ pub enum TerminalConnectionKind { pub struct SshTerminalConfig { pub ssh_config: SshConnectConfig, pub pty_config: PtyConfig, + pub disable_shell_integration: bool, } #[derive(Clone, Copy, PartialEq, Eq, Debug)] @@ -1591,6 +1592,7 @@ impl Terminal { let config = SshTerminalConfig { ssh_config, pty_config, + disable_shell_integration: false, }; let cols = config.pty_config.width as usize; @@ -1964,12 +1966,9 @@ impl Terminal { generation: u64, cx: &mut Context, ) { - // 创建 SSH 后端需要的通知通道 let (notify_tx, mut notify_rx) = unbounded_channel::<()>(); - let (progress_tx, mut progress_rx) = unbounded_channel::(); let task = Tokio::spawn(cx, async move { - // 转发 SSH 通知到事件通道(必须在 tokio runtime 内部) let event_tx_clone = event_tx.clone(); tokio::spawn(async move { while notify_rx.recv().await.is_some() { @@ -1978,15 +1977,15 @@ impl Terminal { }); let disconnect_tx = on_disconnect.map(|tx| { - let (sender, receiver) = tokio::sync::oneshot::channel::(); + let (sender, mut receiver) = unbounded_channel::<()>(); tokio::spawn(async move { - if let Ok(is_graceful) = receiver.await { - let _ = tx.send(is_graceful); + if receiver.recv().await.is_some() { + let _ = tx.send(true); } }); sender }); - SshBackend::connect_with_progress( + SshBackend::connect( session_manager, config.pty_config, connection_id, @@ -1996,30 +1995,11 @@ impl Terminal { notify_tx, disconnect_tx, init_commands, - move |stage| { - let _ = progress_tx.send(stage); - }, + config.disable_shell_integration, ) .await }); - cx.spawn(async move |this: WeakEntity, cx| { - while let Some(stage) = progress_rx.recv().await { - if this - .update(cx, |this, cx| { - if matches!(this.connection_state, ConnectionState::Connecting) { - this.connection_status_message = Some(stage.description()); - cx.emit(TerminalModelEvent::Wakeup); - } - }) - .is_err() - { - break; - } - } - }) - .detach(); - cx.spawn(async move |this: WeakEntity, cx| { let result = task.await; let _ = this.update(cx, |this, cx| { From b6cb79034ca7e723091f42953686536f73dc40bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=9C=E5=86=9C?= Date: Wed, 13 May 2026 12:11:03 +0800 Subject: [PATCH 41/45] =?UTF-8?q?fix(remote=5Ffile=5Feditor):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20open=5Fpopup=5Fwindow=20=E8=B0=83=E7=94=A8=E7=BC=BA?= =?UTF-8?q?=E5=B0=91=20window=20=E5=8F=82=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - open_remote_file_editor 签名添加 window 参数 - 移除不必要的 async spawn 包装 - 更新 file_manager_panel 和 sftp_view 的调用处 Co-Authored-By: Claude Opus 4.7 --- .../remote_file_editor/src/editor_window.rs | 51 ++++++++----------- crates/sftp_view/src/lib.rs | 2 +- .../src/sidebar/file_manager_panel.rs | 2 +- 3 files changed, 23 insertions(+), 32 deletions(-) diff --git a/crates/remote_file_editor/src/editor_window.rs b/crates/remote_file_editor/src/editor_window.rs index 7d309e2bdc..f7b17bbe8b 100644 --- a/crates/remote_file_editor/src/editor_window.rs +++ b/crates/remote_file_editor/src/editor_window.rs @@ -51,40 +51,31 @@ struct RemoteEditorWindowRef { pub fn open_remote_file_editor( remote_path: String, client: Arc>, + window: &mut Window, cx: &mut Context, ) { init_keybindings(cx); - cx.spawn(async move |_this, cx| { - let remote_path_for_log = remote_path.clone(); - let result = cx.update(|cx| { - if open_in_existing_window(remote_path.clone(), cx)? { - return Ok(()); - } - - let title = editor_window_title(&remote_path); - open_popup_window( - PopupWindowOptions::new(title).size(960.0, 720.0).min_width(640.0).min_height(480.0), - move |window, cx: &mut App| { - let view = cx.new(|cx| { - RemoteFileEditorWindow::new(remote_path, client, window, cx) - }); - set_editor_window(RemoteEditorWindowRef { - window: window.window_handle(), - view: view.downgrade(), - }); - view - }, - cx, - ); - - Ok::<_, anyhow::Error>(()) - }); + let _ = remote_path.clone(); + if let Ok(true) = open_in_existing_window(remote_path.clone(), cx) { + return; + } - if let Err(error) = result { - tracing::error!(path = %remote_path_for_log, ?error, "failed to open remote file editor"); - } - }) - .detach(); + let title = editor_window_title(&remote_path); + open_popup_window( + window, + PopupWindowOptions::new(title).size(960.0, 720.0).min_width(640.0).min_height(480.0), + move |window, cx| { + let view = cx.new(|cx| { + RemoteFileEditorWindow::new(remote_path, client, window, cx) + }); + set_editor_window(RemoteEditorWindowRef { + window: window.window_handle(), + view: view.downgrade(), + }); + view + }, + cx, + ); } fn open_in_existing_window(remote_path: String, cx: &mut App) -> anyhow::Result { diff --git a/crates/sftp_view/src/lib.rs b/crates/sftp_view/src/lib.rs index bf0d85b909..6d0e9dae5f 100644 --- a/crates/sftp_view/src/lib.rs +++ b/crates/sftp_view/src/lib.rs @@ -1009,7 +1009,7 @@ impl SftpView { return; }; - open_remote_file_editor(full_path, client, cx); + open_remote_file_editor(full_path, client, window, cx); } fn navigate_local_to(&mut self, path: PathBuf, cx: &mut Context) { diff --git a/crates/terminal_view/src/sidebar/file_manager_panel.rs b/crates/terminal_view/src/sidebar/file_manager_panel.rs index b6f81e33a5..30c5f2eb8a 100644 --- a/crates/terminal_view/src/sidebar/file_manager_panel.rs +++ b/crates/terminal_view/src/sidebar/file_manager_panel.rs @@ -1890,7 +1890,7 @@ impl FileManagerPanel { return; }; - open_remote_file_editor(full_path, client, cx); + open_remote_file_editor(full_path, client, window, cx); } // ── 渲染方法 ────────────────────────────────────────────── From a45ee4ffd700dd3175bbfc41ef787c347091e285 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=9C=E5=86=9C?= Date: Wed, 13 May 2026 14:28:21 +0800 Subject: [PATCH 42/45] =?UTF-8?q?fix(terminal):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E9=AB=98=E9=80=9F=E8=BE=93=E5=87=BA=E6=97=B6=20Wakeup=20?= =?UTF-8?q?=E4=BA=8B=E4=BB=B6=E4=B8=A2=E5=A4=B1=E5=B9=B6=E6=94=B9=E8=BF=9B?= =?UTF-8?q?=E9=AB=98=E4=BA=AE=E6=AD=A3=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 传递 wakeup_pending 句柄给事件循环,转发 Wakeup 后重置标志位, 避免被 GpuiEventProxy 去重逻辑永久吞掉 - 放宽时间正则为 \d{1,2} 并支持毫秒/微秒分隔符 - 数量正则增加单字母单位 B/M/G/T 匹配 Co-Authored-By: Claude Opus 4.7 --- crates/terminal/src/terminal.rs | 30 ++++++++++++------- crates/terminal_view/src/highlight_presets.rs | 4 +-- 2 files changed, 21 insertions(+), 13 deletions(-) diff --git a/crates/terminal/src/terminal.rs b/crates/terminal/src/terminal.rs index f1312e741d..adea3efd35 100644 --- a/crates/terminal/src/terminal.rs +++ b/crates/terminal/src/terminal.rs @@ -31,6 +31,7 @@ use std::collections::VecDeque; use std::fs; use std::path::PathBuf; use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; use std::time::{Duration, Instant}; use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender}; use tokio::time::interval; @@ -42,7 +43,7 @@ use std::ffi::OsStr; #[cfg(any(test, target_os = "windows"))] use std::path::Path; #[cfg(any(test, not(target_os = "linux")))] -use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::atomic::AtomicU64; use crate::history::{ HistoryEntry, PERSISTED_HISTORY_LIMIT, SESSION_HISTORY_LIMIT, ShellHistoryFormat, @@ -1143,10 +1144,10 @@ impl TerminalScrollProxy { impl Terminal { fn new_local_disconnected(error: String, cx: &mut Context) -> Self { let (event_tx, event_rx) = unbounded_channel::(); - let (term, _event_proxy, _colors) = + let (term, event_proxy, _colors) = Self::create_term(DEFAULT_COLS, DEFAULT_ROWS, event_tx.clone()); - Self::spawn_event_loop(event_rx, cx); + Self::spawn_event_loop(event_rx, event_proxy.wakeup_pending_handle(), cx); Self { term, @@ -1256,10 +1257,10 @@ impl Terminal { #[cfg(target_os = "windows")] escape_args: true, }; - let local_backend = LocalPtyBackend::new(term.clone(), event_proxy, pty_options)?; + let local_backend = LocalPtyBackend::new(term.clone(), event_proxy.clone(), pty_options)?; let local_shell_pid = local_backend.child_pid(); - Self::spawn_event_loop(event_rx, cx); + Self::spawn_event_loop(event_rx, event_proxy.wakeup_pending_handle(), cx); #[cfg(target_os = "macos")] Self::spawn_local_process_tree_settler(cx); Self::spawn_local_history_loader(history_shell.as_deref(), cx); @@ -1358,7 +1359,7 @@ impl Terminal { let local_backend = LocalPtyClientBackend::new(request_tx, session_id.clone(), child_pid); let local_shell_pid = local_backend.child_pid(); - Self::spawn_event_loop(event_rx, cx); + Self::spawn_event_loop(event_rx, event_proxy.wakeup_pending_handle(), cx); #[cfg(target_os = "macos")] Self::spawn_local_process_tree_settler(cx); Self::spawn_local_history_loader(history_shell.as_deref(), cx); @@ -1451,7 +1452,7 @@ impl Terminal { let local_shell_pid = local_backend.child_pid(); let local_cwd_file = init_local_cwd_file(config.cwd_file.clone()); - Self::spawn_event_loop(event_rx, cx); + Self::spawn_event_loop(event_rx, event_proxy.wakeup_pending_handle(), cx); #[cfg(target_os = "macos")] Self::spawn_local_process_tree_settler(cx); Self::spawn_local_history_loader(history_shell.as_deref(), cx); @@ -1611,7 +1612,7 @@ impl Terminal { let ssh_session_manager = Arc::new(SshSessionManager::new(config.ssh_config.clone())); Self::spawn_disconnect_handler(disconnect_rx, connection_generation, cx); - Self::spawn_event_loop(event_rx, cx); + Self::spawn_event_loop(event_rx, event_proxy.wakeup_pending_handle(), cx); Self::spawn_ssh_connect( ssh_session_manager.clone(), config.clone(), @@ -1670,13 +1671,13 @@ impl Terminal { .expect("StoredConnection 应包含有效的 SerialParams"); let (event_tx, event_rx) = unbounded_channel::(); - let (term, _event_proxy, _colors) = + let (term, event_proxy, _colors) = Self::create_term(DEFAULT_COLS, DEFAULT_ROWS, event_tx.clone()); let (disconnect_tx, disconnect_rx) = tokio::sync::oneshot::channel::(); let connection_generation = 1; Self::spawn_disconnect_handler(disconnect_rx, connection_generation, cx); - Self::spawn_event_loop(event_rx, cx); + Self::spawn_event_loop(event_rx, event_proxy.wakeup_pending_handle(), cx); Self::spawn_serial_connect( serial_params.clone(), term.clone(), @@ -1805,7 +1806,11 @@ impl Terminal { .detach(); } - fn spawn_event_loop(mut event_rx: UnboundedReceiver, cx: &mut Context) { + fn spawn_event_loop( + mut event_rx: UnboundedReceiver, + wakeup_pending: Arc, + cx: &mut Context, + ) { let _entity = cx.entity().downgrade(); let (render_tx, mut render_rx) = futures::channel::mpsc::unbounded::(); @@ -1838,6 +1843,9 @@ impl Terminal { // 最后发送 Wakeup if pending_wakeup { pending_wakeup = false; + // 转发完毕后允许 alacritty 线程的下一次 Wakeup 重新入队, + // 避免高速输出时被 GpuiEventProxy 的去重永久吞掉 + wakeup_pending.store(false, Ordering::Release); if render_tx.unbounded_send(TerminalEvent::Wakeup).is_err() { return; } diff --git a/crates/terminal_view/src/highlight_presets.rs b/crates/terminal_view/src/highlight_presets.rs index f72bdbfb71..aa59e2e005 100644 --- a/crates/terminal_view/src/highlight_presets.rs +++ b/crates/terminal_view/src/highlight_presets.rs @@ -183,7 +183,7 @@ pub fn builtin_highlight_presets() -> Vec { preset_rule( "time_and_numbers", "clock", - r"\b:?\d{2}:\d{2}(:\d{2})?\b", + r"\b:?\d{1,2}:\d{1,2}([:|\.]\d+)?\b", Some("#22d3ee"), None, 48, @@ -192,7 +192,7 @@ pub fn builtin_highlight_presets() -> Vec { preset_rule( "time_and_numbers", "quantity", - r"\b\d+(?:\.\d+)?(?:ms|s|m|h|K|KB|MB|GB|TB|%)\b", + r"\b\d+(?:\.\d+)?(?:ms|s|m|h|B|K|KB|M|MB|G|GB|T|TB|%)\b", Some("#f59e0b"), None, 36, From b4ddd5a35ee67ce4a7c20846419ce2e9b473f216 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=9C=E5=86=9C?= Date: Wed, 13 May 2026 17:58:13 +0800 Subject: [PATCH 43/45] =?UTF-8?q?style(ui):=20=E4=BC=98=E5=8C=96=E8=A1=A8?= =?UTF-8?q?=E6=A0=BC=E9=80=89=E5=8C=BA=E8=BE=B9=E6=A1=86=E4=B8=8E=E4=B8=BB?= =?UTF-8?q?=E9=A2=98=E9=A2=9C=E8=89=B2=E9=80=8F=E6=98=8E=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 详细说明: - 重构表格单元格选区边框实现,从绝对定位子元素改为直接边框样式 - 添加边框补偿逻辑,避免边框宽度导致内容区域偏移 - 优化筛选器逻辑操作符选择器的居中布局和宽度常量化 - 调整 codium_dark 主题多个颜色的 alpha 透明度 - 删除 main.yml 中重复的 Log 国际化配置项 文件变更: - 修改:crates/db_view/src/table_data/filter_editor.rs - 修改:crates/one_ui/src/edit_table/state.rs - 修改:main/locales/main.yml - 修改:themes/codium_dark.jsonc Co-Authored-By: Claude Opus 4.7 (1M context) --- .../db_view/src/table_data/filter_editor.rs | 9 +- crates/one_ui/src/edit_table/state.rs | 92 +++++++++++-------- main/locales/main.yml | 14 --- themes/codium_dark.jsonc | 16 ++-- 4 files changed, 71 insertions(+), 60 deletions(-) diff --git a/crates/db_view/src/table_data/filter_editor.rs b/crates/db_view/src/table_data/filter_editor.rs index 071bbf385d..7de1adfeb5 100644 --- a/crates/db_view/src/table_data/filter_editor.rs +++ b/crates/db_view/src/table_data/filter_editor.rs @@ -1739,11 +1739,16 @@ impl VisualFilterBuilder { let value_end_input = self.value_end_inputs.get(&row.id); let logic_is_and = matches!(row.logic_operator, LogicOperator::And); + const LOGIC_TOGGLE_WIDTH: gpui::Pixels = px(48.); + // 逻辑操作符选择器(根级别的第一条条件不显示) let logic_toggle = if cr.idx > 0 { gpui::div() + .w(LOGIC_TOGGLE_WIDTH) + .flex() + .justify_center() + .items_center() .text_xs() - .px_2() .py_px() .rounded_full() .bg(if logic_is_and { @@ -1761,7 +1766,7 @@ impl VisualFilterBuilder { ) .child(if logic_is_and { "AND" } else { "OR" }) } else { - gpui::div().w(px(48.)) + gpui::div().w(LOGIC_TOGGLE_WIDTH) }; // 值输入区域 diff --git a/crates/one_ui/src/edit_table/state.rs b/crates/one_ui/src/edit_table/state.rs index 5001459016..a7c7601dc1 100644 --- a/crates/one_ui/src/edit_table/state.rs +++ b/crates/one_ui/src/edit_table/state.rs @@ -1959,6 +1959,19 @@ where && (self.selection.ranges.len() > 1 || self.selection.ranges.iter().any(|r| !r.is_single())); + // 计算选区边框(只在选区边界显示,且仅限单元格选择模式) + let (border_top, border_bottom, border_left, border_right) = + if is_in_selection && row_ix.is_some() { + let r = row_ix.unwrap(); + let top = r == 0 || !self.selection.contains(r - 1, col_ix); + let bottom = !self.selection.contains(r + 1, col_ix); + let left = col_ix == 0 || !self.selection.contains(r, col_ix - 1); + let right = !self.selection.contains(r, col_ix + 1); + (top, bottom, left, right) + } else { + (false, false, false, false) + }; + // 旧的单选逻辑(向后兼容) let is_select_cell = match self.selected_cell { None => false, @@ -2006,34 +2019,31 @@ where .when(is_in_selection && !is_editing, |this| { this.bg(cx.theme().table_active) }) - // 活动单元格边框(用绝对定位子元素,不占用 content 区域) - .when( - (is_active_cell || is_select_cell) && !is_editing && !is_multi_selection, - |this| { - this.child( - div() - .absolute() - .left_0() - .top_0() - .right_0() - .bottom_0() - .border_2() - .border_color(selection_border_color), - ) - }, - ) - // 编辑状态边框(用绝对定位子元素,不占用 content 区域) + // 选区边框 - 上边界 + .when(border_top, |this| { + this.border_t_2().border_color(selection_border_color) + }) + // 选区边框 - 下边界 + .when(border_bottom, |this| { + this.border_b_2().border_color(selection_border_color) + }) + // 选区边框 - 左边界 + .when(border_left, |this| { + this.border_l_2().border_color(selection_border_color) + }) + // 选区边框 - 右边界 + .when(border_right, |this| { + this.border_r_2().border_color(selection_border_color) + }) + // 活动单元格额外添加完整边框(仅在单选时显示) + .when(is_single_select_active, |this| { + this.border_2().border_color(selection_border_color) + }) + // 编辑状态的单元格 .when(is_editing, |this| { - this.bg(cx.theme().background).child( - div() - .absolute() - .left_0() - .top_0() - .right_0() - .bottom_0() - .border_2() - .border_color(cx.theme().ring), - ) + this.bg(cx.theme().background) + .border_2() + .border_color(cx.theme().ring) }) .when(is_modified && !is_editing && !is_in_selection, |this| { this.bg(cx.theme().warning.opacity(0.15)) @@ -2053,11 +2063,23 @@ where ), }; + // 边框补偿:编辑态始终有 border_2;显示态仅选中时有 + let (has_t, has_b, has_l, has_r) = if is_editing { + (true, true, true, true) + } else { + ( + border_top || is_single_select_active, + border_bottom || is_single_select_active, + border_left || is_single_select_active, + border_right || is_single_select_active, + ) + }; + let b = px(2.); cell = cell - .pt(target_pt) - .pb(target_pb) - .pl(target_pl) - .pr(target_pr); + .pt(if has_t { (target_pt - b).max(px(0.)) } else { target_pt }) + .pb(if has_b { (target_pb - b).max(px(0.)) } else { target_pb }) + .pl(if has_l { (target_pl - b).max(px(0.)) } else { target_pl }) + .pr(if has_r { (target_pr - b).max(px(0.)) } else { target_pr }); // 编辑模式:嵌入轻量编辑器(无自带样式,由容器控制布局) if is_editing { @@ -2306,11 +2328,9 @@ where }) .hover(|this| this.bg(cx.theme().secondary).opacity(7.)) .active(|this| this.bg(cx.theme().secondary_active).opacity(1.)) - .on_click(cx.listener(move |table, _e: &ClickEvent, window, cx| { - // 点击排序图标:循环切换排序方向 - cx.stop_propagation(); - table.perform_sort(col_ix, window, cx); - })) + .on_click( + cx.listener(move |table, _, window, cx| table.perform_sort(col_ix, window, cx)), + ) .child( Icon::new(icon) .size_3() diff --git a/main/locales/main.yml b/main/locales/main.yml index 888ec062b7..ab7de3ab15 100644 --- a/main/locales/main.yml +++ b/main/locales/main.yml @@ -1215,20 +1215,6 @@ Settings: zh-CN: 留空时写入默认配置目录下的 logs 文件夹,修改后重启应用生效。 zh-HK: 留空時寫入默認配置目錄下的 logs 文件夾,修改後重啟應用生效。 - Log: - group_title: - en: Log - zh-CN: 日志 - zh-HK: 日誌 - file_path: - en: Log File Path - zh-CN: 日志保存路径 - zh-HK: 日誌保存路徑 - file_path_desc: - en: Leave empty to write logs to the default config directory logs folder. Restart the app after changing this path. - zh-CN: 留空时写入默认配置目录下的 logs 文件夹,修改后重启应用生效。 - zh-HK: 留空時寫入默認配置目錄下的 logs 文件夾,修改後重啟應用生效。 - Update: group_title: en: Update diff --git a/themes/codium_dark.jsonc b/themes/codium_dark.jsonc index af6dcd3dda..ea28ca2d12 100644 --- a/themes/codium_dark.jsonc +++ b/themes/codium_dark.jsonc @@ -84,18 +84,18 @@ // 列表默认背景 "list.background": "#2c1438", // 列表项激活背景 - "list.active.background": "#752194", + "list.active.background": "#75219433", // 列表项激活边框 "list.active.border": "#c678dd", // 列表偶数行背景 "list.even.background": "#321548", // 列表头部背景 // 列表项悬停背景 - "list.hover.background": "#3d2049", + "list.hover.background": "#3d204966", // === 弱化色 === // 次要/禁用区域背景 - "muted.background": "#3d2049", + "muted.background": "#3d204966", // 次要/禁用文字 "muted.foreground": "#9a7a9f", @@ -150,11 +150,11 @@ // === 侧边栏 === // 侧边栏强调色背景 - "sidebar.accent.background": "#2e0f38", + "sidebar.accent.background": "#2e0f3866", // 侧边栏强调色文字 "sidebar.accent.foreground": "#785485", // 侧边栏背景 - "sidebar.background": "#4d3257", + "sidebar.background": "#4d325733", // 侧边栏边框 "sidebar.border": "#230431", // 侧边栏文字 @@ -162,7 +162,7 @@ // === 骨架屏 === // 加载骨架背景 - "skeleton.background": "#3d2049", + "skeleton.background": "#3d204966", // === 滑块 === // 滑块轨道背景 @@ -207,11 +207,11 @@ // === 表格 === // 表格默认背景 // 表格选中行背景 - "table.active.background": "#752194", + "table.active.background": "#75219433", // 表格选中行边框 "table.active.border": "#5e0679", // 表格偶数行背景 - "table.even.background": "#3d2049", + "table.even.background": "#3d204966", // 表格头部背景 "table.head.background": "#2d0c2e", // 表格头部文字 From 5820e259e3bcaabf1008ca58637bb014326e5f09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=9C=E5=86=9C?= Date: Fri, 15 May 2026 18:10:56 +0800 Subject: [PATCH 44/45] sync: update from dev branch --- crates/core/src/lib.rs | 1 + crates/core/src/popup_window.rs | 11 +- crates/db/src/import_export/formats/csv.rs | 129 +++--- crates/db/src/import_export/formats/json.rs | 119 ++++-- crates/db/src/import_export/formats/mod.rs | 110 +----- crates/db/src/import_export/formats/txt.rs | 101 +++-- crates/db/src/import_export/formats/xml.rs | 26 +- crates/db/src/manager.rs | 11 +- crates/db_view/src/chatdb/chat_panel.rs | 3 + crates/one_ui/locales/one_ui.yml | 1 - crates/one_ui/src/edit_table/state.rs | 95 ++--- crates/story/src/lib.rs | 3 +- crates/story/src/title_bar.rs | 21 +- crates/terminal/src/terminal.rs | 366 +++++++++++++++++- .../terminal_view/locales/terminal_view.yml | 56 +-- crates/terminal_view/src/highlight_presets.rs | 8 +- crates/terminal_view/src/settings.rs | 7 + crates/terminal_view/src/ssh_form_window.rs | 39 +- crates/terminal_view/src/terminal_element.rs | 331 ++++++++++++++-- crates/terminal_view/src/view.rs | 240 +++++++++++- crates/ui/src/root.rs | 8 +- main/locales/main.yml | 22 ++ main/src/onetcli_app.rs | 38 +- main/src/setting_tab.rs | 36 ++ 24 files changed, 1365 insertions(+), 417 deletions(-) delete mode 100644 crates/one_ui/locales/one_ui.yml diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index f9f8585cbf..27c20278ef 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -47,4 +47,5 @@ pub fn init(cx: &mut App) { agent::init(cx); connection_notifier::init(cx); certificate_notifier::init(cx); + popup_window::init(cx); } diff --git a/crates/core/src/popup_window.rs b/crates/core/src/popup_window.rs index 9d1fcee885..4d3ae04e00 100644 --- a/crates/core/src/popup_window.rs +++ b/crates/core/src/popup_window.rs @@ -283,6 +283,7 @@ pub fn open_popup_window_with_should_close( let title = options.title.clone(); let on_should_close = Arc::new(on_should_close); let kind = options.kind.clone(); + let corner_radius = cx.theme().radius_lg; cx.spawn(async move |cx| { let on_should_close = Arc::clone(&on_should_close); @@ -314,7 +315,15 @@ pub fn open_popup_window_with_should_close( let view = create_view_fn(window, cx).into(); let popup_view = cx.new(|cx| PopupWindowView::new(view, Some(content_size), window, cx)); - cx.new(|cx| Root::new(popup_view, window, cx)) + cx.new(|cx| { + let root = Root::new(popup_view, window, cx); + #[cfg(target_os = "linux")] + { + // popup 的可见底色由 PopupWindowView 承担,避免 Root 底色在圆角处透出。 + root = root.bg(gpui::transparent_black()); + } + root + }) })?; window.update(cx, |_, window, _| { diff --git a/crates/db/src/import_export/formats/csv.rs b/crates/db/src/import_export/formats/csv.rs index e6a75298cc..f6cc0c94a2 100644 --- a/crates/db/src/import_export/formats/csv.rs +++ b/crates/db/src/import_export/formats/csv.rs @@ -1,19 +1,16 @@ use std::time::Instant; -use anyhow::{anyhow, Result}; +use anyhow::{Result, anyhow}; use async_trait::async_trait; -use super::{ - build_export_select_sql, build_insert_statement, execute_import_statements, - format_import_table_reference, quote_sql_string, -}; +use super::format_import_table_reference; +use crate::DatabasePlugin; use crate::connection::DbConnection; use crate::executor::{ExecOptions, SqlResult}; use crate::import_export::{ ExportConfig, ExportProgressEvent, ExportProgressSender, ExportResult, FormatHandler, ImportConfig, ImportResult, }; -use crate::DatabasePlugin; pub struct CsvFormatHandler; @@ -108,11 +105,15 @@ impl CsvFormatHandler { } } - fn sql_literal_from_option(value: &Option) -> String { + fn append_sql_value(insert_sql: &mut String, value: &Option) { match value { - None => "NULL".to_string(), - Some(v) if v.eq_ignore_ascii_case("null") => "NULL".to_string(), - Some(v) => quote_sql_string(v), + None => insert_sql.push_str("NULL"), + Some(v) if v.eq_ignore_ascii_case("null") => insert_sql.push_str("NULL"), + Some(v) => { + insert_sql.push('\''); + insert_sql.push_str(&v.replace('\'', "''")); + insert_sql.push('\''); + } } } } @@ -195,7 +196,6 @@ impl FormatHandler for CsvFormatHandler { } } - let mut statements = Vec::new(); for (record_num, values) in records.iter().skip(data_start_record).enumerate() { let record_number = record_num + data_start_record + 1; if values.len() != columns.len() { @@ -206,33 +206,49 @@ impl FormatHandler for CsvFormatHandler { continue; } - let sql_values = values - .iter() - .map(Self::sql_literal_from_option) - .collect::>(); - statements.push(( - record_number, - build_insert_statement(plugin, &table_ref, &columns, &sql_values), - )); - } + let mut insert_sql = format!("INSERT INTO {} (", table_ref); + for (i, col) in columns.iter().enumerate() { + if i > 0 { + insert_sql.push_str(", "); + } + insert_sql.push_str(&plugin.quote_identifier(col)); + } + insert_sql.push_str(") VALUES ("); - let statement_sql = statements - .iter() - .map(|(_, sql)| sql.clone()) - .collect::>(); - let results = execute_import_statements(plugin, connection, config, &statement_sql).await?; - for ((record_number, _), result) in statements.iter().zip(results.into_iter()) { - match result { - SqlResult::Exec(exec_result) => { - total_rows += exec_result.rows_affected; + for (i, val) in values.iter().enumerate() { + if i > 0 { + insert_sql.push_str(", "); } - SqlResult::Error(err) => { - errors.push(format!("Record {}: {}", record_number, err.message)); + Self::append_sql_value(&mut insert_sql, val); + } + insert_sql.push(')'); + + match connection + .execute(plugin, &insert_sql, ExecOptions::default()) + .await + { + Ok(results) => { + for result in results { + match result { + SqlResult::Exec(exec_result) => { + total_rows += exec_result.rows_affected; + } + SqlResult::Error(err) => { + errors.push(format!("Record {}: {}", record_number, err.message)); + if config.stop_on_error { + break; + } + } + _ => {} + } + } + } + Err(e) => { + errors.push(format!("Record {}: {}", record_number, e)); if config.stop_on_error { break; } } - _ => {} } } @@ -290,7 +306,26 @@ impl FormatHandler for CsvFormatHandler { table: table.clone(), }); - let select_sql = build_export_select_sql(plugin, config, table); + let table_ref = plugin.format_table_reference(&config.database, None, table); + let columns_str = if let Some(cols) = &config.columns { + cols.iter() + .map(|c| plugin.quote_identifier(c)) + .collect::>() + .join(", ") + } else { + "*".to_string() + }; + + let mut select_sql = format!("SELECT {} FROM {}", columns_str, table_ref); + if let Some(where_clause) = &config.where_clause { + select_sql.push_str(" WHERE "); + select_sql.push_str(where_clause); + } + if let Some(limit) = config.limit { + let pagination = plugin.format_pagination(limit, 0, ""); + select_sql.push_str(&pagination); + } + let result = connection .query(&select_sql) .await @@ -363,19 +398,21 @@ mod tests { #[test] fn test_append_sql_value_formats_option_string_correctly() { - assert_eq!(CsvFormatHandler::sql_literal_from_option(&None), "NULL"); - assert_eq!( - CsvFormatHandler::sql_literal_from_option(&Some(String::new())), - "''" - ); - assert_eq!( - CsvFormatHandler::sql_literal_from_option(&Some("null".to_string())), - "NULL" - ); - assert_eq!( - CsvFormatHandler::sql_literal_from_option(&Some("O'Reilly".to_string())), - "'O''Reilly'" - ); + let mut sql = String::new(); + CsvFormatHandler::append_sql_value(&mut sql, &None); + assert_eq!(sql, "NULL"); + + sql.clear(); + CsvFormatHandler::append_sql_value(&mut sql, &Some(String::new())); + assert_eq!(sql, "''"); + + sql.clear(); + CsvFormatHandler::append_sql_value(&mut sql, &Some("null".to_string())); + assert_eq!(sql, "NULL"); + + sql.clear(); + CsvFormatHandler::append_sql_value(&mut sql, &Some("O'Reilly".to_string())); + assert_eq!(sql, "'O''Reilly'"); } #[test] diff --git a/crates/db/src/import_export/formats/json.rs b/crates/db/src/import_export/formats/json.rs index 65e449a89f..a330d584ca 100644 --- a/crates/db/src/import_export/formats/json.rs +++ b/crates/db/src/import_export/formats/json.rs @@ -1,20 +1,17 @@ use std::time::Instant; -use anyhow::{anyhow, Result}; +use anyhow::{Result, anyhow}; use async_trait::async_trait; use serde_json::Value; -use super::{ - build_export_select_sql, build_insert_statement, execute_import_statements, - format_import_table_reference, quote_sql_string, -}; +use super::format_import_table_reference; +use crate::DatabasePlugin; use crate::connection::DbConnection; use crate::executor::{ExecOptions, SqlResult}; use crate::import_export::{ ExportConfig, ExportProgressEvent, ExportProgressSender, ExportResult, FormatHandler, ImportConfig, ImportResult, }; -use crate::DatabasePlugin; pub struct JsonFormatHandler; @@ -83,13 +80,12 @@ impl FormatHandler for JsonFormatHandler { .ok_or_else(|| anyhow!("JSON array must contain objects"))?; let columns: Vec = first_obj.keys().cloned().collect(); - let mut statements = Vec::new(); - for (index, row_obj) in rows.iter().enumerate() { - let row_number = index + 1; + // 批量插入 + for row_obj in rows { let obj = match row_obj.as_object() { Some(o) => o, None => { - errors.push(format!("Row {}: row is not an object", row_number)); + errors.push("Row is not an object".to_string()); if config.stop_on_error { break; } @@ -97,45 +93,63 @@ impl FormatHandler for JsonFormatHandler { } }; - let sql_values = columns - .iter() - .map(|col| match obj.get(col) { - Some(Value::Null) | None => "NULL".to_string(), - Some(Value::String(s)) => quote_sql_string(s), - Some(Value::Number(n)) => n.to_string(), - Some(Value::Bool(b)) => { - if *b { - "1".to_string() - } else { - "0".to_string() - } + let mut insert_sql = format!("INSERT INTO {} (", table_ref); + for (i, col) in columns.iter().enumerate() { + if i > 0 { + insert_sql.push_str(", "); + } + insert_sql.push_str(&plugin.quote_identifier(col)); + } + insert_sql.push_str(") VALUES ("); + + for (i, col) in columns.iter().enumerate() { + if i > 0 { + insert_sql.push_str(", "); + } + match obj.get(col) { + Some(Value::Null) | None => insert_sql.push_str("NULL"), + Some(Value::String(s)) => { + insert_sql.push('\''); + insert_sql.push_str(&s.replace('\'', "''")); + insert_sql.push('\''); } - Some(v) => quote_sql_string(&v.to_string()), - }) - .collect::>(); - statements.push(( - row_number, - build_insert_statement(plugin, &table_ref, &columns, &sql_values), - )); - } + Some(Value::Number(n)) => insert_sql.push_str(&n.to_string()), + Some(Value::Bool(b)) => insert_sql.push_str(if *b { "1" } else { "0" }), + Some(v) => { + insert_sql.push('\''); + insert_sql.push_str(&v.to_string().replace('\'', "''")); + insert_sql.push('\''); + } + } + } + insert_sql.push(')'); - let statement_sql = statements - .iter() - .map(|(_, sql)| sql.clone()) - .collect::>(); - let results = execute_import_statements(plugin, connection, config, &statement_sql).await?; - for ((row_number, _), result) in statements.iter().zip(results.into_iter()) { - match result { - SqlResult::Exec(exec_result) => { - total_rows += exec_result.rows_affected; + match connection + .execute(plugin, &insert_sql, ExecOptions::default()) + .await + { + Ok(results) => { + for result in results { + match result { + SqlResult::Exec(exec_result) => { + total_rows += exec_result.rows_affected; + } + SqlResult::Error(err) => { + errors.push(format!("Insert failed: {}", err.message)); + if config.stop_on_error { + break; + } + } + _ => {} + } + } } - SqlResult::Error(err) => { - errors.push(format!("Row {}: {}", row_number, err.message)); + Err(e) => { + errors.push(format!("Insert failed: {}", e)); if config.stop_on_error { break; } } - _ => {} } } @@ -187,7 +201,26 @@ impl FormatHandler for JsonFormatHandler { table: table.clone(), }); - let select_sql = build_export_select_sql(plugin, config, table); + let table_ref = plugin.format_table_reference(&config.database, None, table); + let columns_str = if let Some(cols) = &config.columns { + cols.iter() + .map(|c| plugin.quote_identifier(c)) + .collect::>() + .join(", ") + } else { + "*".to_string() + }; + + let mut select_sql = format!("SELECT {} FROM {}", columns_str, table_ref); + if let Some(where_clause) = &config.where_clause { + select_sql.push_str(" WHERE "); + select_sql.push_str(where_clause); + } + if let Some(limit) = config.limit { + let pagination = plugin.format_pagination(limit, 0, ""); + select_sql.push_str(&pagination); + } + let result = connection .query(&select_sql) .await diff --git a/crates/db/src/import_export/formats/mod.rs b/crates/db/src/import_export/formats/mod.rs index 1733103779..15e12e29ab 100644 --- a/crates/db/src/import_export/formats/mod.rs +++ b/crates/db/src/import_export/formats/mod.rs @@ -1,8 +1,5 @@ -use crate::connection::DbConnection; -use crate::executor::{ExecOptions, SqlResult}; -use crate::import_export::{ExportConfig, ImportConfig}; use crate::DatabasePlugin; -use anyhow::{anyhow, Result}; +use crate::import_export::ImportConfig; pub mod csv; pub mod json; @@ -24,90 +21,10 @@ pub(super) fn format_import_table_reference( plugin.format_table_reference(&config.database, config.schema.as_deref(), table) } -pub(super) fn build_export_select_sql( - plugin: &dyn DatabasePlugin, - config: &ExportConfig, - table: &str, -) -> String { - let table_ref = - plugin.format_table_reference(&config.database, config.schema.as_deref(), table); - let columns_str = if let Some(cols) = &config.columns { - cols.iter() - .map(|c| plugin.quote_identifier(c)) - .collect::>() - .join(", ") - } else { - "*".to_string() - }; - - let mut select_sql = format!("SELECT {} FROM {}", columns_str, table_ref); - if let Some(where_clause) = &config.where_clause { - select_sql.push_str(" WHERE "); - select_sql.push_str(where_clause); - } - if let Some(limit) = config.limit { - let pagination = plugin.format_pagination(limit, 0, ""); - select_sql.push_str(&pagination); - } - select_sql -} - -pub(super) fn build_insert_statement( - plugin: &dyn DatabasePlugin, - table_ref: &str, - columns: &[String], - sql_values: &[String], -) -> String { - let mut sql = format!("INSERT INTO {} (", table_ref); - for (i, col) in columns.iter().enumerate() { - if i > 0 { - sql.push_str(", "); - } - sql.push_str(&plugin.quote_identifier(col)); - } - sql.push_str(") VALUES ("); - for (i, value) in sql_values.iter().enumerate() { - if i > 0 { - sql.push_str(", "); - } - sql.push_str(value); - } - sql.push(')'); - sql -} - -pub(super) fn quote_sql_string(value: &str) -> String { - format!("'{}'", value.replace('\'', "''")) -} - -pub(super) async fn execute_import_statements( - plugin: &dyn DatabasePlugin, - connection: &dyn DbConnection, - config: &ImportConfig, - statements: &[String], -) -> Result> { - if statements.is_empty() { - return Ok(Vec::new()); - } - - let script = statements.join(";\n"); - let options = ExecOptions { - stop_on_error: config.stop_on_error, - transactional: config.use_transaction, - max_rows: None, - streaming: false, - }; - - connection - .execute(plugin, &script, options) - .await - .map_err(|e| anyhow!("Import failed: {}", e)) -} - #[cfg(test)] mod tests { - use super::{build_export_select_sql, format_import_table_reference}; - use crate::import_export::{ExportConfig, ImportConfig}; + use super::format_import_table_reference; + use crate::import_export::ImportConfig; use crate::mssql::MsSqlPlugin; use crate::mysql::MySqlPlugin; @@ -139,25 +56,4 @@ mod tests { assert_eq!(table_ref, "[warehouse].[sales].[orders]"); } - - #[test] - fn test_build_export_select_sql_keeps_schema_where_and_limit() { - let plugin = MsSqlPlugin::new(); - let config = ExportConfig { - database: "warehouse".to_string(), - schema: Some("sales".to_string()), - tables: vec!["orders".to_string()], - columns: Some(vec!["id".to_string(), "name".to_string()]), - where_clause: Some("status = 1".to_string()), - limit: Some(10), - ..ExportConfig::default() - }; - - let sql = build_export_select_sql(&plugin, &config, "orders"); - - assert_eq!( - sql, - "SELECT [id], [name] FROM [warehouse].[sales].[orders] WHERE status = 1 ORDER BY (SELECT NULL) OFFSET 0 ROWS FETCH NEXT 10 ROWS ONLY" - ); - } } diff --git a/crates/db/src/import_export/formats/txt.rs b/crates/db/src/import_export/formats/txt.rs index 15f607756d..e3b36bfd44 100644 --- a/crates/db/src/import_export/formats/txt.rs +++ b/crates/db/src/import_export/formats/txt.rs @@ -1,19 +1,16 @@ use std::time::Instant; -use anyhow::{anyhow, Result}; +use anyhow::{Result, anyhow}; use async_trait::async_trait; -use super::{ - build_export_select_sql, build_insert_statement, execute_import_statements, - format_import_table_reference, quote_sql_string, -}; +use super::format_import_table_reference; +use crate::DatabasePlugin; use crate::connection::DbConnection; use crate::executor::{ExecOptions, SqlResult}; use crate::import_export::{ ExportConfig, ExportProgressEvent, ExportProgressSender, ExportResult, FormatHandler, ImportConfig, ImportResult, }; -use crate::DatabasePlugin; pub struct TxtFormatHandler; @@ -93,7 +90,6 @@ impl FormatHandler for TxtFormatHandler { } } - let mut statements = Vec::new(); for (line_num, line) in lines.iter().skip(1).enumerate() { if line.trim().is_empty() { continue; @@ -108,39 +104,55 @@ impl FormatHandler for TxtFormatHandler { continue; } - let sql_values = values - .iter() - .map(|val| { - if val.is_empty() || val.eq_ignore_ascii_case("null") { - "NULL".to_string() - } else { - quote_sql_string(val) - } - }) - .collect::>(); - statements.push(( - line_num + 2, - build_insert_statement(plugin, &table_ref, &columns, &sql_values), - )); - } + let mut insert_sql = format!("INSERT INTO {} (", table_ref); + for (i, col) in columns.iter().enumerate() { + if i > 0 { + insert_sql.push_str(", "); + } + insert_sql.push_str(&plugin.quote_identifier(col)); + } + insert_sql.push_str(") VALUES ("); - let statement_sql = statements - .iter() - .map(|(_, sql)| sql.clone()) - .collect::>(); - let results = execute_import_statements(plugin, connection, config, &statement_sql).await?; - for ((line_number, _), result) in statements.iter().zip(results.into_iter()) { - match result { - SqlResult::Exec(exec_result) => { - total_rows += exec_result.rows_affected; + for (i, val) in values.iter().enumerate() { + if i > 0 { + insert_sql.push_str(", "); + } + if val.is_empty() || val.eq_ignore_ascii_case("null") { + insert_sql.push_str("NULL"); + } else { + insert_sql.push('\''); + insert_sql.push_str(&val.replace('\'', "''")); + insert_sql.push('\''); } - SqlResult::Error(err) => { - errors.push(format!("Line {}: {}", line_number, err.message)); + } + insert_sql.push(')'); + + match connection + .execute(plugin, &insert_sql, ExecOptions::default()) + .await + { + Ok(results) => { + for result in results { + match result { + SqlResult::Exec(exec_result) => { + total_rows += exec_result.rows_affected; + } + SqlResult::Error(err) => { + errors.push(format!("Line {}: {}", line_num + 2, err.message)); + if config.stop_on_error { + break; + } + } + _ => {} + } + } + } + Err(e) => { + errors.push(format!("Line {}: {}", line_num + 2, e)); if config.stop_on_error { break; } } - _ => {} } } @@ -207,7 +219,26 @@ impl FormatHandler for TxtFormatHandler { table: table.clone(), }); - let select_sql = build_export_select_sql(plugin, config, table); + let table_ref = plugin.format_table_reference(&config.database, None, table); + let columns_str = if let Some(cols) = &config.columns { + cols.iter() + .map(|c| plugin.quote_identifier(c)) + .collect::>() + .join(", ") + } else { + "*".to_string() + }; + + let mut select_sql = format!("SELECT {} FROM {}", columns_str, table_ref); + if let Some(where_clause) = &config.where_clause { + select_sql.push_str(" WHERE "); + select_sql.push_str(where_clause); + } + if let Some(limit) = config.limit { + let pagination = plugin.format_pagination(limit, 0, ""); + select_sql.push_str(&pagination); + } + let result = connection .query(&select_sql) .await diff --git a/crates/db/src/import_export/formats/xml.rs b/crates/db/src/import_export/formats/xml.rs index 40035f620c..72f40a1df3 100644 --- a/crates/db/src/import_export/formats/xml.rs +++ b/crates/db/src/import_export/formats/xml.rs @@ -1,16 +1,15 @@ use std::time::Instant; -use anyhow::{anyhow, Result}; +use anyhow::{Result, anyhow}; use async_trait::async_trait; -use super::build_export_select_sql; +use crate::DatabasePlugin; use crate::connection::DbConnection; use crate::executor::SqlResult; use crate::import_export::{ ExportConfig, ExportProgressEvent, ExportProgressSender, ExportResult, FormatHandler, ImportConfig, ImportResult, }; -use crate::DatabasePlugin; pub struct XmlFormatHandler; @@ -105,7 +104,26 @@ impl FormatHandler for XmlFormatHandler { table: table.clone(), }); - let select_sql = build_export_select_sql(plugin, config, table); + let table_ref = plugin.format_table_reference(&config.database, None, table); + let columns_str = if let Some(cols) = &config.columns { + cols.iter() + .map(|c| plugin.quote_identifier(c)) + .collect::>() + .join(", ") + } else { + "*".to_string() + }; + + let mut select_sql = format!("SELECT {} FROM {}", columns_str, table_ref); + if let Some(where_clause) = &config.where_clause { + select_sql.push_str(" WHERE "); + select_sql.push_str(where_clause); + } + if let Some(limit) = config.limit { + let pagination = plugin.format_pagination(limit, 0, ""); + select_sql.push_str(&pagination); + } + let result = connection .query(&select_sql) .await diff --git a/crates/db/src/manager.rs b/crates/db/src/manager.rs index 3e832ce2e0..e958878b73 100644 --- a/crates/db/src/manager.rs +++ b/crates/db/src/manager.rs @@ -732,8 +732,7 @@ impl ConnectionPool { _db_manager: &DbManager, ) -> anyhow::Result>>> { let plugin = self.db_manager.get_plugin(&config.database_type)?; - let mut connection = plugin.create_connection(config).await?; - connection.connect().await?; + let connection = plugin.create_connection(config).await?; Ok(Arc::new(RwLock::new(connection))) } } @@ -1946,6 +1945,14 @@ impl GlobalDbState { connection_id: String, node: DbNode, ) -> anyhow::Result> { + if node.node_type == DbNodeType::Connection && !node.children_loaded { + info!( + "[DB][Timing] load_object_view skipped connection_id={} node_id={} reason=connection_children_not_loaded", + connection_id, node.id + ); + return Ok(None); + } + let mut config = self .get_config(&connection_id) .ok_or_else(|| anyhow::anyhow!("Connection not found: {}", connection_id))? diff --git a/crates/db_view/src/chatdb/chat_panel.rs b/crates/db_view/src/chatdb/chat_panel.rs index ac20adb942..8b77c4d6ce 100644 --- a/crates/db_view/src/chatdb/chat_panel.rs +++ b/crates/db_view/src/chatdb/chat_panel.rs @@ -346,6 +346,9 @@ impl ChatPanel { self.chat_history.clear(); self.sql_result_views.clear(); self.sql_block_results.clear(); + self.latest_ai_message_id = None; + self.render_limit = MESSAGE_RENDER_LIMIT; + self.session_affinity.reset(); cx.notify(); } diff --git a/crates/one_ui/locales/one_ui.yml b/crates/one_ui/locales/one_ui.yml deleted file mode 100644 index 63f1c3e949..0000000000 --- a/crates/one_ui/locales/one_ui.yml +++ /dev/null @@ -1 +0,0 @@ -en: diff --git a/crates/one_ui/src/edit_table/state.rs b/crates/one_ui/src/edit_table/state.rs index a7c7601dc1..b8fb4fe5c8 100644 --- a/crates/one_ui/src/edit_table/state.rs +++ b/crates/one_ui/src/edit_table/state.rs @@ -1959,19 +1959,6 @@ where && (self.selection.ranges.len() > 1 || self.selection.ranges.iter().any(|r| !r.is_single())); - // 计算选区边框(只在选区边界显示,且仅限单元格选择模式) - let (border_top, border_bottom, border_left, border_right) = - if is_in_selection && row_ix.is_some() { - let r = row_ix.unwrap(); - let top = r == 0 || !self.selection.contains(r - 1, col_ix); - let bottom = !self.selection.contains(r + 1, col_ix); - let left = col_ix == 0 || !self.selection.contains(r, col_ix - 1); - let right = !self.selection.contains(r, col_ix + 1); - (top, bottom, left, right) - } else { - (false, false, false, false) - }; - // 旧的单选逻辑(向后兼容) let is_select_cell = match self.selected_cell { None => false, @@ -2005,9 +1992,6 @@ where let is_editing = row_ix.is_some() && self.editing_cell == Some((row_ix.unwrap(), col_ix)); let selection_border_color = cx.theme().table_active_border; - let is_single_select_active = - (is_active_cell || is_select_cell) && !is_editing && !is_multi_selection; - let mut cell = div() .id(cell_id) .w(col_width) @@ -2019,31 +2003,34 @@ where .when(is_in_selection && !is_editing, |this| { this.bg(cx.theme().table_active) }) - // 选区边框 - 上边界 - .when(border_top, |this| { - this.border_t_2().border_color(selection_border_color) - }) - // 选区边框 - 下边界 - .when(border_bottom, |this| { - this.border_b_2().border_color(selection_border_color) - }) - // 选区边框 - 左边界 - .when(border_left, |this| { - this.border_l_2().border_color(selection_border_color) - }) - // 选区边框 - 右边界 - .when(border_right, |this| { - this.border_r_2().border_color(selection_border_color) - }) - // 活动单元格额外添加完整边框(仅在单选时显示) - .when(is_single_select_active, |this| { - this.border_2().border_color(selection_border_color) - }) - // 编辑状态的单元格 + // 活动单元格边框(用绝对定位子元素,不占用 content 区域) + .when( + (is_active_cell || is_select_cell) && !is_editing && !is_multi_selection, + |this| { + this.child( + div() + .absolute() + .left_0() + .top_0() + .right_0() + .bottom_0() + .border_2() + .border_color(selection_border_color), + ) + }, + ) + // 编辑状态边框(用绝对定位子元素,不占用 content 区域) .when(is_editing, |this| { - this.bg(cx.theme().background) - .border_2() - .border_color(cx.theme().ring) + this.bg(cx.theme().background).child( + div() + .absolute() + .left_0() + .top_0() + .right_0() + .bottom_0() + .border_2() + .border_color(cx.theme().ring), + ) }) .when(is_modified && !is_editing && !is_in_selection, |this| { this.bg(cx.theme().warning.opacity(0.15)) @@ -2063,23 +2050,11 @@ where ), }; - // 边框补偿:编辑态始终有 border_2;显示态仅选中时有 - let (has_t, has_b, has_l, has_r) = if is_editing { - (true, true, true, true) - } else { - ( - border_top || is_single_select_active, - border_bottom || is_single_select_active, - border_left || is_single_select_active, - border_right || is_single_select_active, - ) - }; - let b = px(2.); cell = cell - .pt(if has_t { (target_pt - b).max(px(0.)) } else { target_pt }) - .pb(if has_b { (target_pb - b).max(px(0.)) } else { target_pb }) - .pl(if has_l { (target_pl - b).max(px(0.)) } else { target_pl }) - .pr(if has_r { (target_pr - b).max(px(0.)) } else { target_pr }); + .pt(target_pt) + .pb(target_pb) + .pl(target_pl) + .pr(target_pr); // 编辑模式:嵌入轻量编辑器(无自带样式,由容器控制布局) if is_editing { @@ -2328,9 +2303,11 @@ where }) .hover(|this| this.bg(cx.theme().secondary).opacity(7.)) .active(|this| this.bg(cx.theme().secondary_active).opacity(1.)) - .on_click( - cx.listener(move |table, _, window, cx| table.perform_sort(col_ix, window, cx)), - ) + .on_click(cx.listener(move |table, _e: &ClickEvent, window, cx| { + // 点击排序图标:循环切换排序方向 + cx.stop_propagation(); + table.perform_sort(col_ix, window, cx); + })) .child( Icon::new(icon) .size_3() diff --git a/crates/story/src/lib.rs b/crates/story/src/lib.rs index 22c9cd831d..e561782f9f 100644 --- a/crates/story/src/lib.rs +++ b/crates/story/src/lib.rs @@ -52,7 +52,8 @@ actions!( TestAction, Tab, TabPrev, - ShowPanelInfo + ShowPanelInfo, + ToggleListActiveHighlight ] ); diff --git a/crates/story/src/title_bar.rs b/crates/story/src/title_bar.rs index 6e40476408..02fa53cf55 100644 --- a/crates/story/src/title_bar.rs +++ b/crates/story/src/title_bar.rs @@ -14,7 +14,7 @@ use gpui_component::{ scroll::ScrollbarShow, }; -use crate::{SelectFont, SelectRadius, SelectScrollbarShow, app_menus}; +use crate::{SelectFont, SelectRadius, SelectScrollbarShow, ToggleListActiveHighlight, app_menus}; pub struct AppTitleBar { app_menu_bar: Entity, @@ -141,6 +141,17 @@ impl FontSizeSelector { Theme::global_mut(cx).scrollbar_show = show.0; window.refresh(); } + + fn on_toggle_list_active_highlight( + &mut self, + _: &ToggleListActiveHighlight, + window: &mut Window, + cx: &mut Context, + ) { + let theme = Theme::global_mut(cx); + theme.list.active_highlight = !theme.list.active_highlight; + window.refresh(); + } } impl Render for FontSizeSelector { @@ -156,12 +167,13 @@ impl Render for FontSizeSelector { .on_action(cx.listener(Self::on_select_font)) .on_action(cx.listener(Self::on_select_radius)) .on_action(cx.listener(Self::on_select_scrollbar_show)) + .on_action(cx.listener(Self::on_toggle_list_active_highlight)) .child( Button::new("btn") .small() .ghost() .icon(IconName::Settings2) - .dropdown_menu(move |this, _, _cx| { + .dropdown_menu(move |this, _, cx| { this.scrollable(true) .check_side(Side::Right) .max_h(px(480.)) @@ -201,6 +213,11 @@ impl Render for FontSizeSelector { Box::new(SelectScrollbarShow(ScrollbarShow::Always)), ) .separator() + .menu_with_check( + "List Active Highlight", + cx.theme().list.active_highlight, + Box::new(ToggleListActiveHighlight), + ) }) .anchor(Corner::TopRight), ) diff --git a/crates/terminal/src/terminal.rs b/crates/terminal/src/terminal.rs index adea3efd35..830970ae08 100644 --- a/crates/terminal/src/terminal.rs +++ b/crates/terminal/src/terminal.rs @@ -15,7 +15,8 @@ use alacritty_terminal::term::cell::{Flags, LineLength}; use alacritty_terminal::term::{Config as TermConfig, Term, TermMode}; use alacritty_terminal::tty::{self, Options as PtyOptions}; use alacritty_terminal::vte::ansi::{Processor, StdSyncHandler}; -use anyhow::Result; +use anyhow::{Result, anyhow}; +use async_trait::async_trait; use futures::StreamExt; use gpui::*; use one_core::gpui_tokio::Tokio; @@ -30,10 +31,11 @@ use std::collections::HashSet; use std::collections::VecDeque; use std::fs; use std::path::PathBuf; -use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex as StdMutex}; use std::time::{Duration, Instant}; use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender}; +use tokio::sync::oneshot; use tokio::time::interval; #[cfg(any(test, target_os = "windows"))] @@ -64,14 +66,15 @@ use crate::{ LocalConfig, SerialBackend, SshBackend, TerminalBackend, TerminalCloseMode, TerminalEvent, TerminalSize, }; -use ssh::{ChannelEvent, RusshClient, SshChannel, SshClient}; +use ssh::{ + ChannelEvent, KeyboardInteractiveRequest, KeyboardInteractiveResponder, + KeyboardInteractiveTarget, RusshClient, SshChannel, SshClient +}; pub use ssh::{ JumpServerConnectConfig, ProxyConnectConfig, ProxyType, PtyConfig, SshAuth, SshConnectConfig, SshConnectionStage, SshSessionManager, }; -const DEFAULT_COLS: usize = 80; -const DEFAULT_ROWS: usize = 24; pub const DEFAULT_RECOVERY_SCROLLBACK_LINES: usize = 2000; pub const MAX_RECOVERY_SCROLLBACK_LINES: usize = 5000; const HISTORY_RESTORED_BANNER: &str = @@ -331,9 +334,204 @@ pub enum TerminalConnectionKind { pub struct SshTerminalConfig { pub ssh_config: SshConnectConfig, pub pty_config: PtyConfig, + /// 关闭 shell integration 注入:走裸 request_shell,失去 OSC 集成。 pub disable_shell_integration: bool, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TerminalMfaPrompt { + pub prompt: String, + pub echo: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TerminalMfaRequest { + pub name: String, + pub instructions: String, + pub prompts: Vec, +} + +#[derive(Clone, Default)] +pub struct TerminalMfaResponder { + state: Arc>, + event_tx: Option>, + jump_password: Option, + target_password: Option, +} + +#[derive(Default)] +struct TerminalMfaState { + pending: Option, +} + +struct TerminalMfaPending { + request: TerminalMfaRequest, + response_tx: Option>>, +} + +impl TerminalMfaResponder { + pub fn new( + event_tx: UnboundedSender, + jump_password: Option, + target_password: Option, + ) -> Self { + Self { + state: Arc::new(StdMutex::new(TerminalMfaState::default())), + event_tx: Some(event_tx), + jump_password, + target_password, + } + } + + pub fn pending_request(&self) -> Option { + self.state + .lock() + .ok()? + .pending + .as_ref() + .map(|pending| pending.request.clone()) + } + + pub fn submit(&self, responses: Vec) -> bool { + let Some(mut pending) = self + .state + .lock() + .ok() + .and_then(|mut state| state.pending.take()) + else { + return false; + }; + + let sent = pending + .response_tx + .take() + .is_some_and(|tx| tx.send(responses).is_ok()); + self.notify_changed(); + sent + } + + pub fn cancel(&self) -> bool { + let cleared = self + .state + .lock() + .ok() + .and_then(|mut state| state.pending.take()) + .is_some(); + if cleared { + self.notify_changed(); + } + cleared + } + + fn notify_changed(&self) { + if let Some(event_tx) = &self.event_tx { + let _ = event_tx.send(TerminalEvent::SshMfaChanged); + } + } +} + +#[async_trait] +impl KeyboardInteractiveResponder for TerminalMfaResponder { + async fn respond(&self, request: KeyboardInteractiveRequest) -> Result> { + let terminal_prompts = request + .prompts + .iter() + .filter(|prompt| !is_ssh_password_prompt(&prompt.prompt)) + .map(|prompt| TerminalMfaPrompt { + prompt: prompt.prompt.clone(), + echo: prompt.echo, + }) + .collect::>(); + + if terminal_prompts.is_empty() { + return keyboard_interactive_answers_for_terminal( + &request, + &[], + self.jump_password.as_deref(), + self.target_password.as_deref(), + ); + } + + let (response_tx, response_rx) = oneshot::channel(); + let terminal_request = TerminalMfaRequest { + name: request.name.clone(), + instructions: request.instructions.clone(), + prompts: terminal_prompts, + }; + + if let Ok(mut state) = self.state.lock() { + state.pending = Some(TerminalMfaPending { + request: terminal_request, + response_tx: Some(response_tx), + }); + } else { + return Err(anyhow!("failed to store SSH MFA request")); + } + self.notify_changed(); + + let responses = response_rx + .await + .map_err(|_| anyhow!("SSH MFA response was cancelled"))?; + + keyboard_interactive_answers_for_terminal( + &request, + &responses, + self.jump_password.as_deref(), + self.target_password.as_deref(), + ) + } +} + +fn keyboard_interactive_answers_for_terminal( + request: &KeyboardInteractiveRequest, + responses: &[String], + jump_password: Option<&str>, + target_password: Option<&str>, +) -> Result> { + let mut response_index = 0; + let mut answers = Vec::with_capacity(request.prompts.len()); + + for prompt in &request.prompts { + if is_ssh_password_prompt(&prompt.prompt) { + let password = match request.target { + KeyboardInteractiveTarget::JumpServer => jump_password, + KeyboardInteractiveTarget::TargetServer => target_password, + }; + answers.push( + password + .ok_or_else(|| anyhow!("SSH password prompt has no configured password"))? + .to_string(), + ); + } else { + let response = responses + .get(response_index) + .ok_or_else(|| anyhow!("SSH MFA response is missing"))?; + if response.trim().is_empty() { + return Err(anyhow!("SSH MFA response is empty")); + } + answers.push(response.clone()); + response_index += 1; + } + } + + if response_index == responses.len() { + Ok(answers) + } else { + Err(anyhow!("SSH MFA response count does not match prompts")) + } +} + +fn is_ssh_password_prompt(prompt: &str) -> bool { + prompt + .trim() + .trim_end_matches(':') + .to_ascii_lowercase() + .ends_with("password") +} + +const DEFAULT_COLS: usize = 80; +const DEFAULT_ROWS: usize = 24; + #[derive(Clone, Copy, PartialEq, Eq, Debug)] enum SshProcessState { Unknown, @@ -341,6 +539,10 @@ enum SshProcessState { Busy, } +/// SSH 进程检测超时时间(秒) +/// 如果连接建立后超过此时间仍未检测到任何提示符事件,认为 shell integration 不可用 +const SSH_DETECTION_TIMEOUT_SECS: u64 = 30; + /// 将路径安全地转为 POSIX shell 单参数,避免命令注入。 pub(crate) fn shell_escape_arg(arg: &str) -> String { if arg.is_empty() { @@ -601,7 +803,26 @@ fn should_report_ssh_running_processes( ssh_prompt_detected: bool, interactive_mode_active: bool, command_submitted_without_prompt_sync: bool, + connection_established_at: Option, + ssh_detection_disabled: bool, ) -> bool { + // 如果检测机制已被禁用,不报告进程 + if ssh_detection_disabled { + return false; + } + + // 检查是否超时:连接建立后超过 30 秒仍未检测到提示符事件 + if let Some(established_at) = connection_established_at { + if !ssh_prompt_detected && established_at.elapsed().as_secs() > SSH_DETECTION_TIMEOUT_SECS { + tracing::warn!( + target: "terminal.ssh", + "SSH shell integration 未检测到(超时 {} 秒),禁用进程检查以避免误报", + SSH_DETECTION_TIMEOUT_SECS + ); + return false; + } + } + is_connected && ((ssh_prompt_detected && ssh_process_state == SshProcessState::Busy) || (interactive_mode_active && command_submitted_without_prompt_sync)) @@ -1031,6 +1252,9 @@ pub struct Terminal { /// 终端尺寸 cols: usize, rows: usize, + /// 最近一次同步给 PTY 的像素尺寸,用于 nudge_resize 重发 SIGWINCH + pixel_width: u16, + pixel_height: u16, /// SSH 配置(用于重连) ssh_config: Option, @@ -1041,9 +1265,13 @@ pub struct Terminal { /// 是否已从远端收到过 OSC 133;A/B prompt 事件。 /// 用于防御 shell integration 不工作时的永久 Busy 误报。 ssh_prompt_detected: bool, - /// 用户已提交命令,但会话尚未反馈“回到 prompt”。 + /// 用户已提交命令,但会话尚未反馈”回到 prompt”。 /// 用于补偿不支持 shell integration 的 SSH,会更保守地拦截关闭。 ssh_command_submitted_without_prompt_sync: Cell, + /// SSH 连接建立的时间,用于超时检测 + ssh_connection_established_at: Option, + /// SSH 进程检测是否已禁用(当检测到 shell integration 不可用时) + ssh_detection_disabled: Cell, /// 串口参数(用于重连) serial_params: Option, /// 事件发送器(用于 SSH 重连) @@ -1164,11 +1392,15 @@ impl Terminal { connection_wait_started_at: None, cols: DEFAULT_COLS, rows: DEFAULT_ROWS, + pixel_width: 0, + pixel_height: 0, ssh_config: None, ssh_session_manager: None, ssh_process_state: Cell::new(SshProcessState::Unknown), ssh_prompt_detected: false, ssh_command_submitted_without_prompt_sync: Cell::new(false), + ssh_connection_established_at: None, + ssh_detection_disabled: Cell::new(false), serial_params: None, event_tx: Some(event_tx), event_proxy: None, @@ -1280,11 +1512,15 @@ impl Terminal { connection_wait_started_at: None, cols: DEFAULT_COLS, rows: DEFAULT_ROWS, + pixel_width: 0, + pixel_height: 0, ssh_config: None, ssh_session_manager: None, ssh_process_state: Cell::new(SshProcessState::Unknown), ssh_prompt_detected: false, ssh_command_submitted_without_prompt_sync: Cell::new(false), + ssh_connection_established_at: None, + ssh_detection_disabled: Cell::new(false), serial_params: None, event_tx: Some(event_tx), event_proxy: None, @@ -1379,11 +1615,15 @@ impl Terminal { connection_wait_started_at: None, cols: DEFAULT_COLS, rows: DEFAULT_ROWS, + pixel_width: 0, + pixel_height: 0, ssh_config: None, ssh_session_manager: None, ssh_process_state: Cell::new(SshProcessState::Unknown), ssh_prompt_detected: false, ssh_command_submitted_without_prompt_sync: Cell::new(false), + ssh_connection_established_at: None, + ssh_detection_disabled: Cell::new(false), serial_params: None, event_tx: Some(event_tx), event_proxy: Some(event_proxy), @@ -1472,11 +1712,15 @@ impl Terminal { connection_wait_started_at: None, cols: DEFAULT_COLS, rows: DEFAULT_ROWS, + pixel_width: 0, + pixel_height: 0, ssh_config: None, ssh_session_manager: None, ssh_process_state: Cell::new(SshProcessState::Unknown), ssh_prompt_detected: false, ssh_command_submitted_without_prompt_sync: Cell::new(false), + ssh_connection_established_at: None, + ssh_detection_disabled: Cell::new(false), serial_params: None, event_tx: Some(event_tx), event_proxy: Some(event_proxy), @@ -1521,6 +1765,19 @@ impl Terminal { .to_ssh_params() .expect("StoredConnection should contain valid SSH params"); + let target_password = match &ssh_params.auth_method { + SshAuthMethod::Password { password } => Some(password.clone()), + _ => None, + }; + let jump_password = + ssh_params + .jump_server + .as_ref() + .and_then(|jump| match &jump.auth_method { + SshAuthMethod::Password { password } => Some(password.clone()), + _ => None, + }); + let auth = match ssh_params.auth_method.clone() { SshAuthMethod::Password { password } => SshAuth::Password(password), SshAuthMethod::PrivateKey { @@ -1535,6 +1792,7 @@ impl Terminal { SshAuthMethod::AutoPublicKey => SshAuth::AutoPublicKey, }; + // 构建初始化命令 let init_commands = build_ssh_init_commands( working_dir, ssh_params.default_directory.as_deref(), @@ -1542,7 +1800,7 @@ impl Terminal { sync_path_with_terminal, ); - let ssh_config = SshConnectConfig { + let mut ssh_config = SshConnectConfig { host: ssh_params.host, port: ssh_params.port, username: ssh_params.username, @@ -1590,16 +1848,20 @@ impl Terminal { }; let pty_config = PtyConfig::default(); + let (event_tx, event_rx) = unbounded_channel::(); + let ssh_mfa_responder = + TerminalMfaResponder::new(event_tx.clone(), jump_password, target_password); + ssh_config.keyboard_interactive_responder = Some(Arc::new(ssh_mfa_responder.clone())); let config = SshTerminalConfig { ssh_config, pty_config, - disable_shell_integration: false, + disable_shell_integration: ssh_params.disable_shell_integration.unwrap_or(false), }; + let ssh_session_manager = Arc::new(SshSessionManager::new(config.ssh_config.clone())); let cols = config.pty_config.width as usize; let rows = config.pty_config.height as usize; - let (event_tx, event_rx) = unbounded_channel::(); let (term, event_proxy, _colors) = Self::create_term(cols, rows, event_tx.clone()); let initial_working_dir = working_dir.map(str::to_string); @@ -1609,7 +1871,6 @@ impl Terminal { } let (disconnect_tx, disconnect_rx) = tokio::sync::oneshot::channel::(); let connection_generation = 1; - let ssh_session_manager = Arc::new(SshSessionManager::new(config.ssh_config.clone())); Self::spawn_disconnect_handler(disconnect_rx, connection_generation, cx); Self::spawn_event_loop(event_rx, event_proxy.wakeup_pending_handle(), cx); @@ -1645,11 +1906,15 @@ impl Terminal { connection_wait_started_at: Some(Instant::now()), cols, rows, + pixel_width: 0, + pixel_height: 0, ssh_config: Some(config.clone()), ssh_session_manager: Some(ssh_session_manager), ssh_process_state: Cell::new(SshProcessState::Unknown), ssh_prompt_detected: false, ssh_command_submitted_without_prompt_sync: Cell::new(false), + ssh_connection_established_at: None, + ssh_detection_disabled: Cell::new(false), serial_params: None, event_tx: Some(event_tx), event_proxy: Some(event_proxy), @@ -1702,11 +1967,15 @@ impl Terminal { connection_wait_started_at: None, cols: DEFAULT_COLS, rows: DEFAULT_ROWS, + pixel_width: 0, + pixel_height: 0, ssh_config: None, ssh_session_manager: None, ssh_process_state: Cell::new(SshProcessState::Unknown), ssh_prompt_detected: false, ssh_command_submitted_without_prompt_sync: Cell::new(false), + ssh_connection_established_at: None, + ssh_detection_disabled: Cell::new(false), serial_params: Some(serial_params), event_tx: Some(event_tx), event_proxy: None, @@ -2038,6 +2307,12 @@ impl Terminal { // 后端任务与这里并发执行,prompt 事件可能先于连接成功回调到达。 // 成功分支不能重置 SSH 跟踪状态,否则会把已收到的 Idle/prompt 信号抹掉, // 导致 top 等前台程序运行时无法正确拦截关闭。 + + // 记录 SSH 连接建立时间,用于超时检测 + if self.connection_kind == TerminalConnectionKind::Ssh { + self.ssh_connection_established_at = Some(Instant::now()); + } + tracing::debug!( target: "terminal.ssh", ssh_process_state = ?self.ssh_process_state.get(), @@ -2358,7 +2633,11 @@ impl Terminal { } /// 是否存在会在关闭时被中断的本地子进程。 - pub fn has_running_processes(&self) -> bool { + pub fn has_running_processes(&self, check_enabled: bool) -> bool { + if !check_enabled { + return false; + } + if self.child_exited.is_some() { return false; } @@ -2391,13 +2670,32 @@ impl Terminal { let interactive_mode_active = has_ssh_interactive_program_mode(self.mode()); let command_submitted_without_prompt_sync = self.ssh_command_submitted_without_prompt_sync.get() && !self.ssh_prompt_detected; - let result = should_report_ssh_running_processes( + + // 检查是否需要禁用检测(超时) + let should_disable = should_report_ssh_running_processes( matches!(self.connection_state, ConnectionState::Connected), ssh_process_state, self.ssh_prompt_detected, interactive_mode_active, command_submitted_without_prompt_sync, + self.ssh_connection_established_at, + self.ssh_detection_disabled.get(), ); + + // 如果检测到超时,标记为已禁用 + if !self.ssh_detection_disabled.get() + && self.ssh_connection_established_at.is_some() + && !self.ssh_prompt_detected + && self.ssh_connection_established_at.unwrap().elapsed().as_secs() > SSH_DETECTION_TIMEOUT_SECS + { + self.ssh_detection_disabled.set(true); + tracing::warn!( + target: "terminal.ssh", + "SSH shell integration 检测超时,已禁用进程检查" + ); + } + + let result = should_disable; if result { tracing::debug!( target: "terminal.ssh", @@ -2516,21 +2814,33 @@ impl Terminal { /// 调整终端大小 pub fn resize(&mut self, cols: usize, rows: usize, pixel_width: u16, pixel_height: u16) { if self.cols == cols && self.rows == rows { + // 单元格行列数未变,但仍记录最新像素尺寸,供 nudge_resize 复用 + self.pixel_width = pixel_width; + self.pixel_height = pixel_height; + tracing::debug!( + target: "terminal_residue", + cols, rows, pixel_width, pixel_height, + "Terminal::resize noop (cells unchanged, pixels cached)" + ); return; } tracing::info!( - "Terminal::resize: {}x{} -> {}x{}, pixel={}x{}", + target: "terminal_residue", + "Terminal::resize: {}x{} -> {}x{}, pixel={}x{}, backend={}", self.cols, self.rows, cols, rows, pixel_width, - pixel_height + pixel_height, + self.backend.is_some() ); self.cols = cols; self.rows = rows; + self.pixel_width = pixel_width; + self.pixel_height = pixel_height; self.term.lock().resize(TermDimensions { cols, rows }); @@ -2544,6 +2854,32 @@ impl Terminal { } } + /// 重新向 PTY 后端发送当前尺寸,不修改 alacritty grid。 + /// + /// 用于在 alt screen 切换等场景下触发 SIGWINCH, + /// 让 TUI 应用(opencode/lazygit/vim 等)重新查询尺寸并刷新整屏画面, + /// 避免出现底部残留旧画面的问题。 + pub fn nudge_resize(&self) { + let Some(ref backend) = self.backend else { + tracing::warn!(target: "terminal_residue", "nudge_resize skipped: no backend"); + return; + }; + tracing::info!( + target: "terminal_residue", + cols = self.cols, + rows = self.rows, + pixel_width = self.pixel_width, + pixel_height = self.pixel_height, + "Terminal::nudge_resize -> backend.resize" + ); + backend.resize(TerminalSize { + rows: self.rows as u16, + cols: self.cols as u16, + pixel_width: self.pixel_width, + pixel_height: self.pixel_height, + }); + } + /// 重新连接 SSH 或串口 pub fn reconnect(&mut self, cx: &mut Context) { self.reconnect_internal(false, cx); @@ -3091,6 +3427,8 @@ mod tests { connection_state: ConnectionState::Connected, cols: 80, rows: 24, + pixel_width: 0, + pixel_height: 0, ssh_config: None, ssh_session_manager: None, serial_params: None, diff --git a/crates/terminal_view/locales/terminal_view.yml b/crates/terminal_view/locales/terminal_view.yml index 0c4e227f4a..c2519da0bd 100644 --- a/crates/terminal_view/locales/terminal_view.yml +++ b/crates/terminal_view/locales/terminal_view.yml @@ -155,10 +155,6 @@ SSH: en: Auto Public Key zh-CN: 自动公钥认证 zh-HK: 自動公鑰認證 - auto_publickey_hint: - en: Try SSH Agent first, then default private keys under ~/.ssh. - zh-CN: 将优先尝试 SSH Agent,若不可用则尝试 ~/.ssh 下的默认私钥。 - zh-HK: 將優先嘗試 SSH Agent,若不可用則嘗試 ~/.ssh 下的預設私鑰。 key_path: en: Key Path zh-CN: 密钥路径 @@ -183,6 +179,26 @@ SSH: en: Authentication Method zh-CN: 认证方式 zh-HK: 認證方式 + auto_publickey_hint: + en: Try SSH Agent first, then default private keys under ~/.ssh. + zh-CN: 将优先尝试 SSH Agent,若不可用则尝试 ~/.ssh 下的默认私钥。 + zh-HK: 將優先嘗試 SSH Agent,若不可用則嘗試 ~/.ssh 下的預設私鑰。 + test_required_before_save: + en: Please complete a successful connection test before saving + zh-CN: 请先完成一次成功的连接测试再保存 + zh-HK: 請先完成一次成功的連線測試再保存 + retest_after_change: + en: Connection parameters changed, please test again before saving + zh-CN: 连接参数已变更,请重新测试后再保存 + zh-HK: 連線參數已變更,請重新測試後再保存 + save_failed: + en: "Failed to save SSH connection: %{error}" + zh-CN: 保存 SSH 连接失败:%{error} + zh-HK: 保存 SSH 連線失敗:%{error} + save_while_testing: + en: Please wait for the connection test to finish before saving + zh-CN: 请等待连接测试完成后再保存 + zh-HK: 請等待連線測試完成後再保存 workspace: en: Workspace zh-CN: 工作区 @@ -199,22 +215,6 @@ SSH: en: Connection test successful zh-CN: 连接测试成功 zh-HK: 連線測試成功 - save_failed: - en: "Failed to save SSH connection: %{error}" - zh-CN: 保存 SSH 连接失败:%{error} - zh-HK: 保存 SSH 連線失敗:%{error} - save_while_testing: - en: Please wait for the connection test to finish before saving - zh-CN: 请等待连接测试完成后再保存 - zh-HK: 請等待連線測試完成後再保存 - test_required_before_save: - en: Please complete a successful connection test before saving - zh-CN: 请先完成一次成功的连接测试再保存 - zh-HK: 請先完成一次成功的連線測試再保存 - retest_after_change: - en: Connection parameters changed, please test again before saving - zh-CN: 连接参数已变更,请重新测试后再保存 - zh-HK: 連線參數已變更,請重新測試後再保存 # 标签页 tab_basic: en: Basic @@ -360,6 +360,14 @@ SSH: en: Leave empty to use server default directory zh-CN: 留空则使用服务器默认目录 zh-HK: 留空則使用伺服器預設目錄 + disable_shell_integration: + en: Disable Shell Integration + zh-CN: 禁用 Shell 集成 + zh-HK: 禁用 Shell 集成 + disable_shell_integration_desc: + en: Run native login shell without OSC injection (no prompt hook, command recording, or vim mouse) + zh-CN: 走裸 login shell,不注入 OSC(失去命令记录 / prompt hook / vim 鼠标) + zh-HK: 走裸 login shell,不注入 OSC(失去命令記錄 / prompt hook / vim 鼠標) # 其他设置 remark: en: Remark @@ -1083,10 +1091,6 @@ Terminal: # SSH 会话状态 SshSession: - reconnect: - en: Reconnect - zh-CN: 重新连接 - zh-HK: 重新連接 connecting: en: Connecting... zh-CN: 连接中... @@ -1103,6 +1107,10 @@ SshSession: en: The SSH session has been disconnected. zh-CN: SSH 会话已断开连接。 zh-HK: SSH 工作階段已斷開連接。 + reconnect: + en: Reconnect + zh-CN: 重新连接 + zh-HK: 重新連接 session_ended: en: Session ended. zh-CN: 会话已结束。 diff --git a/crates/terminal_view/src/highlight_presets.rs b/crates/terminal_view/src/highlight_presets.rs index aa59e2e005..5b3c334cf6 100644 --- a/crates/terminal_view/src/highlight_presets.rs +++ b/crates/terminal_view/src/highlight_presets.rs @@ -280,14 +280,14 @@ mod tests { assert!( preset - .rules - .iter() + .rules + .iter() .any(|rule| rule.id == "preset:ip_addresses:ipv4") ); assert!( preset - .rules - .iter() + .rules + .iter() .any(|rule| rule.id == "preset:ip_addresses:ipv6") ); } diff --git a/crates/terminal_view/src/settings.rs b/crates/terminal_view/src/settings.rs index 954abcf09a..e75c6fffbc 100644 --- a/crates/terminal_view/src/settings.rs +++ b/crates/terminal_view/src/settings.rs @@ -45,6 +45,12 @@ pub struct TerminalSettings { pub builtin_highlights_initialized: bool, #[serde(default)] pub custom_highlights: Vec, + #[serde(default = "default_check_running_processes")] + pub check_running_processes_on_exit: bool, +} + +fn default_check_running_processes() -> bool { + true } impl Default for TerminalSettings { @@ -61,6 +67,7 @@ impl Default for TerminalSettings { confirm_high_risk_command: true, builtin_highlights_initialized: true, custom_highlights: builtin_highlight_rules(), + check_running_processes_on_exit: true, } } } diff --git a/crates/terminal_view/src/ssh_form_window.rs b/crates/terminal_view/src/ssh_form_window.rs index 87191dbb58..82191eae8b 100644 --- a/crates/terminal_view/src/ssh_form_window.rs +++ b/crates/terminal_view/src/ssh_form_window.rs @@ -171,6 +171,9 @@ pub struct SshFormWindow { // 云同步开关 sync_enabled: bool, + // 关闭 shell integration 注入(走裸 request_shell,失去 OSC 集成) + disable_shell_integration: bool, + is_testing: bool, test_status_message: Option, test_started_at: Option, @@ -347,6 +350,7 @@ impl SshFormWindow { let mut proxy_type = ProxyTypeSelection::default(); let mut enable_legacy_kex = false; let mut sync_enabled = true; // 默认启用云同步 + let mut disable_shell_integration = false; let mut editing_credential_ref: Option = None; if let Some(ref conn) = config.editing_connection { @@ -407,6 +411,7 @@ impl SshFormWindow { if let Some(ref script) = params.init_script { init_script_input.update(cx, |s, cx| s.set_value(script, window, cx)); } + disable_shell_integration = params.disable_shell_integration.unwrap_or(false); if let Some(ref dir) = params.sftp_local_directory { sftp_local_directory_input.update(cx, |s, cx| s.set_value(dir, window, cx)); } @@ -500,6 +505,7 @@ impl SshFormWindow { pending_key_content, last_tested_signature: None, sync_enabled, + disable_shell_integration, is_testing: false, test_status_message: None, test_started_at: None, @@ -839,7 +845,11 @@ impl SshFormWindow { init_script, sftp_local_directory, sftp_remote_directory, - disable_shell_integration: None, + disable_shell_integration: if self.disable_shell_integration { + Some(true) + } else { + None + }, jump_server, proxy, }) @@ -1299,7 +1309,7 @@ impl SshFormWindow { } /// 渲染初始化标签页 - fn render_init_tab(&self) -> impl IntoElement { + fn render_init_tab(&self, cx: &mut Context) -> impl IntoElement { v_flex() .gap_2() .child(self.render_form_row( @@ -1318,6 +1328,28 @@ impl SshFormWindow { &t!("SSH.sftp_remote_directory"), self.styled_input(Input::new(&self.sftp_remote_directory_input)), )) + .child( + self.render_form_row( + &t!("SSH.disable_shell_integration"), + h_flex() + .gap_2() + .child( + Checkbox::new("disable-shell-integration") + .checked(self.disable_shell_integration) + .on_click(cx.listener(|this, _, _, cx| { + this.disable_shell_integration = + !this.disable_shell_integration; + cx.notify(); + })), + ) + .child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child(t!("SSH.disable_shell_integration_desc").to_string()), + ), + ), + ) } /// 渲染跳板机标签页 @@ -1632,7 +1664,7 @@ impl Render for SshFormWindow { .overflow_y_scroll() .child(match active_tab { 0 => self.render_basic_tab(cx).into_any_element(), - 1 => self.render_init_tab().into_any_element(), + 1 => self.render_init_tab(cx).into_any_element(), 2 => self.render_jump_server_tab(cx).into_any_element(), 3 => self.render_proxy_tab(cx).into_any_element(), 4 => self.render_advanced_tab(cx).into_any_element(), @@ -1723,6 +1755,7 @@ mod tests { enable_legacy_kex: false, default_directory: Some("/tmp".to_string()), init_script: Some("pwd".to_string()), + disable_shell_integration: None, sftp_local_directory: None, sftp_remote_directory: None, jump_server: None, diff --git a/crates/terminal_view/src/terminal_element.rs b/crates/terminal_view/src/terminal_element.rs index 341bc715d6..022b22f079 100644 --- a/crates/terminal_view/src/terminal_element.rs +++ b/crates/terminal_view/src/terminal_element.rs @@ -12,7 +12,7 @@ use alacritty_terminal::grid::Dimensions; use alacritty_terminal::selection::SelectionRange; use alacritty_terminal::term::cell::Flags; use alacritty_terminal::term::color::Colors; -use alacritty_terminal::term::{RenderableContent, Term, TermDamage}; +use alacritty_terminal::term::{RenderableContent, Term, TermDamage, TermMode}; use alacritty_terminal::vte::ansi::{Color, CursorShape, NamedColor, Rgb}; use gpui::*; use std::collections::HashMap; @@ -111,6 +111,79 @@ fn is_decorative_character(ch: char) -> bool { ) } +/// 为 Unicode 块字符(U+2580..U+259F)生成几何矩形序列。 +/// +/// 返回的 rect 坐标以 cell 自身宽高的 [0, 1] 归一化系数表示, +/// 调用方在 paint 阶段乘以 cell_width / cell_height 得到像素矩形。 +/// +/// 几何绘制避免依赖字体字形,可解决字体回退时块状字符出现接缝、 +/// 抗锯齿不一致或 line-height gap 导致的视觉断层问题。 +fn block_element_geometry(c: char) -> Option> { + fn rect(x: f32, y: f32, w: f32, h: f32) -> BlockRect { + BlockRect { x, y, w, h } + } + fn lower(fraction: f32) -> Vec { + vec![rect(0.0, 1.0 - fraction, 1.0, fraction)] + } + fn left(fraction: f32) -> Vec { + vec![rect(0.0, 0.0, fraction, 1.0)] + } + const QUAD_UPPER_LEFT: u8 = 1 << 0; + const QUAD_UPPER_RIGHT: u8 = 1 << 1; + const QUAD_LOWER_LEFT: u8 = 1 << 2; + const QUAD_LOWER_RIGHT: u8 = 1 << 3; + fn quadrants(mask: u8) -> Vec { + let mut out = Vec::with_capacity(4); + if mask & QUAD_UPPER_LEFT != 0 { + out.push(rect(0.0, 0.0, 0.5, 0.5)); + } + if mask & QUAD_UPPER_RIGHT != 0 { + out.push(rect(0.5, 0.0, 0.5, 0.5)); + } + if mask & QUAD_LOWER_LEFT != 0 { + out.push(rect(0.0, 0.5, 0.5, 0.5)); + } + if mask & QUAD_LOWER_RIGHT != 0 { + out.push(rect(0.5, 0.5, 0.5, 0.5)); + } + out + } + + Some(match c { + '\u{2580}' => vec![rect(0.0, 0.0, 1.0, 0.5)], // ▀ upper half + '\u{2581}' => lower(1.0 / 8.0), // ▁ + '\u{2582}' => lower(2.0 / 8.0), // ▂ + '\u{2583}' => lower(3.0 / 8.0), // ▃ + '\u{2584}' => lower(4.0 / 8.0), // ▄ + '\u{2585}' => lower(5.0 / 8.0), // ▅ + '\u{2586}' => lower(6.0 / 8.0), // ▆ + '\u{2587}' => lower(7.0 / 8.0), // ▇ + '\u{2588}' => vec![rect(0.0, 0.0, 1.0, 1.0)], // █ full block + '\u{2589}' => left(7.0 / 8.0), // ▉ + '\u{258A}' => left(6.0 / 8.0), // ▊ + '\u{258B}' => left(5.0 / 8.0), // ▋ + '\u{258C}' => left(4.0 / 8.0), // ▌ + '\u{258D}' => left(3.0 / 8.0), // ▍ + '\u{258E}' => left(2.0 / 8.0), // ▎ + '\u{258F}' => left(1.0 / 8.0), // ▏ + '\u{2590}' => vec![rect(0.5, 0.0, 0.5, 1.0)], // ▐ right half + // U+2591..U+2593 阴影块由文本路径处理(依赖字体本身的密度图,更自然) + '\u{2594}' => vec![rect(0.0, 0.0, 1.0, 1.0 / 8.0)], // ▔ upper one-eighth + '\u{2595}' => vec![rect(7.0 / 8.0, 0.0, 1.0 / 8.0, 1.0)], // ▕ right one-eighth + '\u{2596}' => quadrants(QUAD_LOWER_LEFT), + '\u{2597}' => quadrants(QUAD_LOWER_RIGHT), + '\u{2598}' => quadrants(QUAD_UPPER_LEFT), + '\u{2599}' => quadrants(QUAD_UPPER_LEFT | QUAD_LOWER_LEFT | QUAD_LOWER_RIGHT), + '\u{259A}' => quadrants(QUAD_UPPER_LEFT | QUAD_LOWER_RIGHT), + '\u{259B}' => quadrants(QUAD_UPPER_LEFT | QUAD_UPPER_RIGHT | QUAD_LOWER_LEFT), + '\u{259C}' => quadrants(QUAD_UPPER_LEFT | QUAD_UPPER_RIGHT | QUAD_LOWER_RIGHT), + '\u{259D}' => quadrants(QUAD_UPPER_RIGHT), + '\u{259E}' => quadrants(QUAD_UPPER_RIGHT | QUAD_LOWER_LEFT), + '\u{259F}' => quadrants(QUAD_UPPER_RIGHT | QUAD_LOWER_LEFT | QUAD_LOWER_RIGHT), + _ => return None, + }) +} + /// Manages decorations from all addons pub struct DecorationManager { // Decorations indexed by line number @@ -206,6 +279,8 @@ impl DecorationManager { pub struct CachedLine { pub background_rects: Vec<(usize, usize, Hsla)>, pub text_runs: Vec, + /// 块状字符(U+2580..U+259F)使用几何绘制,避免字体回退导致的接缝 + pub block_glyphs: Vec, } #[derive(Clone)] @@ -219,6 +294,25 @@ pub struct CachedTextRun { pub char_count: usize, } +/// 单个 cell 内的几何块字符渲染数据 +/// +/// rects 中的坐标均归一化到 cell 自身的 [0, 1] 范围, +/// paint 时再按当前 cell_width/cell_height 缩放为像素矩形。 +#[derive(Clone)] +pub struct CachedBlockGlyph { + pub column: usize, + pub color: Hsla, + pub rects: Vec, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct BlockRect { + pub x: f32, + pub y: f32, + pub w: f32, + pub h: f32, +} + /// Terminal rendering cache maintained by TerminalView pub struct RenderCache { lines: Vec, @@ -250,6 +344,23 @@ struct CachedCursor { shape: CursorShape, } +enum DamageSnapshot { + Full, + Partial(Vec), +} + +impl DamageSnapshot { + fn from_term_damage(damage: TermDamage<'_>) -> Self { + match damage { + TermDamage::Full => Self::Full, + TermDamage::Partial(iter) => { + let lines = iter.map(|line_damage| line_damage.line).collect(); + Self::Partial(lines) + } + } + } +} + impl RenderCache { pub fn new(num_lines: usize, num_cols: usize, colors: Colors) -> Self { let default_bg = convert_color(Color::Named(NamedColor::Background), &colors); @@ -257,7 +368,8 @@ impl RenderCache { lines: vec![ CachedLine { background_rects: Vec::new(), - text_runs: Vec::new() + text_runs: Vec::new(), + block_glyphs: Vec::new(), }; num_lines ], @@ -287,9 +399,20 @@ impl RenderCache { // Handle resize if num_lines != self.num_lines || num_cols != self.num_cols { + tracing::info!( + target: "terminal_residue", + old_lines = self.num_lines, + old_cols = self.num_cols, + new_lines = num_lines, + new_cols = num_cols, + "RenderCache::resize" + ); self.resize(num_lines, num_cols); } + let damage = DamageSnapshot::from_term_damage(term.damage()); + term.reset_damage(); + // Collect decorations from all addons let display_offset = term.grid().display_offset(); self.decoration_manager @@ -309,37 +432,51 @@ impl RenderCache { // 同步主题光标颜色 self.custom_cursor = theme.cursor; - // Force full rebuild when theme colors or decorations changed - let has_decorations = !self.decoration_manager.decorations_by_line.is_empty(); - if fg_changed || bg_changed || has_decorations { - self.rebuild_all(term); - self.update_last_selection(term); - return; - } - - // Check terminal color palette changes + // 在任何 full rebuild 早返回之前同步终端调色板。 let colors = term.colors(); - if !colors_equal(&self.colors, colors) { + let colors_changed = !colors_equal(&self.colors, colors); + if colors_changed { self.colors = colors.clone(); self.default_bg = convert_color(Color::Named(NamedColor::Background), &self.colors); - self.rebuild_all(term); - self.update_last_selection(term); + } + + // 主题颜色变化或存在装饰时保守全量重建。 + let has_decorations = !self.decoration_manager.decorations_by_line.is_empty(); + if fg_changed || bg_changed || colors_changed || has_decorations { + tracing::debug!( + target: "terminal_residue", + fg_changed, + bg_changed, + colors_changed, + has_decorations, + num_lines, + "rebuild_all (forced by theme/decoration)" + ); + self.rebuild_all_and_update_state(term); return; } - // Collect dirty lines from terminal damage let mut dirty_lines: std::collections::HashSet = std::collections::HashSet::new(); - let damage = term.damage(); match damage { - TermDamage::Full => { - self.rebuild_all(term); - self.update_last_selection(term); + DamageSnapshot::Full => { + tracing::debug!( + target: "terminal_residue", + num_lines, + "rebuild_all (TermDamage::Full)" + ); + self.rebuild_all_and_update_state(term); return; } - TermDamage::Partial(iter) => { - for line_damage in iter { - dirty_lines.insert(line_damage.line); + DamageSnapshot::Partial(lines) => { + if !lines.is_empty() { + tracing::debug!( + target: "terminal_residue", + damaged = ?lines, + num_lines, + "Partial damage" + ); } + dirty_lines.extend(lines); } } @@ -389,11 +526,18 @@ impl RenderCache { CachedLine { background_rects: Vec::new(), text_runs: Vec::new(), + block_glyphs: Vec::new(), }, ); self.left_edge_fingerprint.resize(num_lines, 0); } + fn rebuild_all_and_update_state(&mut self, term: &Term) { + self.rebuild_all(term); + self.update_last_selection(term); + self.sync_left_edge_fingerprint(term, 4); + } + fn rebuild_all(&mut self, term: &Term) { let content = term.renderable_content(); let display_offset = content.display_offset; @@ -403,6 +547,7 @@ impl RenderCache { for line in &mut self.lines { line.background_rects.clear(); line.text_runs.clear(); + line.block_glyphs.clear(); } // Group cells by screen line @@ -437,6 +582,34 @@ impl RenderCache { // Update cursor from a fresh content let content = term.renderable_content(); self.update_cursor_from_content(&content); + + // 调试日志:统计 cache 重建后各行的内容分布。 + // 关注底部最后 8 行,若 TUI 仅画了上半部,底部 8 行的 text/bg 应该为空。 + let total = self.lines.len(); + let non_empty_lines = self + .lines + .iter() + .filter(|l| !l.text_runs.is_empty() || !l.background_rects.is_empty()) + .count(); + let mut tail_summary = Vec::new(); + let tail_start = total.saturating_sub(8); + for idx in tail_start..total { + let l = &self.lines[idx]; + tail_summary.push(format!( + "[{idx}] bg={} text={} chars={}", + l.background_rects.len(), + l.text_runs.len(), + l.text_runs.iter().map(|r| r.char_count).sum::(), + )); + } + tracing::debug!( + target: "terminal_residue", + total_lines = total, + non_empty_lines, + in_alt_screen = content.mode.contains(TermMode::ALT_SCREEN), + tail = tail_summary.join(" | "), + "rebuild_all done" + ); } /// Rebuild specified lines @@ -481,6 +654,7 @@ impl RenderCache { if line_idx < self.num_lines { self.lines[line_idx].background_rects.clear(); self.lines[line_idx].text_runs.clear(); + self.lines[line_idx].block_glyphs.clear(); let cells = std::mem::take(&mut line_cells[line_idx]); self.build_line_cache(line_idx, cells); } @@ -538,8 +712,39 @@ impl RenderCache { term: &Term, probe_cols: usize, ) -> Vec { + let current = self.compute_left_edge_fingerprint(term, probe_cols); + + if self.left_edge_fingerprint.len() != self.num_lines { + self.left_edge_fingerprint.resize(self.num_lines, 0); + } + + let mut changed = Vec::new(); + for (line_idx, (old, new)) in self + .left_edge_fingerprint + .iter() + .zip(current.iter()) + .enumerate() + { + if old != new { + changed.push(line_idx); + } + } + + self.left_edge_fingerprint = current; + changed + } + + fn sync_left_edge_fingerprint(&mut self, term: &Term, probe_cols: usize) { + self.left_edge_fingerprint = self.compute_left_edge_fingerprint(term, probe_cols); + } + + fn compute_left_edge_fingerprint( + &self, + term: &Term, + probe_cols: usize, + ) -> Vec { if self.num_lines == 0 || probe_cols == 0 { - return Vec::new(); + return vec![0; self.num_lines]; } let mut current = vec![0_u64; self.num_lines]; @@ -567,24 +772,7 @@ impl RenderCache { .wrapping_add(piece.wrapping_add(1469598103934665603)); } - if self.left_edge_fingerprint.len() != self.num_lines { - self.left_edge_fingerprint.resize(self.num_lines, 0); - } - - let mut changed = Vec::new(); - for (line_idx, (old, new)) in self - .left_edge_fingerprint - .iter() - .zip(current.iter()) - .enumerate() - { - if old != new { - changed.push(line_idx); - } - } - - self.left_edge_fingerprint = current; - changed + current } fn build_line_cache(&mut self, line_idx: usize, mut cells: Vec) { @@ -692,6 +880,19 @@ impl RenderCache { continue; } + // 块状字符走几何路径,避免不同字体渲染出现接缝 + if let Some(rects) = block_element_geometry(cell.c) { + if let Some(run) = text_run.take() { + line.text_runs.push(run); + } + line.block_glyphs.push(CachedBlockGlyph { + column: cell.column, + color: fg, + rects, + }); + continue; + } + let bold = cell.flags.contains(Flags::BOLD); let italic = cell.flags.contains(Flags::ITALIC); @@ -980,6 +1181,16 @@ impl Element for TerminalElementImpl { let intersection = content_mask.intersect(&terminal_bounds); if intersection.size.height <= px(0.) || intersection.size.width <= px(0.) { + tracing::debug!( + target: "terminal_residue", + lines = self.lines.len(), + num_cols = self.num_cols, + cell_w = ?tb.cell_width, + cell_h = ?tb.cell_height, + origin = ?tb.origin, + content_mask = ?content_mask, + "paint skipped (no intersection)" + ); return; // 完全不可见,跳过渲染 } @@ -997,6 +1208,26 @@ impl Element for TerminalElementImpl { .ceil() as usize; let visible_end = last_visible.min(self.lines.len()); + // 仅在统计行数 / 像素差异时记录一次,避免每帧爆量 + let cm_h: f32 = content_mask.size.height.into(); + let tb_h: f32 = terminal_height.into(); + if (cm_h - tb_h).abs() > 0.5 || self.lines.len() < visible_end { + tracing::debug!( + target: "terminal_residue", + lines = self.lines.len(), + num_cols = self.num_cols, + cell_w = ?tb.cell_width, + cell_h = ?tb.cell_height, + origin = ?tb.origin, + terminal_bounds_h = ?terminal_height, + content_mask = ?content_mask, + first_visible, + visible_end, + bg_alpha = self.custom_background.a, + "paint metrics" + ); + } + // Paint backgrounds (only visible lines) for line_idx in first_visible..visible_end { let line = &self.lines[line_idx]; @@ -1009,6 +1240,24 @@ impl Element for TerminalElementImpl { } } + // Paint block-element geometry(在文字之前,与背景同样的覆盖关系) + for line_idx in first_visible..visible_end { + let line = &self.lines[line_idx]; + for glyph in &line.block_glyphs { + let cell_origin = tb.cell_origin(line_idx, glyph.column); + for r in &glyph.rects { + let rect = Bounds::new( + Point::new( + cell_origin.x + tb.cell_width * r.x, + cell_origin.y + tb.cell_height * r.y, + ), + size(tb.cell_width * r.w, tb.cell_height * r.h), + ); + window.paint_quad(fill(rect, glyph.color)); + } + } + } + // Paint text (only visible lines, using cached fonts) // 使用 cell_width 确保等宽渲染,避免字符布局漂移 for line_idx in first_visible..visible_end { diff --git a/crates/terminal_view/src/view.rs b/crates/terminal_view/src/view.rs index 7f36826925..cf732797ed 100644 --- a/crates/terminal_view/src/view.rs +++ b/crates/terminal_view/src/view.rs @@ -248,17 +248,51 @@ fn take_whole_scroll_lines(scroll_lines_accumulated: &mut f32) -> i32 { lines } -fn alt_screen_scroll_arrow(lines: i32, app_cursor: bool) -> Option<&'static str> { +fn sgr_mouse_wheel_report(lines: i32, col: usize, row: usize) -> Option { if lines == 0 { return None; } - Some(match (lines > 0, app_cursor) { - (true, true) => "\x1bOA", // Up, application mode - (true, false) => "\x1b[A", // Up, normal mode - (false, true) => "\x1bOB", // Down, application mode - (false, false) => "\x1b[B", // Down, normal mode - }) + let button = if lines > 0 { 64 } else { 65 }; + Some(format!("\x1b[<{};{};{}M", button, col + 1, row + 1)) +} + +/// 生成 SGR 鼠标按钮报告。 +/// +/// - `button`:xterm 按钮编码(0=左键、1=中键、2=右键,加上 shift/alt/ctrl/拖动等位) +/// - `pressed`:true 用 `M` 表示按下,false 用 `m` 表示释放(SGR 协议规定) +/// - `col` / `row`:0-based,输出转为 1-based +/// +/// 抽出为独立纯函数,便于单元测试和后续扩展(拖动 32 位、wheel-with-modifiers 等)。 +fn sgr_mouse_button_report(button: u8, col: usize, row: usize, pressed: bool) -> String { + let suffix = if pressed { 'M' } else { 'm' }; + format!("\x1b[<{};{};{}{}", button, col + 1, row + 1, suffix) +} + +/// 将 GPUI 鼠标按钮映射为 xterm 按钮基础编码:左=0、中=1、右=2。 +/// 其它按钮(X1/X2 等)当前未在 SGR 报告中使用,返回 None。 +fn mouse_button_code(button: MouseButton) -> Option { + match button { + MouseButton::Left => Some(0), + MouseButton::Middle => Some(1), + MouseButton::Right => Some(2), + _ => None, + } +} + +/// 将修饰键编码到 xterm 鼠标按钮的高位:shift=4、alt=8、control=16。 +fn encode_mouse_modifiers(modifiers: Modifiers) -> u8 { + let mut bits = 0u8; + if modifiers.shift { + bits |= 4; + } + if modifiers.alt { + bits |= 8; + } + if modifiers.control { + bits |= 16; + } + bits } fn should_scroll_to_bottom_on_user_input( @@ -568,6 +602,12 @@ pub struct TerminalView { cell_width: Pixels, last_size: Option<(usize, usize)>, + /// 上一帧 alacritty 是否处于 alt screen 模式。 + /// + /// 用于检测主屏与备用屏切换:进入 alt screen 时主动调用 nudge_resize + /// 重发当前尺寸给 PTY,触发 SIGWINCH,让 TUI 应用刷新整屏画面, + /// 避免出现底部残留上一次渲染内容的问题。 + last_alt_screen: bool, scroll_lines_accumulated: f32, mouse_state: MouseState, @@ -730,7 +770,10 @@ impl TerminalView { } fn has_blocking_terminal_activity(&self, cx: &App) -> bool { - self.terminal.read(cx).has_running_processes() + let settings = current_settings(cx); + self.terminal + .read(cx) + .has_running_processes(settings.check_running_processes_on_exit) } pub fn new(config: LocalConfig, window: &mut Window, cx: &mut Context) -> Self { @@ -1005,6 +1048,7 @@ impl TerminalView { // 初始化为 None,确保首次渲染时会触发 resize, // 将正确的终端尺寸发送给 PTY last_size: None, + last_alt_screen: false, scroll_lines_accumulated: 0.0, mouse_state: MouseState::default(), addon_manager: Self::create_addon_manager(), @@ -3014,6 +3058,16 @@ impl TerminalView { let new_size = (cols, rows); if self.last_size != Some(new_size) { + tracing::info!( + target: "terminal_residue", + old = ?self.last_size, + new = ?new_size, + bounds_w = ?bounds.size.width, + bounds_h = ?bounds.size.height, + cell_width = ?self.cell_width, + line_height = ?self.line_height, + "resize_if_needed -> Terminal::resize" + ); self.last_size = Some(new_size); self.terminal.update(cx, |terminal, _| { terminal.resize( @@ -3407,11 +3461,14 @@ impl TerminalView { } if mode.contains(TermMode::ALT_SCREEN) { - // ALT_SCREEN(vim、less 等):累计到整行后再转为上下箭头,避免放大小幅滚轮输入 - if let Some(arrow) = alt_screen_scroll_arrow(lines, mode.contains(TermMode::APP_CURSOR)) - { - for _ in 0..lines.abs() { - self.write_to_pty(arrow.as_bytes().to_vec(), cx); + if mode.contains(TermMode::SGR_MOUSE) && mode.intersects(TermMode::MOUSE_MODE) { + let point = self.pixel_to_point(event.position, self.terminal_bounds, cx); + if let Some(report) = + sgr_mouse_wheel_report(lines, point.column.0, point.line.0 as usize) + { + for _ in 0..lines.unsigned_abs() { + self.write_to_pty(report.as_bytes().to_vec(), cx); + } } } return; @@ -3478,6 +3535,41 @@ impl TerminalView { } } + /// 当终端启用 SGR 鼠标 + 任意鼠标报告模式时,把按钮按下/释放事件以 SGR 形式 + /// 回报给 PTY。返回 true 表示已经处理,调用方应跳过 selection/dismiss/paste 等本地行为。 + /// + /// 特殊穿透:Shift+Left 永远走终端自身的文本选区,不向 TUI 转发 —— 这是 xterm/iTerm/ + /// kitty/wezterm 等的通用约定,让用户在 vim/tmux 等捕获鼠标的应用里仍能复制文本。 + /// 同理 mouse_up 时,如果当前正在终端选区(由 shift+drag 启动),也跳过 release 回报, + /// 避免在 release 阶段 shift 已松开就把 release 事件错发给 TUI、丢掉 selection 收尾。 + fn try_report_sgr_mouse_button( + &mut self, + button: MouseButton, + position: Point, + modifiers: Modifiers, + pressed: bool, + cx: &mut Context, + ) -> bool { + if button == MouseButton::Left + && (modifiers.shift || (!pressed && self.mouse_state.selecting)) + { + return false; + } + let mode = self.terminal.read(cx).mode(); + if !(mode.contains(TermMode::SGR_MOUSE) && mode.intersects(TermMode::MOUSE_MODE)) { + return false; + } + let Some(base) = mouse_button_code(button) else { + return false; + }; + let point = self.pixel_to_point(position, self.terminal_bounds, cx); + let encoded = base | encode_mouse_modifiers(modifiers); + let report = + sgr_mouse_button_report(encoded, point.column.0, point.line.0 as usize, pressed); + self.write_to_pty(report.into_bytes(), cx); + true + } + fn handle_mouse_down( &mut self, event: &MouseDownEvent, @@ -3572,10 +3664,20 @@ impl TerminalView { fn handle_middle_mouse_down( &mut self, - _event: &MouseDownEvent, + event: &MouseDownEvent, window: &mut Window, cx: &mut Context, ) { + // SGR 鼠标模式下中键按下走 TUI 报告而不是 middle-click paste + if self.try_report_sgr_mouse_button( + MouseButton::Middle, + event.position, + event.modifiers, + true, + cx, + ) { + return; + } if !self.middle_click_paste { return; } @@ -3644,6 +3746,16 @@ impl TerminalView { _window: &mut Window, cx: &mut Context, ) { + // SGR 鼠标模式下:先回报释放,然后跳过 selection 收尾 + if self.try_report_sgr_mouse_button( + event.button, + event.position, + event.modifiers, + false, + cx, + ) { + return; + } if event.button != MouseButton::Left { return; } @@ -4106,6 +4218,28 @@ impl Render for TerminalView { let view = cx.entity().clone(); let show_scrollbar = !terminal_mode.contains(TermMode::ALT_SCREEN) && history_size > 0; + // 检测主屏 ↔ alt screen 切换。 + // 进入 alt screen 时(opencode/lazygit/vim 等 TUI 启动),主动重发当前尺寸到 PTY, + // 触发 SIGWINCH 让 TUI 重新查询尺寸并刷新整屏,避免底部残留旧画面。 + // 仅在 last_size 已就绪时(说明 PTY 已收到过正确尺寸)才 nudge, + // 避免覆盖即将到来的首次 resize_if_needed。 + let alt_screen = terminal_mode.contains(TermMode::ALT_SCREEN); + if alt_screen != self.last_alt_screen { + tracing::info!( + target: "terminal_residue", + from = self.last_alt_screen, + to = alt_screen, + last_size = ?self.last_size, + "alt_screen mode transition" + ); + self.last_alt_screen = alt_screen; + if alt_screen && self.last_size.is_some() { + tracing::info!(target: "terminal_residue", "nudge_resize fired on enter alt_screen"); + self.terminal + .update(cx, |terminal, _| terminal.nudge_resize()); + } + } + div() .size_full() .flex() @@ -4527,10 +4661,80 @@ mod tests { } #[test] - fn alt_screen_scroll_arrow_maps_negative_lines_to_down() { - assert_eq!(alt_screen_scroll_arrow(-1, false), Some("\x1b[B")); - assert_eq!(alt_screen_scroll_arrow(-1, true), Some("\x1bOB")); - assert_eq!(alt_screen_scroll_arrow(0, false), None); + fn sgr_mouse_wheel_report_maps_negative_lines_to_wheel_down() { + assert_eq!( + sgr_mouse_wheel_report(-1, 4, 2).as_deref(), + Some("\x1b[<65;5;3M") + ); + assert_eq!(sgr_mouse_wheel_report(0, 4, 2), None); + } + + #[test] + fn sgr_mouse_button_report_uses_capital_m_on_press() { + // 左键按下,列 0、行 0 -> 转 1-based + let s = sgr_mouse_button_report(0, 0, 0, true); + assert_eq!(s, "\x1b[<0;1;1M"); + } + + #[test] + fn sgr_mouse_button_report_uses_lowercase_m_on_release() { + let s = sgr_mouse_button_report(2, 9, 4, false); + // 右键 (button=2) 释放在 1-based col=10 row=5 + assert_eq!(s, "\x1b[<2;10;5m"); + } + + #[test] + fn sgr_mouse_button_report_supports_modifier_encoded_buttons() { + // 左键 + shift (4) + ctrl (16) -> button=20 + let s = sgr_mouse_button_report(20, 0, 0, true); + assert_eq!(s, "\x1b[<20;1;1M"); + } + + #[test] + fn sgr_mouse_button_report_supports_drag_button_codes() { + // 拖动事件:button + 32(xterm 拖动位) + // 左键拖动 = 32 + let s = sgr_mouse_button_report(32, 7, 11, true); + assert_eq!(s, "\x1b[<32;8;12M"); + } + + #[test] + fn mouse_button_code_maps_three_main_buttons() { + assert_eq!(mouse_button_code(MouseButton::Left), Some(0)); + assert_eq!(mouse_button_code(MouseButton::Middle), Some(1)); + assert_eq!(mouse_button_code(MouseButton::Right), Some(2)); + } + + #[test] + fn encode_mouse_modifiers_packs_shift_alt_control() { + let none = Modifiers::default(); + assert_eq!(encode_mouse_modifiers(none), 0); + + let shift = Modifiers { + shift: true, + ..Default::default() + }; + assert_eq!(encode_mouse_modifiers(shift), 4); + + let alt = Modifiers { + alt: true, + ..Default::default() + }; + assert_eq!(encode_mouse_modifiers(alt), 8); + + let ctrl = Modifiers { + control: true, + ..Default::default() + }; + assert_eq!(encode_mouse_modifiers(ctrl), 16); + + let all = Modifiers { + shift: true, + alt: true, + control: true, + ..Default::default() + }; + assert_eq!(encode_mouse_modifiers(all), 28); } #[test] diff --git a/crates/ui/src/root.rs b/crates/ui/src/root.rs index 4c51bfbd04..7251014105 100644 --- a/crates/ui/src/root.rs +++ b/crates/ui/src/root.rs @@ -10,7 +10,7 @@ use crate::{ use gpui::{ AnyView, App, AppContext, Context, DefiniteLength, Entity, FocusHandle, InteractiveElement, IntoElement, KeyBinding, ParentElement as _, Pixels, Render, StyleRefinement, Styled, - WeakFocusHandle, Window, actions, div, prelude::FluentBuilder as _, + WeakFocusHandle, Window, actions, div, prelude::FluentBuilder as _, px }; use std::{any::TypeId, cell::RefCell, rc::Rc}; @@ -467,9 +467,9 @@ impl Render for Root { .relative() .size_full() .font_family(cx.theme().font_family.clone()) - .bg(cx.theme().transparent) - .rounded(cx.theme().radius_lg) - .overflow_hidden() + .bg(cx.theme().background) + .rounded(cx.theme().radius_lg - px(4.0)) + // .overflow_hidden() .text_color(cx.theme().foreground) .refine_style(&self.style) .child(self.view.clone()), diff --git a/main/locales/main.yml b/main/locales/main.yml index ab7de3ab15..4a9bb414fd 100644 --- a/main/locales/main.yml +++ b/main/locales/main.yml @@ -1129,6 +1129,14 @@ Settings: en: Reuse terminal session content when available; when disabled, only reopen the connection zh-CN: 可用时恢复终端中的会话内容;关闭后仅重新打开连接 zh-HK: 可用時恢復終端中的工作階段內容;關閉後僅重新開啟連線 + check_running_processes_on_exit: + en: Check running processes on exit + zh-CN: 退出时检测运行中的进程 + zh-HK: 退出時檢測運行中的進程 + check_running_processes_on_exit_desc: + en: Detect running processes when closing terminal tabs to prevent accidental data loss + zh-CN: 关闭终端标签页时检测是否有运行中的进程,防止意外丢失数据 + zh-HK: 關閉終端標籤頁時檢測是否有運行中的進程,防止意外丟失數據 # 数据库设置 Database: @@ -1215,6 +1223,20 @@ Settings: zh-CN: 留空时写入默认配置目录下的 logs 文件夹,修改后重启应用生效。 zh-HK: 留空時寫入默認配置目錄下的 logs 文件夾,修改後重啟應用生效。 + Log: + group_title: + en: Log + zh-CN: 日志 + zh-HK: 日誌 + file_path: + en: Log File Path + zh-CN: 日志保存路径 + zh-HK: 日誌保存路徑 + file_path_desc: + en: Leave empty to write logs to the default config directory logs folder. Restart the app after changing this path. + zh-CN: 留空时写入默认配置目录下的 logs 文件夹,修改后重启应用生效。 + zh-HK: 留空時寫入默認配置目錄下的 logs 文件夾,修改後重啟應用生效。 + Update: group_title: en: Update diff --git a/main/src/onetcli_app.rs b/main/src/onetcli_app.rs index 9cee93b7ba..99e780ef6e 100644 --- a/main/src/onetcli_app.rs +++ b/main/src/onetcli_app.rs @@ -473,6 +473,21 @@ fn request_main_window_close(window: &mut Window, cx: &mut App) -> bool { AppCloseDecision::Allow | AppCloseDecision::ForceClose => true, AppCloseDecision::Ignore => false, AppCloseDecision::Prompt => { + // 在打开对话框之前先保存状态,确保"仍然退出"时可以恢复 + tracing::info!("准备显示退出确认对话框,先保存当前标签状态"); + let state = with_recovery_snapshot_overrides( + cx, + None, + Some(WINDOW_CLOSE_TERMINAL_RECOVERY_MAX_CHARS), + |cx| tab_container.read(cx).dump(cx), + ); + if let Err(err) = save_tab_state(&state) { + tracing::error!("保存标签状态失败:{:?}", err); + } else { + tracing::info!("标签状态保存成功,共 {} 个标签", state.tabs.len()); + } + AppSettings::save_global(cx); + open_app_close_dialog(window, tab_container, running_states, guard, cx); false } @@ -1043,14 +1058,21 @@ impl OnetCliApp { cx.on_app_quit({ let tab_container = tab_container.clone(); move |_, cx| { - let state = with_recovery_snapshot_overrides( - cx, - None, - Some(WINDOW_CLOSE_TERMINAL_RECOVERY_MAX_CHARS), - |cx| tab_container.read(cx).dump(cx), - ); - if let Err(err) = save_tab_state(&state) { - tracing::error!("退出时保存标签状态失败:{:?}", err); + // 检查是否有标签页,如果没有说明已经在 force_close 中保存过了 + let has_tabs = tab_container.read(cx).tabs().len() > 0; + if has_tabs { + tracing::info!("应用退出:保存标签状态"); + let state = with_recovery_snapshot_overrides( + cx, + None, + Some(WINDOW_CLOSE_TERMINAL_RECOVERY_MAX_CHARS), + |cx| tab_container.read(cx).dump(cx), + ); + if let Err(err) = save_tab_state(&state) { + tracing::error!("退出时保存标签状态失败:{:?}", err); + } + } else { + tracing::info!("应用退出:标签页已清空,跳过保存(状态已在强制退出时保存)"); } AppSettings::save_global(cx); let redis_state = cx diff --git a/main/src/setting_tab.rs b/main/src/setting_tab.rs index 017cf1d16d..f5a21f2872 100644 --- a/main/src/setting_tab.rs +++ b/main/src/setting_tab.rs @@ -51,6 +51,7 @@ use terminal_view::{ DEFAULT_LINE_HEIGHT_SCALE, DEFAULT_RECOVERY_SCROLLBACK_LINES, MAX_LINE_HEIGHT_SCALE, MAX_RECOVERY_SCROLLBACK_LINES, MIN_LINE_HEIGHT_SCALE, TerminalSettings, TerminalTheme, set_recovery_scrollback_lines, + settings::{GlobalTerminalSettings, TerminalSettingsStore}, }; use tracing::{error, info}; @@ -495,6 +496,8 @@ pub struct AppSettings { pub terminal_confirm_multiline_paste: bool, #[serde(default = "default_true")] pub terminal_confirm_high_risk_command: bool, + #[serde(default = "default_true")] + pub terminal_check_running_processes_on_exit: bool, #[serde(default)] pub log_file_path: String, #[serde(default = "default_true")] @@ -834,6 +837,7 @@ impl Default for AppSettings { terminal_recovery_scrollback_lines: default_terminal_recovery_scrollback_lines(), terminal_confirm_multiline_paste: default_true(), terminal_confirm_high_risk_command: default_true(), + terminal_check_running_processes_on_exit: default_true(), restore_connections_on_startup: default_true(), restore_session_content: default_true(), log_file_path: String::new(), @@ -1248,6 +1252,16 @@ fn migrate_legacy_theme_state(settings: &mut AppSettings) { fn sync_terminal_settings_to_all(settings: AppSettings, cx: &mut App) { set_recovery_scrollback_lines(cx, settings.normalized_terminal_recovery_scrollback_lines()); + // 更新 GlobalTerminalSettings + if let Some(global) = cx.try_global::() { + let store = global.0.clone(); + store.update(cx, |store: &mut TerminalSettingsStore, cx| { + let mut next = store.snapshot(); + next.check_running_processes_on_exit = settings.terminal_check_running_processes_on_exit; + store.replace(next, cx); + }); + } + let Some(home) = cx.try_global::() else { return; }; @@ -1296,6 +1310,7 @@ fn legacy_terminal_settings(settings: &AppSettings) -> TerminalSettings { confirm_high_risk_command: settings.terminal_confirm_high_risk_command, builtin_highlights_initialized: false, custom_highlights: Vec::new(), + check_running_processes_on_exit: settings.terminal_check_running_processes_on_exit, } } @@ -2506,6 +2521,27 @@ impl SettingsPanel { .visible_when(|cx| { AppSettings::global(cx).restore_connections_on_startup }), + SettingItem::new( + t!("Settings.General.Terminal.check_running_processes_on_exit"), + SettingField::switch( + |cx: &App| { + AppSettings::global(cx).terminal_check_running_processes_on_exit + }, + |val: bool, cx: &mut App| { + let settings = AppSettings::global_mut(cx); + settings.terminal_check_running_processes_on_exit = val; + settings.save(); + sync_terminal_settings_to_all(settings.clone(), cx); + }, + ) + .default_value( + default_settings.terminal_check_running_processes_on_exit, + ), + ) + .description( + t!("Settings.General.Terminal.check_running_processes_on_exit_desc") + .to_string(), + ), ]), themed_setting_group(SettingGroup::new(), cx) .title(t!("Settings.General.Database.group_title")) From 520410cd44a6ca0b3de4837e92d2f638058b85a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=9C=E5=86=9C?= Date: Fri, 15 May 2026 21:06:14 +0800 Subject: [PATCH 45/45] fix popup in linux --- crates/core/src/popup_window.rs | 2 +- vendor/zed/Cargo.lock | 7951 +++++++++++++++++++++++++++++++ 2 files changed, 7952 insertions(+), 1 deletion(-) create mode 100644 vendor/zed/Cargo.lock diff --git a/crates/core/src/popup_window.rs b/crates/core/src/popup_window.rs index 4d3ae04e00..a00e5c0b3f 100644 --- a/crates/core/src/popup_window.rs +++ b/crates/core/src/popup_window.rs @@ -316,7 +316,7 @@ pub fn open_popup_window_with_should_close( let popup_view = cx.new(|cx| PopupWindowView::new(view, Some(content_size), window, cx)); cx.new(|cx| { - let root = Root::new(popup_view, window, cx); + let mut root = Root::new(popup_view, window, cx); #[cfg(target_os = "linux")] { // popup 的可见底色由 PopupWindowView 承担,避免 Root 底色在圆角处透出。 diff --git a/vendor/zed/Cargo.lock b/vendor/zed/Cargo.lock new file mode 100644 index 0000000000..ad209b74cd --- /dev/null +++ b/vendor/zed/Cargo.lock @@ -0,0 +1,7951 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", + "zeroize", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "const-random", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "aligned" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" +dependencies = [ + "as-slice", +] + +[[package]] +name = "aligned-vec" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "ar_archive_writer" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b" +dependencies = [ + "object", +] + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + +[[package]] +name = "arg_enum_proc_macro" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "as-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + +[[package]] +name = "as-slice" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "ash" +version = "0.38.0+1.3.281" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" +dependencies = [ + "libloading", +] + +[[package]] +name = "ash-window" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52bca67b61cb81e5553babde81b8211f713cb6db79766f80168f3e5f40ea6c82" +dependencies = [ + "ash", + "raw-window-handle", + "raw-window-metal", +] + +[[package]] +name = "ashpd" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33a3c86f3fd70c0ffa500ed189abfa90b5a52398a45d5dc372fcc38ebeb7a645" +dependencies = [ + "async-fs", + "async-net", + "enumflags2", + "futures-channel", + "futures-util", + "rand 0.9.2", + "serde", + "serde_repr", + "url", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "zbus", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener 5.4.1", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35" +dependencies = [ + "concurrent-queue", + "event-listener 2.5.3", + "futures-core", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-compression" +version = "0.4.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0f9ee0f6e02ffd7ad5816e9464499fba7b3effd01123b515c41d1697c43dad1" +dependencies = [ + "compression-codecs", + "compression-core", + "futures-io", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand 2.3.0", + "futures-lite 2.6.1", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-fs" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" +dependencies = [ + "async-lock", + "blocking", + "futures-lite 2.6.1", +] + +[[package]] +name = "async-global-executor" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b1b633a2115cd122d73b955eadd9916c18c8f510ec9cd1686404c60ad1c29c" +dependencies = [ + "async-channel 2.5.0", + "async-executor", + "async-io", + "async-lock", + "blocking", + "futures-lite 2.6.1", + "once_cell", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite 2.6.1", + "parking", + "polling", + "rustix 1.1.4", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener 5.4.1", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-net" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7" +dependencies = [ + "async-io", + "blocking", + "futures-lite 2.6.1", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel 2.5.0", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener 5.4.1", + "futures-lite 2.6.1", + "rustix 1.1.4", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "async-signal" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.4", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-std" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c8e079a4ab67ae52b7403632e4618815d6db36d2a010cfe41b02c1b1578f93b" +dependencies = [ + "async-channel 1.9.0", + "async-global-executor", + "async-io", + "async-lock", + "async-process", + "crossbeam-utils", + "futures-channel", + "futures-core", + "futures-io", + "futures-lite 2.6.1", + "gloo-timers", + "kv-log-macro", + "log", + "memchr", + "once_cell", + "pin-project-lite", + "pin-utils", + "slab", + "wasm-bindgen-futures", +] + +[[package]] +name = "async-tar" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1937db2d56578aa3919b9bdb0e5100693fd7d1c0f145c53eb81fbb03e217550" +dependencies = [ + "async-std", + "filetime", + "libc", + "pin-project", + "redox_syscall 0.2.16", + "xattr", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "git+https://github.com/smol-rs/async-task.git?rev=b4486cd71e4e94fbda54ce6302444de14f4d190e#b4486cd71e4e94fbda54ce6302444de14f4d190e" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "async_zip" +version = "0.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c50d65ce1b0e0cb65a785ff615f78860d7754290647d3b983208daa4f85e6" +dependencies = [ + "async-compression", + "crc32fast", + "futures-lite 2.6.1", + "pin-project", + "thiserror 2.0.18", +] + +[[package]] +name = "atomic" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c59bdb34bc650a32731b31bd8f0829cc15d24a708ee31559e0bb34f2bc320cba" + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "av-scenechange" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" +dependencies = [ + "aligned", + "anyhow", + "arg_enum_proc_macro", + "arrayvec", + "log", + "num-rational", + "num-traits", + "pastey", + "rayon", + "thiserror 2.0.18", + "v_frame", + "y4m", +] + +[[package]] +name = "av1-grain" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" +dependencies = [ + "anyhow", + "arrayvec", + "log", + "nom 8.0.0", + "num-rational", + "v_frame", +] + +[[package]] +name = "avif-serialize" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "375082f007bd67184fb9c0374614b29f9aaa604ec301635f72338bb65386a53d" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "aws-lc-rs" +version = "1.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa7e52a4c5c547c741610a2c6f123f3881e409b714cd27e6798ef020c514f0a" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link 0.2.1", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bindgen" +version = "0.71.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3" +dependencies = [ + "bitflags 2.11.0", + "cexpr", + "clang-sys", + "itertools 0.13.0", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash 2.1.1", + "shlex", + "syn 2.0.117", +] + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bit_field" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "bitstream-io" +version = "4.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60d4bd9d1db2c6bdf285e223a7fa369d5ce98ec767dec949c6ca62863ce61757" +dependencies = [ + "core2", +] + +[[package]] +name = "blade-graphics" +version = "0.7.0" +source = "git+https://github.com/kvark/blade?rev=e3cf011ca18a6dfd907d1dedd93e85e21f005fe3#e3cf011ca18a6dfd907d1dedd93e85e21f005fe3" +dependencies = [ + "ash", + "ash-window", + "bitflags 2.11.0", + "bytemuck", + "codespan-reporting", + "glow", + "gpu-alloc", + "gpu-alloc-ash", + "hidden-trait", + "js-sys", + "khronos-egl", + "libloading", + "log", + "mint", + "naga", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-metal", + "objc2-quartz-core", + "objc2-ui-kit", + "once_cell", + "raw-window-handle", + "slab", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "blade-macros" +version = "0.3.0" +source = "git+https://github.com/kvark/blade?rev=e3cf011ca18a6dfd907d1dedd93e85e21f005fe3#e3cf011ca18a6dfd907d1dedd93e85e21f005fe3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "blade-util" +version = "0.3.0" +source = "git+https://github.com/kvark/blade?rev=e3cf011ca18a6dfd907d1dedd93e85e21f005fe3#e3cf011ca18a6dfd907d1dedd93e85e21f005fe3" +dependencies = [ + "blade-graphics", + "bytemuck", + "log", + "profiling", +] + +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel 2.5.0", + "async-task", + "futures-io", + "futures-lite 2.6.1", + "piper", +] + +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "built" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4ad8f11f288f48ca24471bbd51ac257aaeaaa07adae295591266b792902ae64" + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "calloop" +version = "0.14.3" +source = "git+https://github.com/zed-industries/calloop#eb6b4fd17b9af5ecc226546bdd04185391b3e265" +dependencies = [ + "bitflags 2.11.0", + "polling", + "rustix 1.1.4", + "slab", + "tracing", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" +dependencies = [ + "calloop", + "rustix 1.1.4", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + +[[package]] +name = "cbindgen" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadd868a2ce9ca38de7eeafdcec9c7065ef89b42b32f0839278d55f35c54d1ff" +dependencies = [ + "heck 0.4.1", + "indexmap", + "log", + "proc-macro2", + "quote", + "serde", + "serde_json", + "syn 2.0.117", + "tempfile", + "toml 0.8.23", +] + +[[package]] +name = "cc" +version = "1.2.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom 7.1.3", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "cgl" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ced0551234e87afee12411d535648dd89d2e7f34c78b753395567aff3d447ff" +dependencies = [ + "libc", +] + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link 0.2.1", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", + "zeroize", +] + +[[package]] +name = "circular-buffer" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14c638459986b83c2b885179bd4ea6a2cbb05697b001501a56adb3a3d230803b" + +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "clap" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "cmake" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +dependencies = [ + "cc", +] + +[[package]] +name = "cocoa" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6140449f97a6e97f9511815c5632d84c8aacf8ac271ad77c559218161a1373c" +dependencies = [ + "bitflags 1.3.2", + "block", + "cocoa-foundation 0.1.2", + "core-foundation 0.9.4", + "core-graphics 0.23.2", + "foreign-types", + "libc", + "objc", +] + +[[package]] +name = "cocoa" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f79398230a6e2c08f5c9760610eb6924b52aa9e7950a619602baba59dcbbdbb2" +dependencies = [ + "bitflags 2.11.0", + "block", + "cocoa-foundation 0.2.0", + "core-foundation 0.10.0", + "core-graphics 0.24.0", + "foreign-types", + "libc", + "objc", +] + +[[package]] +name = "cocoa-foundation" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c6234cbb2e4c785b456c0644748b1ac416dd045799740356f8363dfe00c93f7" +dependencies = [ + "bitflags 1.3.2", + "block", + "core-foundation 0.9.4", + "core-graphics-types 0.1.3", + "libc", + "objc", +] + +[[package]] +name = "cocoa-foundation" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e14045fb83be07b5acf1c0884b2180461635b433455fa35d1cd6f17f1450679d" +dependencies = [ + "bitflags 2.11.0", + "block", + "core-foundation 0.10.0", + "core-graphics-types 0.2.0", + "libc", + "objc", +] + +[[package]] +name = "codespan-reporting" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe6d2e5af09e8c8ad56c969f2157a3d4238cebc7c55f0a517728c38f7b200f81" +dependencies = [ + "serde", + "termcolor", + "unicode-width", +] + +[[package]] +name = "collections" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed?rev=8b5328cad3d9ca3576296be574a1c7bfbcaf3557#8b5328cad3d9ca3576296be574a1c7bfbcaf3557" +dependencies = [ + "indexmap", + "rustc-hash 2.1.1", +] + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "command-fds" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f849b92c694fe237ecd8fafd1ba0df7ae0d45c1df6daeb7f68ed4220d51640bd" +dependencies = [ + "nix 0.30.1", + "thiserror 2.0.18", +] + +[[package]] +name = "compression-codecs" +version = "0.4.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb7b51a7d9c967fc26773061ba86150f19c50c0d65c887cb1fbe295fd16619b7" +dependencies = [ + "compression-core", + "deflate64", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d" + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] + +[[package]] +name = "convert_case" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b55271e5c8c478ad3f38ad24ef34923091e0548492a266d19b3c0b4d82574c63" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "core-graphics-types 0.1.3", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.10.0", + "core-graphics-types 0.2.0", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-helmer-fork" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32eb7c354ae9f6d437a6039099ce7ecd049337a8109b23d73e48e8ffba8e9cd5" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.9.4", + "core-graphics-types 0.1.3", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.10.0", + "libc", +] + +[[package]] +name = "core-graphics2" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e4583956b9806b69f73fcb23aee05eb3620efc282972f08f6a6db7504f8334d" +dependencies = [ + "bitflags 2.11.0", + "block", + "cfg-if", + "core-foundation 0.10.0", + "libc", +] + +[[package]] +name = "core-text" +version = "21.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a593227b66cbd4007b2a050dfdd9e1d1318311409c8d600dc82ba1b15ca9c130" +dependencies = [ + "core-foundation 0.10.0", + "core-graphics 0.24.0", + "foreign-types", + "libc", +] + +[[package]] +name = "core-video" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45e71d5be22206bed53c3c3cb99315fc4c3d31b8963808c6bc4538168c4f8ef" +dependencies = [ + "block", + "core-foundation 0.10.0", + "core-graphics2", + "io-surface", + "libc", + "metal", +] + +[[package]] +name = "core2" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505" +dependencies = [ + "memchr", +] + +[[package]] +name = "core_maths" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +dependencies = [ + "libm", +] + +[[package]] +name = "cosmic-text" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d8c4e3a1d02f5269ed15c2d70b4647167856f66f228dcdf99050ab77bbb5a56" +dependencies = [ + "bitflags 2.11.0", + "fontdb", + "harfrust", + "linebender_resource_handle", + "log", + "rangemap", + "rustc-hash 2.1.1", + "self_cell", + "skrifa 0.40.0", + "smol_str", + "swash", + "sys-locale", + "unicode-bidi", + "unicode-linebreak", + "unicode-script", + "unicode-segmentation", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "ctor" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec09e802f5081de6157da9a75701d6c713d8dc3ba52571fd4bd25f412644e8a6" +dependencies = [ + "ctor-proc-macro", + "dtor", +] + +[[package]] +name = "ctor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2931af7e13dc045d8e9d26afccc6fa115d64e115c9c84b1166288b46f6782c2" + +[[package]] +name = "ctrlc" +version = "3.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162" +dependencies = [ + "dispatch2", + "nix 0.31.2", + "windows-sys 0.61.2", +] + +[[package]] +name = "data-url" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" + +[[package]] +name = "deflate64" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2" + +[[package]] +name = "derive_more" +version = "0.99.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.117", +] + +[[package]] +name = "derive_refineable" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed?rev=8b5328cad3d9ca3576296be574a1c7bfbcaf3557#8b5328cad3d9ca3576296be574a1c7bfbcaf3557" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "dirs" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059" +dependencies = [ + "dirs-sys 0.3.7", +] + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys 0.4.1", +] + +[[package]] +name = "dirs-sys" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" +dependencies = [ + "libc", + "redox_users", + "winapi", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.11.0", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dtor" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97cbdf2ad6846025e8e25df05171abfb30e3ababa12ee0a0e44b9bbe570633a8" +dependencies = [ + "dtor-proc-macro", +] + +[[package]] +name = "dtor-proc-macro" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7454e41ff9012c00d53cf7f475c5e3afa3b91b7c90568495495e8d9bf47a1055" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dwrote" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b35532432acc8b19ceed096e35dfa088d3ea037fe4f3c085f1f97f33b4d02" +dependencies = [ + "lazy_static", + "libc", + "winapi", + "wio", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "embed-resource" +version = "3.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63a1d0de4f2249aa0ff5884d7080814f446bb241a559af6c170a41e878ed2d45" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 0.9.12+spec-1.1.0", + "vswhom", + "winreg", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "env_filter" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e90c2accc4b07a8456ea0debdc2e7587bdd890680d71173a15d4ae604f6eef" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "etagere" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc89bf99e5dc15954a60f707c1e09d7540e5cd9af85fa75caa0b510bc08c5342" +dependencies = [ + "euclid", + "svg_fmt", +] + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "event-listener" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener 5.4.1", + "pin-project-lite", +] + +[[package]] +name = "exr" +version = "1.74.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide", + "rayon-core", + "smallvec", + "zune-inflate", +] + +[[package]] +name = "fastrand" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e51093e27b0797c359783294ca4f0a911c270184cb10f85783b118614a1501be" +dependencies = [ + "instant", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "fax" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab" +dependencies = [ + "fax_derive", +] + +[[package]] +name = "fax_derive" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + +[[package]] +name = "filetime" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +dependencies = [ + "cfg-if", + "libc", + "libredox", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "float-cmp" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" + +[[package]] +name = "float-ord" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ce81f49ae8a0482e4c55ea62ebbd7e5a686af544c00b9d090bba3ff9be97b3d" + +[[package]] +name = "float_next_after" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bf7cc16383c4b8d58b9905a8509f02926ce3058053c056376248d958c9df1e8" + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "nanorand", + "spin 0.9.8", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "font-types" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39a654f404bbcbd48ea58c617c2993ee91d1cb63727a37bf2323a4edeed1b8c5" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "font-types" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73829a7b5c91198af28a99159b7ae4afbb252fb906159ff7f189f3a2ceaa3df2" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "fontconfig-parser" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" +dependencies = [ + "roxmltree", +] + +[[package]] +name = "fontdb" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905" +dependencies = [ + "fontconfig-parser", + "log", + "memmap2", + "slotmap", + "tinyvec", + "ttf-parser", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "freetype-sys" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7edc5b9669349acfda99533e9e0bcf26a51862ab43b08ee7745c55d28eb134" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" +dependencies = [ + "mac", + "new_debug_unreachable", +] + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-lite" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49a9d51ce47660b1e808d3c990b4709f2f415d928835a17dfd16991515c46bce" +dependencies = [ + "fastrand 1.9.0", + "futures-core", + "futures-io", + "memchr", + "parking", + "pin-project-lite", + "waker-fn", +] + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand 2.3.0", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.4", + "windows-link 0.2.1", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "gif" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5df2ba84018d80c213569363bdcd0c64e6933c67fe4c1d60ecf822971a3c35e" +dependencies = [ + "color_quant", + "weezl", +] + +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + +[[package]] +name = "git2" +version = "0.20.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b88256088d75a56f8ecfa070513a775dd9107f6530ef14919dac831af9cfe2b" +dependencies = [ + "bitflags 2.11.0", + "libc", + "libgit2-sys", + "log", + "url", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "glow" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e5ea60d70410161c8bf5da3fdfeaa1c72ed2c15f8bbb9d19fe3a4fad085f08" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gpu-alloc" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbcd2dba93594b227a1f57ee09b8b9da8892c34d55aa332e034a228d0fe6a171" +dependencies = [ + "bitflags 2.11.0", + "gpu-alloc-types", +] + +[[package]] +name = "gpu-alloc-ash" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbda7a18a29bc98c2e0de0435c347df935bf59489935d0cbd0b73f1679b6f79a" +dependencies = [ + "ash", + "gpu-alloc-types", + "tinyvec", +] + +[[package]] +name = "gpu-alloc-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98ff03b468aa837d70984d55f5d3f846f6ec31fe34bbb97c4f85219caeee1ca4" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "gpui" +version = "0.2.2" +dependencies = [ + "anyhow", + "as-raw-xcb-connection", + "ashpd", + "async-task", + "backtrace", + "bindgen", + "bitflags 2.11.0", + "blade-graphics", + "blade-macros", + "blade-util", + "block", + "bytemuck", + "calloop", + "calloop-wayland-source", + "cbindgen", + "chrono", + "circular-buffer", + "cocoa 0.26.0", + "cocoa-foundation 0.2.0", + "collections", + "core-foundation 0.10.0", + "core-foundation-sys", + "core-graphics 0.24.0", + "core-text", + "core-video", + "cosmic-text", + "ctor", + "derive_more", + "embed-resource", + "env_logger", + "etagere", + "filedescriptor", + "foreign-types", + "futures", + "gpui_macros", + "http_client", + "image", + "inventory", + "itertools 0.14.0", + "libc", + "log", + "lyon", + "mach2", + "media", + "metal", + "naga", + "num_cpus", + "objc", + "objc2", + "objc2-metal", + "oo7", + "open", + "parking", + "parking_lot", + "pathfinder_geometry", + "pin-project", + "postage", + "pretty_assertions", + "profiling", + "rand 0.9.2", + "raw-window-handle", + "refineable", + "reqwest_client", + "resvg", + "scheduler", + "schemars", + "seahash", + "semver", + "serde", + "serde_json", + "slotmap", + "smallvec", + "smol", + "spin 0.10.0", + "stacksafe", + "strum 0.27.2", + "sum_tree", + "swash", + "taffy", + "thiserror 2.0.18", + "unicode-segmentation", + "usvg", + "util", + "util_macros", + "uuid", + "waker-fn", + "wayland-backend", + "wayland-client", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-plasma", + "wayland-protocols-wlr", + "windows 0.61.3", + "windows-core 0.61.2", + "windows-numerics", + "windows-registry 0.5.3", + "x11-clipboard", + "x11rb", + "xkbcommon", + "zed-font-kit", + "zed-scap", + "zed-xim", +] + +[[package]] +name = "gpui_macros" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed?rev=8b5328cad3d9ca3576296be574a1c7bfbcaf3557#8b5328cad3d9ca3576296be574a1c7bfbcaf3557" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "grid" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12101ecc8225ea6d675bc70263074eab6169079621c2186fe0c66590b2df9681" + +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "num-traits", + "zerocopy", +] + +[[package]] +name = "harfrust" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9da2e5ae821f6e96664977bf974d6d6a2d6682f9ccee23e62ec1d134246845f9" +dependencies = [ + "bitflags 2.11.0", + "bytemuck", + "core_maths", + "read-fonts 0.37.0", + "smallvec", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hexf-parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" + +[[package]] +name = "hidden-trait" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ed9e850438ac849bec07e7d09fbe9309cbd396a5988c30b010580ce08860df" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "http_client" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed?rev=8b5328cad3d9ca3576296be574a1c7bfbcaf3557#8b5328cad3d9ca3576296be574a1c7bfbcaf3557" +dependencies = [ + "anyhow", + "async-compression", + "async-fs", + "async-tar", + "bytes", + "derive_more", + "futures", + "http", + "http-body", + "log", + "parking_lot", + "serde", + "serde_json", + "serde_urlencoded", + "sha2", + "tempfile", + "url", + "util", +] + +[[package]] +name = "http_client_tls" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed?rev=8b5328cad3d9ca3576296be574a1c7bfbcaf3557#8b5328cad3d9ca3576296be574a1c7bfbcaf3557" +dependencies = [ + "rustls", + "rustls-platform-verifier", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "libc", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "exr", + "gif", + "image-webp", + "moxcms", + "num-traits", + "png 0.18.1", + "qoi", + "ravif", + "rayon", + "rgb", + "tiff", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "imagesize" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285" + +[[package]] +name = "imgref" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c5cedc30da3a610cac6b4ba17597bdf7152cf974e8aab3afb3d54455e371c8" + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "interpolate_name" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "inventory" +version = "0.3.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "009ae045c87e7082cb72dab0ccd01ae075dd00141ddc108f43a0ea150a9e7227" +dependencies = [ + "rustversion", +] + +[[package]] +name = "io-surface" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "554b8c5d64ec09a3a520fe58e4d48a73e00ff32899cdcbe32a4877afd4968b8e" +dependencies = [ + "cgl", + "core-foundation 0.10.0", + "core-foundation-sys", + "leaky-cow", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jiff" +version = "0.2.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359" +dependencies = [ + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-static" +version = "0.2.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "khronos-egl" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" +dependencies = [ + "libc", + "libloading", +] + +[[package]] +name = "kurbo" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62026ae44756f8a599ba21140f350303d4f08dcdcc71b5ad9c9bb8128c13c62" +dependencies = [ + "arrayvec", + "euclid", + "smallvec", +] + +[[package]] +name = "kv-log-macro" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de8b303297635ad57c9f5059fd9cee7a47f8e8daa09df0fcd07dd39fb22977f" +dependencies = [ + "log", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin 0.9.8", +] + +[[package]] +name = "leak" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd100e01f1154f2908dfa7d02219aeab25d0b9c7fa955164192e3245255a0c73" + +[[package]] +name = "leaky-cow" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40a8225d44241fd324a8af2806ba635fc7c8a7e9a7de4d5cf3ef54e71f5926fc" +dependencies = [ + "leak", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "lebe" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" + +[[package]] +name = "libc" +version = "0.2.183" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" + +[[package]] +name = "libfuzzer-sys" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f12a681b7dd8ce12bff52488013ba614b869148d54dd79836ab85aafdd53f08d" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "libgit2-sys" +version = "0.18.3+1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9b3acc4b91781bb0b3386669d325163746af5f6e4f73e6d2d630e09a35f3487" +dependencies = [ + "cc", + "libc", + "libz-sys", + "pkg-config", +] + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link 0.2.1", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" +dependencies = [ + "bitflags 2.11.0", + "libc", + "plain", + "redox_syscall 0.7.3", +] + +[[package]] +name = "libz-sys" +version = "1.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52f4c29e2a68ac30c9087e1b772dc9f44a2b66ed44edf2266cf2be9b03dafc1" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linebender_resource_handle" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +dependencies = [ + "serde_core", + "value-bag", +] + +[[package]] +name = "loop9" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" +dependencies = [ + "imgref", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "lyon" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0578bdecb7d6d88987b8b2b1e3a4e2f81df9d0ece1078623324a567904e7b7" +dependencies = [ + "lyon_algorithms", + "lyon_extra", + "lyon_tessellation", +] + +[[package]] +name = "lyon_algorithms" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9815fac08e6fd96733a11dce4f9d15a3f338e96a2e2311ee21e1b738efc2bc0f" +dependencies = [ + "lyon_path", + "num-traits", +] + +[[package]] +name = "lyon_extra" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7755f08423275157ad1680aaecc9ccb7e0cc633da3240fea2d1522935cc15c72" +dependencies = [ + "lyon_path", + "thiserror 2.0.18", +] + +[[package]] +name = "lyon_geom" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4336502e29e32af93cf2dad2214ed6003c17ceb5bd499df77b1de663b9042b92" +dependencies = [ + "arrayvec", + "euclid", + "num-traits", +] + +[[package]] +name = "lyon_path" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c463f9c428b7fc5ec885dcd39ce4aa61e29111d0e33483f6f98c74e89d8621e" +dependencies = [ + "lyon_geom", + "num-traits", +] + +[[package]] +name = "lyon_tessellation" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e43b7e44161571868f5c931d12583592c223c5583eef86b08aa02b7048a3552" +dependencies = [ + "float_next_after", + "lyon_path", + "num-traits", +] + +[[package]] +name = "mac" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" + +[[package]] +name = "mach2" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a1b95cd5421ec55b445b5ae102f5ea0e768de1f82bd3001e11f426c269c3aea" +dependencies = [ + "libc", +] + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + +[[package]] +name = "maybe-rayon" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +dependencies = [ + "cfg-if", + "rayon", +] + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "media" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed?rev=8b5328cad3d9ca3576296be574a1c7bfbcaf3557#8b5328cad3d9ca3576296be574a1c7bfbcaf3557" +dependencies = [ + "anyhow", + "bindgen", + "core-foundation 0.10.0", + "core-video", + "ctor", + "foreign-types", + "metal", + "objc", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "memmap2" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +dependencies = [ + "libc", +] + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "metal" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ecfd3296f8c56b7c1f6fbac3c71cefa9d78ce009850c45000015f206dc7fa21" +dependencies = [ + "bitflags 2.11.0", + "block", + "core-graphics-types 0.1.3", + "foreign-types", + "log", + "objc", + "paste", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mint" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e53debba6bda7a793e5f99b8dacf19e626084f525f7829104ba9898f367d85ff" + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "naga" +version = "25.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b977c445f26e49757f9aca3631c3b8b836942cb278d69a92e7b80d3b24da632" +dependencies = [ + "arrayvec", + "bit-set", + "bitflags 2.11.0", + "cfg_aliases", + "codespan-reporting", + "half", + "hashbrown 0.15.5", + "hexf-parse", + "indexmap", + "log", + "num-traits", + "once_cell", + "rustc-hash 1.1.0", + "spirv", + "strum 0.26.3", + "thiserror 2.0.18", + "unicode-ident", +] + +[[package]] +name = "nanorand" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a51313c5820b0b02bd422f4b44776fbf47961755c74ce64afc73bfad10226c3" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "nix" +version = "0.31.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d6d0705320c1e6ba1d912b5e37cf18071b6c2e9b7fa8215a1e8a7651966f5d3" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "noop_proc_macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" + +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.5", + "serde", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", + "objc_exception", +] + +[[package]] +name = "objc-foundation" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1add1b659e36c9607c7aab864a76c7a4c2760cd0cd2e120f3fb8b952c7e22bf9" +dependencies = [ + "block", + "objc", + "objc_id", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-core-foundation", + "objc2-foundation", + "objc2-quartz-core", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.11.0", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-metal" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" +dependencies = [ + "bitflags 2.11.0", + "block2", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-core-foundation", + "objc2-foundation", + "objc2-metal", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-core-foundation", + "objc2-foundation", + "objc2-quartz-core", +] + +[[package]] +name = "objc_exception" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad970fb455818ad6cba4c122ad012fae53ae8b4795f86378bce65e4f6bab2ca4" +dependencies = [ + "cc", +] + +[[package]] +name = "objc_id" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92d4ddb4bd7b50d730c215ff871754d0da6b2178849f8a2a2ab69712d0c073b" +dependencies = [ + "objc", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "oo7" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3299dd401feaf1d45afd8fd1c0586f10fcfb22f244bb9afa942cec73503b89d" +dependencies = [ + "aes", + "ashpd", + "async-fs", + "async-io", + "async-lock", + "blocking", + "cbc", + "cipher", + "digest", + "endi", + "futures-lite 2.6.1", + "futures-util", + "getrandom 0.3.4", + "hkdf", + "hmac", + "md-5", + "num", + "num-bigint-dig", + "pbkdf2", + "rand 0.9.2", + "serde", + "sha2", + "subtle", + "zbus", + "zbus_macros", + "zeroize", + "zvariant", +] + +[[package]] +name = "open" +version = "5.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc" +dependencies = [ + "is-wsl", + "libc", + "pathdiff", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + +[[package]] +name = "pathfinder_geometry" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b7e7b4ea703700ce73ebf128e1450eb69c3a8329199ffbfb9b2a0418e5ad3" +dependencies = [ + "log", + "pathfinder_simd", +] + +[[package]] +name = "pathfinder_simd" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf9027960355bf3afff9841918474a81a5f972ac6d226d518060bba758b5ad57" +dependencies = [ + "rustc_version", +] + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "perf" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed?rev=8b5328cad3d9ca3576296be574a1c7bfbcaf3557#8b5328cad3d9ca3576296be574a1c7bfbcaf3557" +dependencies = [ + "collections", + "serde", + "serde_json", +] + +[[package]] +name = "pico-args" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" + +[[package]] +name = "pin-project" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand 2.3.0", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.11.0", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "pollster" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5da3b0203fd7ee5720aa0b5e790b591aa5d3f41c3ed2c34a3a393382198af2f7" + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "091397be61a01d4be58e7841595bd4bfedb15f1cd54977d79b8271e94ed799a3" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "postage" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af3fb618632874fb76937c2361a7f22afd393c982a2165595407edc75b06d3c1" +dependencies = [ + "atomic", + "crossbeam-queue", + "futures", + "log", + "parking_lot", + "pin-project", + "pollster", + "static_assertions", + "thiserror 1.0.69", +] + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.8+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" +dependencies = [ + "profiling-procmacros", +] + +[[package]] +name = "profiling-procmacros" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "psm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3852766467df634d74f0b2d7819bf8dc483a0eb2e3b0f50f756f9cfe8b0d18d8" +dependencies = [ + "ar_archive_writer", + "cc", +] + +[[package]] +name = "pxfm" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d" + +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quick-xml" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eff6510e86862b57b210fd8cbe8ed3f0d7d600b9c2863cd4549a2e033c66e956" +dependencies = [ + "memchr", +] + +[[package]] +name = "quick-xml" +version = "0.39.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d" +dependencies = [ + "memchr", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash 2.1.1", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash 2.1.1", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rangemap" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68" + +[[package]] +name = "rav1e" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" +dependencies = [ + "aligned-vec", + "arbitrary", + "arg_enum_proc_macro", + "arrayvec", + "av-scenechange", + "av1-grain", + "bitstream-io", + "built", + "cfg-if", + "interpolate_name", + "itertools 0.14.0", + "libc", + "libfuzzer-sys", + "log", + "maybe-rayon", + "new_debug_unreachable", + "noop_proc_macro", + "num-derive", + "num-traits", + "paste", + "profiling", + "rand 0.9.2", + "rand_chacha 0.9.0", + "simd_helpers", + "thiserror 2.0.18", + "v_frame", + "wasm-bindgen", +] + +[[package]] +name = "ravif" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45" +dependencies = [ + "avif-serialize", + "imgref", + "loop9", + "quick-error", + "rav1e", + "rayon", + "rgb", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "raw-window-metal" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76e8caa82e31bb98fee12fa8f051c94a6aa36b07cddb03f0d4fc558988360ff1" +dependencies = [ + "cocoa 0.25.0", + "core-graphics 0.23.2", + "objc", + "raw-window-handle", +] + +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "read-fonts" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6717cf23b488adf64b9d711329542ba34de147df262370221940dfabc2c91358" +dependencies = [ + "bytemuck", + "font-types 0.10.1", +] + +[[package]] +name = "read-fonts" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b634fabf032fab15307ffd272149b622260f55974d9fad689292a5d33df02e5" +dependencies = [ + "bytemuck", + "core_maths", + "font-types 0.11.1", +] + +[[package]] +name = "redox_syscall" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "redox_syscall" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "refineable" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed?rev=8b5328cad3d9ca3576296be574a1c7bfbcaf3557#8b5328cad3d9ca3576296be574a1c7bfbcaf3557" +dependencies = [ + "derive_refineable", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest_client" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed?rev=8b5328cad3d9ca3576296be574a1c7bfbcaf3557#8b5328cad3d9ca3576296be574a1c7bfbcaf3557" +dependencies = [ + "anyhow", + "bytes", + "futures", + "http_client", + "http_client_tls", + "log", + "regex", + "serde", + "tokio", + "util", + "zed-reqwest", +] + +[[package]] +name = "resvg" +version = "0.45.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8928798c0a55e03c9ca6c4c6846f76377427d2c1e1f7e6de3c06ae57942df43" +dependencies = [ + "log", + "pico-args", + "rgb", + "svgtypes", + "tiny-skia", + "usvg", +] + +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + +[[package]] +name = "rust-embed" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04113cb9355a377d83f06ef1f0a45b8ab8cd7d8b1288160717d66df5c7988d27" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0902e4c7c8e997159ab384e6d0fc91c221375f6894346ae107f47dd0f3ccaa" +dependencies = [ + "proc-macro2", + "quote", + "rust-embed-utils", + "syn 2.0.117", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1" +dependencies = [ + "globset", + "sha2", + "walkdir", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19787cda76408ec5404443dc8b31795c87cd8fec49762dc75fa727740d34acc1" +dependencies = [ + "core-foundation 0.10.0", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs 0.26.11", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "rustybuzz" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702" +dependencies = [ + "bitflags 2.11.0", + "bytemuck", + "core_maths", + "log", + "smallvec", + "ttf-parser", + "unicode-bidi-mirroring", + "unicode-ccc", + "unicode-properties", + "unicode-script", +] + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scheduler" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed?rev=8b5328cad3d9ca3576296be574a1c7bfbcaf3557#8b5328cad3d9ca3576296be574a1c7bfbcaf3557" +dependencies = [ + "async-task", + "backtrace", + "chrono", + "flume", + "futures", + "parking_lot", + "rand 0.9.2", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "indexmap", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.117", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "screencapturekit" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5eeeb57ac94960cfe5ff4c402be6585ae4c8d29a2cf41b276048c2e849d64e" +dependencies = [ + "screencapturekit-sys", +] + +[[package]] +name = "screencapturekit-sys" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22411b57f7d49e7fe08025198813ee6fd65e1ee5eff4ebc7880c12c82bde4c60" +dependencies = [ + "block", + "dispatch", + "objc", + "objc-foundation", + "objc_id", + "once_cell", +] + +[[package]] +name = "seahash" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.10.0", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "self_cell" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_fmt" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e497af288b3b95d067a23a4f749f2861121ffcb2f6d8379310dcda040c345ed" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "indexmap", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_json_lenient" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e033097bf0d2b59a62b42c18ebbb797503839b26afdda2c4e1415cb6c813540" +dependencies = [ + "indexmap", + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "876ac351060d4f882bb1032b6369eb0aef79ad9df1ea8bc404874d8cc3d0cd98" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + +[[package]] +name = "simd_helpers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" +dependencies = [ + "quote", +] + +[[package]] +name = "simplecss" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c" +dependencies = [ + "log", +] + +[[package]] +name = "siphasher" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" + +[[package]] +name = "skrifa" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c31071dedf532758ecf3fed987cdb4bd9509f900e026ab684b4ecb81ea49841" +dependencies = [ + "bytemuck", + "read-fonts 0.35.0", +] + +[[package]] +name = "skrifa" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fbdfe3d2475fbd7ddd1f3e5cf8288a30eb3e5f95832829570cd88115a7434ac" +dependencies = [ + "bytemuck", + "read-fonts 0.37.0", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "smol" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a33bd3e260892199c3ccfc487c88b2da2265080acb316cd920da72fdfd7c599f" +dependencies = [ + "async-channel 2.5.0", + "async-executor", + "async-fs", + "async-io", + "async-lock", + "async-net", + "async-process", + "blocking", + "futures-lite 2.6.1", +] + +[[package]] +name = "smol_str" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa7368fcf4852a4c2dd92df0cace6a71f2091ca0a23391ce7f3a31833f1523" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spin" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spirv" +version = "0.3.0+sdk-1.3.268.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stacker" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d74a23609d509411d10e2176dc2a4346e3b4aea2e7b1869f19fdedbc71c013" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys 0.59.0", +] + +[[package]] +name = "stacksafe" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d9c1172965d317e87ddb6d364a040d958b40a1db82b6ef97da26253a8b3d090" +dependencies = [ + "stacker", + "stacksafe-macro", +] + +[[package]] +name = "stacksafe-macro" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "172175341049678163e979d9107ca3508046d4d2a7c6682bee46ac541b17db69" +dependencies = [ + "proc-macro-error2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" +dependencies = [ + "float-cmp", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros 0.26.4", +] + +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros 0.27.2", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.117", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "sum_tree" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed?rev=8b5328cad3d9ca3576296be574a1c7bfbcaf3557#8b5328cad3d9ca3576296be574a1c7bfbcaf3557" +dependencies = [ + "arrayvec", + "log", + "rayon", + "tracing", + "ztracing", +] + +[[package]] +name = "sval" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1aaf178a50bbdd86043fce9bf0a5867007d9b382db89d1c96ccae4601ff1ff9" + +[[package]] +name = "sval_buffer" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f89273e48f03807ebf51c4d81c52f28d35ffa18a593edf97e041b52de143df89" +dependencies = [ + "sval", + "sval_ref", +] + +[[package]] +name = "sval_dynamic" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0430f4e18e7eba21a49d10d25a8dec3ce0e044af40b162347e99a8e3c3ced864" +dependencies = [ + "sval", +] + +[[package]] +name = "sval_fmt" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "835f51b9d7331b9d7fc48fc716c02306fa88c4a076b1573531910c91a525882d" +dependencies = [ + "itoa", + "ryu", + "sval", +] + +[[package]] +name = "sval_json" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13cbfe3ef406ee2366e7e8ab3678426362085fa9eaedf28cb878a967159dced3" +dependencies = [ + "itoa", + "ryu", + "sval", +] + +[[package]] +name = "sval_nested" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b20358af4af787c34321a86618c3cae12eabdd0e9df22cd9dd2c6834214c518" +dependencies = [ + "sval", + "sval_buffer", + "sval_ref", +] + +[[package]] +name = "sval_ref" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb5e500f8eb2efa84f75e7090f7fc43f621b9f8b6cde571c635b3855f97b332a" +dependencies = [ + "sval", +] + +[[package]] +name = "sval_serde" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2032ae39b11dcc6c18d5fbc50a661ea191cac96484c59ccf49b002261ca2c1" +dependencies = [ + "serde_core", + "sval", + "sval_nested", +] + +[[package]] +name = "svg_fmt" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0193cc4331cfd2f3d2011ef287590868599a2f33c3e69bc22c1a3d3acf9e02fb" + +[[package]] +name = "svgtypes" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68c7541fff44b35860c1a7a47a7cadf3e4a304c457b58f9870d9706ece028afc" +dependencies = [ + "kurbo", + "siphasher", +] + +[[package]] +name = "swash" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47846491253e976bdd07d0f9cc24b7daf24720d11309302ccbbc6e6b6e53550a" +dependencies = [ + "skrifa 0.37.0", + "yazi", + "zeno", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "sys-locale" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" +dependencies = [ + "libc", +] + +[[package]] +name = "sysinfo" +version = "0.31.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "355dbe4f8799b304b05e1b0f05fc59b2a18d36645cf169607da45bde2f69a1be" +dependencies = [ + "core-foundation-sys", + "libc", + "memchr", + "ntapi", + "rayon", + "windows 0.57.0", +] + +[[package]] +name = "system-configuration" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "taffy" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13e5d13f79d558b5d353a98072ca8ca0e99da429467804de959aa8c83c9a004" +dependencies = [ + "arrayvec", + "grid", + "serde", + "slotmap", +] + +[[package]] +name = "take-until" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bdb6fa0dfa67b38c1e66b7041ba9dcf23b99d8121907cd31c807a332f7a0bbb" + +[[package]] +name = "tao-core-video-sys" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271450eb289cb4d8d0720c6ce70c72c8c858c93dd61fc625881616752e6b98f6" +dependencies = [ + "cfg-if", + "core-foundation-sys", + "libc", + "objc", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand 2.3.0", + "getrandom 0.4.2", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "tendril" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" +dependencies = [ + "futf", + "mac", + "utf-8", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + +[[package]] +name = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "png 0.17.16", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-socks" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d4770b8024672c1101b3f6733eab95b18007dbe0847a8afe341fcf79e06043f" +dependencies = [ + "either", + "futures-util", + "thiserror 1.0.69", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned 1.1.0", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_datetime" +version = "1.1.0+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97251a7c317e03ad83774a8752a7e81fb6067740609f75ea2b585b569a59198f" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", +] + +[[package]] +name = "toml_edit" +version = "0.25.8+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16bff38f1d86c47f9ff0647e6838d7bb362522bdf44006c7068c2b1e606f1f3c" +dependencies = [ + "indexmap", + "toml_datetime 1.1.0+spec-1.1.0", + "toml_parser", + "winnow 1.0.0", +] + +[[package]] +name = "toml_parser" +version = "1.1.0+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011" +dependencies = [ + "winnow 1.0.0", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "toml_writer" +version = "1.1.0+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d282ade6016312faf3e41e57ebbba0c073e4056dab1232ab1cb624199648f8ed" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "nu-ansi-term", + "sharded-slab", + "smallvec", + "thread_local", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" +dependencies = [ + "core_maths", +] + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-bidi-mirroring" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfa6e8c60bb66d49db113e0125ee8711b7647b5579dc7f5f19c42357ed039fe" + +[[package]] +name = "unicode-ccc" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-script" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" + +[[package]] +name = "unicode-segmentation" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a559e63b5d8004e12f9bce88af5c6d939c58de839b7532cfe9653846cedd2a9e" + +[[package]] +name = "unicode-vo" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "usvg" +version = "0.45.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80be9b06fbae3b8b303400ab20778c80bbaf338f563afe567cf3c9eea17b47ef" +dependencies = [ + "base64", + "data-url", + "flate2", + "fontdb", + "imagesize", + "kurbo", + "log", + "pico-args", + "roxmltree", + "rustybuzz", + "simplecss", + "siphasher", + "strict-num", + "svgtypes", + "tiny-skia-path", + "unicode-bidi", + "unicode-script", + "unicode-vo", + "xmlwriter", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "util" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed?rev=8b5328cad3d9ca3576296be574a1c7bfbcaf3557#8b5328cad3d9ca3576296be574a1c7bfbcaf3557" +dependencies = [ + "anyhow", + "async-fs", + "async_zip", + "collections", + "command-fds", + "dirs 4.0.0", + "dunce", + "futures", + "futures-lite 1.13.0", + "git2", + "globset", + "itertools 0.14.0", + "libc", + "log", + "mach2", + "nix 0.29.0", + "percent-encoding", + "rand 0.9.2", + "regex", + "rust-embed", + "schemars", + "serde", + "serde_json", + "serde_json_lenient", + "shlex", + "smol", + "take-until", + "tempfile", + "tendril", + "unicase", + "url", + "util_macros", + "walkdir", + "which", +] + +[[package]] +name = "util_macros" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed?rev=8b5328cad3d9ca3576296be574a1c7bfbcaf3557#8b5328cad3d9ca3576296be574a1c7bfbcaf3557" +dependencies = [ + "perf", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "uuid" +version = "1.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "serde_core", + "sha1_smol", + "wasm-bindgen", +] + +[[package]] +name = "v_frame" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" +dependencies = [ + "aligned-vec", + "num-traits", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "value-bag" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0" +dependencies = [ + "value-bag-serde1", + "value-bag-sval2", +] + +[[package]] +name = "value-bag-serde1" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16530907bfe2999a1773ca5900a65101e092c70f642f25cc23ca0c43573262c5" +dependencies = [ + "erased-serde", + "serde_core", + "serde_fmt", +] + +[[package]] +name = "value-bag-sval2" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d00ae130edd690eaa877e4f40605d534790d1cf1d651e7685bd6a144521b251f" +dependencies = [ + "sval", + "sval_buffer", + "sval_dynamic", + "sval_fmt", + "sval_json", + "sval_ref", + "sval_serde", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "waker-fn" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "317211a0dc0ceedd78fb2ca9a44aed3d7b9b26f81870d485c07122b4350673b7" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.0", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "wayland-backend" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa75f400b7f719bcd68b3f47cd939ba654cedeef690f486db71331eec4c6a406" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.4", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab51d9f7c071abeee76007e2b742499e535148035bb835f97aaed1338cf516c3" +dependencies = [ + "bitflags 2.11.0", + "rustix 1.1.4", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-cursor" +version = "0.31.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b3298683470fbdc6ca40151dfc48c8f2fd4c41a26e13042f801f85002384091" +dependencies = [ + "rustix 1.1.4", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b23b5df31ceff1328f06ac607591d5ba360cf58f90c8fad4ac8d3a55a3c4aec7" +dependencies = [ + "bitflags 2.11.0", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d392fc283a87774afc9beefcd6f931582bb97fe0e6ced0b306a62cb1d026527c" +dependencies = [ + "bitflags 2.11.0", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78248e4cc0eff8163370ba5c158630dcae1f3497a586b826eca2ef5f348d6235" +dependencies = [ + "bitflags 2.11.0", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c86287151a309799b821ca709b7345a048a2956af05957c89cb824ab919fa4e3" +dependencies = [ + "proc-macro2", + "quick-xml 0.39.2", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "374f6b70e8e0d6bf9461a32988fd553b59ff630964924dad6e4a4eb6bd538d17" +dependencies = [ + "dlib", + "log", + "once_cell", + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75c7f0ef91146ebfb530314f5f1d24528d7f0767efbfd31dce919275413e393e" +dependencies = [ + "webpki-root-certs 1.0.6", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "which" +version = "6.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ee928febd44d98f2f459a4a79bd4d928591333a494a10a868418ac1b39cf1f" +dependencies = [ + "either", + "home", + "rustix 0.38.44", + "winsafe", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143" +dependencies = [ + "windows-core 0.57.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-capture" +version = "1.4.3" +source = "git+https://github.com/zed-industries/windows-capture.git?rev=f0d6c1b6691db75461b732f6d5ff56eed002eeb9#f0d6c1b6691db75461b732f6d5ff56eed002eeb9" +dependencies = [ + "clap", + "ctrlc", + "parking_lot", + "rayon", + "thiserror 2.0.18", + "windows 0.61.3", + "windows-future", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d" +dependencies = [ + "windows-implement 0.57.0", + "windows-interface 0.57.0", + "windows-result 0.1.2", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-registry" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4286ad90ddb45071efd1a66dfa43eb02dd0dfbae1545ad6cc3c51cf34d7e8ba3" +dependencies = [ + "windows-result 0.3.4", + "windows-strings 0.3.1", + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-registry" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" +dependencies = [ + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87fa48cc5d406560701792be122a10132491cff9d0aeb23583cc2dcafc847319" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + +[[package]] +name = "winsafe" +version = "0.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" + +[[package]] +name = "wio" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d129932f4644ac2396cb456385cbf9e63b5b30c6e8dc4820bdca4eb082037a5" +dependencies = [ + "winapi", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck 0.5.0", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck 0.5.0", + "indexmap", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.0", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-clipboard" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "662d74b3d77e396b8e5beb00b9cad6a9eccf40b2ef68cc858784b14c41d535a3" +dependencies = [ + "libc", + "x11rb", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "as-raw-xcb-connection", + "gethostname", + "libc", + "rustix 1.1.4", + "x11rb-protocol", + "xcursor", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xattr" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d1526bbe5aaeb5eb06885f4d987bcdfa5e23187055de9b83fe00156a821fabc" +dependencies = [ + "libc", +] + +[[package]] +name = "xcb" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4c580d8205abb0a5cf4eb7e927bd664e425b6c3263f9c5310583da96970cf6" +dependencies = [ + "bitflags 1.3.2", + "libc", + "quick-xml 0.30.0", + "x11", +] + +[[package]] +name = "xcursor" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b" + +[[package]] +name = "xim-ctext" +version = "0.3.0" +source = "git+https://github.com/zed-industries/xim-rs.git?rev=16f35a2c881b815a2b6cdfd6687988e84f8447d8#16f35a2c881b815a2b6cdfd6687988e84f8447d8" +dependencies = [ + "encoding_rs", +] + +[[package]] +name = "xim-parser" +version = "0.2.1" +source = "git+https://github.com/zed-industries/xim-rs.git?rev=16f35a2c881b815a2b6cdfd6687988e84f8447d8#16f35a2c881b815a2b6cdfd6687988e84f8447d8" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "xkbcommon" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d66ca9352cbd4eecbbc40871d8a11b4ac8107cfc528a6e14d7c19c69d0e1ac9" +dependencies = [ + "as-raw-xcb-connection", + "libc", + "memmap2", + "xkeysym", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "xmlwriter" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" + +[[package]] +name = "y4m" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + +[[package]] +name = "yazi" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01738255b5a16e78bbb83e7fbba0a1e7dd506905cfc53f4622d89015a03fbb5" + +[[package]] +name = "yeslogic-fontconfig-sys" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "503a066b4c037c440169d995b869046827dbc71263f6e8f3be6d77d4f3229dbd" +dependencies = [ + "dlib", + "once_cell", + "pkg-config", +] + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zbus" +version = "5.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca82f95dbd3943a40a53cfded6c2d0a2ca26192011846a1810c4256ef92c60bc" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener 5.4.1", + "futures-core", + "futures-lite 2.6.1", + "hex", + "libc", + "ordered-stream", + "rustix 1.1.4", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 0.7.15", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897e79616e84aac4b2c46e9132a4f63b93105d54fe8c0e8f6bffc21fa8d49222" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" +dependencies = [ + "serde", + "winnow 0.7.15", + "zvariant", +] + +[[package]] +name = "zed-font-kit" +version = "0.14.1-zed" +source = "git+https://github.com/zed-industries/font-kit?rev=110523127440aefb11ce0cf280ae7c5071337ec5#110523127440aefb11ce0cf280ae7c5071337ec5" +dependencies = [ + "bitflags 2.11.0", + "byteorder", + "core-foundation 0.10.0", + "core-graphics 0.24.0", + "core-text", + "dirs 5.0.1", + "dwrote", + "float-ord", + "freetype-sys", + "lazy_static", + "libc", + "log", + "pathfinder_geometry", + "pathfinder_simd", + "walkdir", + "winapi", + "yeslogic-fontconfig-sys", +] + +[[package]] +name = "zed-reqwest" +version = "0.12.15-zed" +source = "git+https://github.com/zed-industries/reqwest.git?rev=c15662463bda39148ba154100dd44d3fba5873a4#c15662463bda39148ba154100dd44d3fba5873a4" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "ipnet", + "js-sys", + "log", + "mime", + "mime_guess", + "once_cell", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-native-certs", + "rustls-pemfile", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "system-configuration", + "tokio", + "tokio-rustls", + "tokio-socks", + "tokio-util", + "tower", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "windows-registry 0.4.0", +] + +[[package]] +name = "zed-scap" +version = "0.0.8-zed" +source = "git+https://github.com/zed-industries/scap?rev=4afea48c3b002197176fb19cd0f9b180dd36eaac#4afea48c3b002197176fb19cd0f9b180dd36eaac" +dependencies = [ + "anyhow", + "cocoa 0.25.0", + "core-graphics-helmer-fork", + "log", + "objc", + "rand 0.8.5", + "screencapturekit", + "screencapturekit-sys", + "sysinfo", + "tao-core-video-sys", + "windows 0.61.3", + "windows-capture", + "x11", + "xcb", +] + +[[package]] +name = "zed-xim" +version = "0.4.0-zed" +source = "git+https://github.com/zed-industries/xim-rs.git?rev=16f35a2c881b815a2b6cdfd6687988e84f8447d8#16f35a2c881b815a2b6cdfd6687988e84f8447d8" +dependencies = [ + "ahash", + "hashbrown 0.14.5", + "log", + "x11rb", + "xim-ctext", + "xim-parser", +] + +[[package]] +name = "zeno" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524" + +[[package]] +name = "zerocopy" +version = "0.8.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zlog" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed?rev=8b5328cad3d9ca3576296be574a1c7bfbcaf3557#8b5328cad3d9ca3576296be574a1c7bfbcaf3557" +dependencies = [ + "anyhow", + "chrono", + "collections", + "log", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "ztracing" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed?rev=8b5328cad3d9ca3576296be574a1c7bfbcaf3557#8b5328cad3d9ca3576296be574a1c7bfbcaf3557" +dependencies = [ + "tracing", + "tracing-subscriber", + "zlog", + "ztracing_macro", +] + +[[package]] +name = "ztracing_macro" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed?rev=8b5328cad3d9ca3576296be574a1c7bfbcaf3557#8b5328cad3d9ca3576296be574a1c7bfbcaf3557" + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "zune-jpeg" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7a1c0af6e5d8d1363f4994b7a091ccf963d8b694f7da5b0b9cceb82da2c0a6" +dependencies = [ + "zune-core", +] + +[[package]] +name = "zvariant" +version = "5.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5708299b21903bbe348e94729f22c49c55d04720a004aa350f1f9c122fd2540b" +dependencies = [ + "endi", + "enumflags2", + "serde", + "url", + "winnow 0.7.15", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b59b012ebe9c46656f9cc08d8da8b4c726510aef12559da3e5f1bf72780752c" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 2.0.117", + "winnow 0.7.15", +] + +[[patch.unused]] +name = "notify" +version = "8.2.0" +source = "git+https://github.com/zed-industries/notify.git?rev=6c550ac3c56cbd143c57ea6390e197af9d790908#6c550ac3c56cbd143c57ea6390e197af9d790908" + +[[patch.unused]] +name = "notify-types" +version = "2.0.0" +source = "git+https://github.com/zed-industries/notify.git?rev=6c550ac3c56cbd143c57ea6390e197af9d790908#6c550ac3c56cbd143c57ea6390e197af9d790908"