diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 3813beea5..b737e6572 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -80,6 +80,9 @@ crate-type = ["staticlib", "cdylib", "rlib"] [build-dependencies] tauri-build = { version = "2", features = [] } +[dev-dependencies] +tokio = { version = "1", features = ["test-util"] } + [dependencies] nyaterm-otp = { path = "crates/otp" } tauri = { version = "2", features = ["tray-icon", "protocol-asset"] } diff --git a/src-tauri/src/cmd/app.rs b/src-tauri/src/cmd/app.rs index 59a1d7ee7..5bd2ef23c 100644 --- a/src-tauri/src/cmd/app.rs +++ b/src-tauri/src/cmd/app.rs @@ -344,6 +344,11 @@ pub async fn open_child_window( .inner_size(width, height) .maximized(maximized) .visible(false) + // On macOS, parent/addChildWindow can add the child to the parent hierarchy during + // creation; keep it unfocusable until the ready handshake to prevent the native window + // from stealing focus before the page is rendered. + .focusable(false) + .focused(false) .decorations(cfg!(target_os = "macos")) .resizable(resizable) .always_on_top(options.always_on_top.unwrap_or(false)); @@ -352,6 +357,9 @@ pub async fn open_child_window( { builder = builder .title_bar_style(tauri::TitleBarStyle::Overlay) + // Position the traffic light controls in logical points so the 12px native buttons + // sit visually centered in the 40px custom header. + .traffic_light_position(tauri::LogicalPosition::new(12.0, 18.0)) .hidden_title(true); } @@ -376,6 +384,13 @@ pub async fn open_child_window( .build() .map_err(|error| AppError::Config(error.to_string()))?; + // macOS addChildWindow:ordered: can bypass builder.visible(false) and place the window + // above its parent. Order it out immediately after build so the WebView's first frame and + // page ready handshake complete before an empty window is exposed; revealChildWindow + // restores focusability before showing it. + let _ = window.hide(); + let _ = window.set_focusable(false); + if let Some(placement) = placement { if window .set_position(crate::window_state::placement_to_position(placement)) diff --git a/src-tauri/src/cmd/local_fs.rs b/src-tauri/src/cmd/local_fs.rs index 81324e8ce..3cc24bf0f 100644 --- a/src-tauri/src/cmd/local_fs.rs +++ b/src-tauri/src/cmd/local_fs.rs @@ -1,3 +1,4 @@ +use crate::core::sftp::util::content_hash; use crate::core::sftp::{ DirectoryChild, FileEntry, FileProperties, RemoteBinaryFile, RemoteTextFile, TextFileOpenResult, WriteRemoteTextResult, classify_text_file, @@ -269,6 +270,7 @@ async fn read_local_file_text_impl(path: &str, max_bytes: u64) -> AppResult AppResult AppResult, expected_size: Option, + expected_mtime_nanos: Option, + expected_hash: Option, force: Option, ) -> AppResult { ensure_local_session(state.inner(), &session_id).await?; @@ -329,6 +336,8 @@ pub async fn write_local_file_text( &content, expected_mtime, expected_size, + expected_mtime_nanos.as_deref(), + expected_hash.as_deref(), force.unwrap_or(false), ) .await @@ -339,16 +348,36 @@ async fn write_local_file_text_impl( content: &str, expected_mtime: Option, expected_size: Option, + expected_mtime_nanos: Option<&str>, + expected_hash: Option<&str>, force: bool, ) -> AppResult { let metadata = tokio::fs::metadata(&path).await?; let current_mtime = modified_time_secs(&metadata); + let current_mtime_nanos = modified_time_nanos(&metadata); let current_size = metadata.len(); let has_conflict = expected_mtime.is_some_and(|mtime| mtime != current_mtime) - || expected_size.is_some_and(|size| size != current_size); + || expected_size.is_some_and(|size| size != current_size) + || expected_mtime_nanos.is_some_and(|mtime| Some(mtime) != current_mtime_nanos.as_deref()); - if has_conflict && !force { - return Ok(WriteRemoteTextResult::conflict(current_mtime, current_size)); + let has_hash_conflict = if !force && !has_conflict { + match expected_hash { + Some(expected_hash) => { + let current_bytes = tokio::fs::read(&path).await?; + content_hash(¤t_bytes) != expected_hash + } + None => false, + } + } else { + false + }; + + if (has_conflict || has_hash_conflict) && !force { + return Ok(WriteRemoteTextResult::conflict( + current_mtime, + current_size, + current_mtime_nanos, + )); } tokio::fs::write(&path, content).await?; @@ -356,6 +385,8 @@ async fn write_local_file_text_impl( Ok(WriteRemoteTextResult::saved( modified_time_secs(&next_metadata), next_metadata.len(), + modified_time_nanos(&next_metadata), + content_hash(content.as_bytes()), )) } @@ -433,6 +464,14 @@ fn modified_time_secs(metadata: &std::fs::Metadata) -> u64 { system_time_secs(metadata.modified()) } +fn modified_time_nanos(metadata: &std::fs::Metadata) -> Option { + metadata + .modified() + .ok() + .and_then(|value| value.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|duration| duration.as_nanos().to_string()) +} + fn accessed_time_secs(metadata: &std::fs::Metadata) -> u64 { system_time_secs(metadata.accessed()) } @@ -583,21 +622,79 @@ mod tests { rename_local_file_impl(original.to_str().unwrap(), renamed.to_str().unwrap()) .await .unwrap(); - write_local_file_text_impl(renamed.to_str().unwrap(), "hello", None, None, false) - .await - .unwrap(); + write_local_file_text_impl( + renamed.to_str().unwrap(), + "hello", + None, + None, + None, + None, + false, + ) + .await + .unwrap(); let text = read_local_file_text_impl(renamed.to_str().unwrap(), 1024) .await .unwrap(); assert_eq!(text.content, "hello"); assert_eq!(text.size, 5); + assert_eq!(text.content_hash, content_hash(b"hello")); + + let saved = write_local_file_text_impl( + renamed.to_str().unwrap(), + "hello!", + Some(text.mtime), + Some(text.size), + text.mtime_nanos.as_deref(), + Some(&text.content_hash), + false, + ) + .await + .unwrap(); + assert_eq!(saved.status, "saved"); + assert_eq!( + saved.content_hash.as_deref(), + Some(content_hash(b"hello!").as_str()) + ); + + let text = read_local_file_text_impl(renamed.to_str().unwrap(), 1024) + .await + .unwrap(); + + let conflict = write_local_file_text_impl( + renamed.to_str().unwrap(), + "changed", + Some(text.mtime + 1), + Some(text.size), + text.mtime_nanos.as_deref(), + Some(&text.content_hash), + false, + ) + .await + .unwrap(); + assert_eq!(conflict.status, "conflict"); let conflict = write_local_file_text_impl( renamed.to_str().unwrap(), "changed", Some(text.mtime), Some(text.size + 1), + text.mtime_nanos.as_deref(), + Some(&text.content_hash), + false, + ) + .await + .unwrap(); + assert_eq!(conflict.status, "conflict"); + + let conflict = write_local_file_text_impl( + renamed.to_str().unwrap(), + "changed", + Some(text.mtime), + Some(text.size), + text.mtime_nanos.as_deref(), + Some("definitely-not-the-current-hash"), false, ) .await @@ -609,11 +706,17 @@ mod tests { "changed", Some(text.mtime), Some(text.size + 1), + text.mtime_nanos.as_deref(), + Some("definitely-not-the-current-hash"), true, ) .await .unwrap(); assert_eq!(forced.status, "saved"); + assert_eq!( + forced.content_hash.as_deref(), + Some(content_hash(b"changed").as_str()) + ); delete_local_file_impl(root.to_str().unwrap()) .await diff --git a/src-tauri/src/cmd/sftp.rs b/src-tauri/src/cmd/sftp.rs index c98fa83c6..c88319b18 100644 --- a/src-tauri/src/cmd/sftp.rs +++ b/src-tauri/src/cmd/sftp.rs @@ -183,6 +183,7 @@ pub async fn write_remote_file_text( content: String, expected_mtime: Option, expected_size: Option, + expected_hash: Option, force: Option, ) -> AppResult { sftp::write_remote_file_text( @@ -192,6 +193,7 @@ pub async fn write_remote_file_text( &content, expected_mtime, expected_size, + expected_hash.as_deref(), force.unwrap_or(false), ) .await diff --git a/src-tauri/src/core/output.rs b/src-tauri/src/core/output.rs index 61a9d7d7d..a0f3fae36 100644 --- a/src-tauri/src/core/output.rs +++ b/src-tauri/src/core/output.rs @@ -470,7 +470,7 @@ mod tests { use crate::core::SessionCommand; use std::sync::{Arc, Mutex}; use tokio::sync::mpsc; - use tokio::time::{Duration, sleep}; + use tokio::time::{Duration, Instant, advance, sleep}; fn collect_sink() -> ( Arc>>, @@ -524,6 +524,51 @@ mod tests { assert_eq!(emitted[0].bytes, "hello world".len()); } + #[tokio::test(start_paused = true)] + async fn one_millisecond_tiny_bursts_are_coalesced_without_delaying_first_flush() { + let emitted = Arc::new(Mutex::new(Vec::::new())); + let first_emit_at = Arc::new(Mutex::new(None::)); + let started_at = Instant::now(); + let emitted_sink = emitted.clone(); + let first_emit_sink = first_emit_at.clone(); + let output = SessionOutputCoalescer::with_sink(move |payload| { + let mut first = first_emit_sink.lock().unwrap(); + if first.is_none() { + *first = Some(Instant::now().duration_since(started_at)); + } + emitted_sink.lock().unwrap().push(payload); + }); + + output.attach(); + + let mut expected = String::new(); + for index in 0..1000 { + let chunk = format!("{index:04};"); + expected.push_str(&chunk); + output.push_owned(chunk); + advance(Duration::from_millis(1)).await; + } + advance(Duration::from_millis(20)).await; + + let emitted = emitted.lock().unwrap(); + let actual = emitted + .iter() + .map(|payload| payload.data.as_str()) + .collect::(); + let first_emit_at = first_emit_at.lock().unwrap().expect("first emit"); + + assert_eq!(actual, expected); + assert!( + emitted.len() < 350, + "expected coalesced events, got {} events", + emitted.len() + ); + assert!( + first_emit_at <= Duration::from_millis(8), + "first emit took {first_emit_at:?}" + ); + } + #[tokio::test] async fn size_threshold_flushes_immediately() { let (emitted, sink) = collect_sink(); diff --git a/src-tauri/src/core/sftp/mod.rs b/src-tauri/src/core/sftp/mod.rs index 2f02319fc..d02bfdfef 100644 --- a/src-tauri/src/core/sftp/mod.rs +++ b/src-tauri/src/core/sftp/mod.rs @@ -2230,13 +2230,21 @@ pub async fn write_remote_file_text( content: &str, expected_mtime: Option, expected_size: Option, + expected_hash: Option<&str>, force: bool, ) -> AppResult { let auto_fs = get_or_create_auto_fs(&manager, session_id).await?; let guard = auto_fs.backend().await?; let fs = guard.as_ref().unwrap(); - fs.write_file_text(path, content, expected_mtime, expected_size, force) - .await + fs.write_file_text( + path, + content, + expected_mtime, + expected_size, + expected_hash, + force, + ) + .await } pub async fn create_remote_file( diff --git a/src-tauri/src/core/sftp/scp_enhanced.rs b/src-tauri/src/core/sftp/scp_enhanced.rs index fbac4f76a..dc4fcc013 100644 --- a/src-tauri/src/core/sftp/scp_enhanced.rs +++ b/src-tauri/src/core/sftp/scp_enhanced.rs @@ -874,7 +874,8 @@ impl RemoteFs for ScpEnhancedBackend { let output = self.exec_ok(&cmd).await?; ensure_text_bytes(&output, max_bytes)?; - let content = String::from_utf8(output.clone()) + let file_hash = content_hash(&output); + let content = String::from_utf8(output) .map_err(|_| AppError::Config("Only UTF-8 text files are supported".to_string()))?; Ok(RemoteTextFile { @@ -882,6 +883,8 @@ impl RemoteFs for ScpEnhancedBackend { content, size: props.size, mtime: props.mtime, + mtime_nanos: None, + content_hash: file_hash, }) } @@ -907,6 +910,7 @@ impl RemoteFs for ScpEnhancedBackend { content_bytes: bytes, size: props.size, mtime: props.mtime, + mtime_nanos: None, }) } @@ -916,6 +920,7 @@ impl RemoteFs for ScpEnhancedBackend { content: &str, expected_mtime: Option, expected_size: Option, + expected_hash: Option<&str>, force: bool, ) -> AppResult { let props = self.stat(path).await?; @@ -923,7 +928,24 @@ impl RemoteFs for ScpEnhancedBackend { && (expected_mtime.is_some_and(|mtime| mtime != props.mtime) || expected_size.is_some_and(|size| size != props.size)) { - return Ok(WriteRemoteTextResult::conflict(props.mtime, props.size)); + return Ok(WriteRemoteTextResult::conflict( + props.mtime, + props.size, + None, + )); + } + if !force { + if let Some(expected_hash) = expected_hash { + let cmd = format!("head -c {} -- {}", props.size, sh_quote(path)); + let current_bytes = self.exec_ok(&cmd).await?; + if content_hash(¤t_bytes) != expected_hash { + return Ok(WriteRemoteTextResult::conflict( + props.mtime, + props.size, + None, + )); + } + } } let tmp = format!( @@ -947,7 +969,12 @@ impl RemoteFs for ScpEnhancedBackend { ))); } let props = self.stat(path).await?; - Ok(WriteRemoteTextResult::saved(props.mtime, props.size)) + Ok(WriteRemoteTextResult::saved( + props.mtime, + props.size, + None, + content_hash(content.as_bytes()), + )) } async fn download_file( diff --git a/src-tauri/src/core/sftp/scp_normal.rs b/src-tauri/src/core/sftp/scp_normal.rs index 7fefab69a..194c16620 100644 --- a/src-tauri/src/core/sftp/scp_normal.rs +++ b/src-tauri/src/core/sftp/scp_normal.rs @@ -1029,6 +1029,7 @@ impl RemoteFs for ScpNormalBackend { let bytes = result.stdout; ensure_text_bytes(&bytes, max_bytes)?; + let file_hash = content_hash(&bytes); let content = String::from_utf8(bytes) .map_err(|_| AppError::Config("Only UTF-8 text files are supported".to_string()))?; @@ -1037,6 +1038,8 @@ impl RemoteFs for ScpNormalBackend { content, size: props.size, mtime: props.mtime, + mtime_nanos: None, + content_hash: file_hash, }) } @@ -1074,6 +1077,7 @@ impl RemoteFs for ScpNormalBackend { content_bytes: result.stdout, size: props.size, mtime: props.mtime, + mtime_nanos: None, }) } @@ -1083,6 +1087,7 @@ impl RemoteFs for ScpNormalBackend { content: &str, expected_mtime: Option, expected_size: Option, + expected_hash: Option<&str>, force: bool, ) -> AppResult { let props = self.stat(path).await?; @@ -1090,7 +1095,35 @@ impl RemoteFs for ScpNormalBackend { && (expected_mtime.is_some_and(|mtime| props.mtime != 0 && mtime != props.mtime) || expected_size.is_some_and(|size| size != props.size)) { - return Ok(WriteRemoteTextResult::conflict(props.mtime, props.size)); + return Ok(WriteRemoteTextResult::conflict( + props.mtime, + props.size, + None, + )); + } + if !force { + if let Some(expected_hash) = expected_hash { + let cmd = format!( + "dd bs=1 count={} if={} 2>/dev/null", + props.size, + sh_quote(path) + ); + let result = self.exec(&cmd).await?; + if result.exit_code != Some(0) && result.stdout.is_empty() { + let stderr_text = String::from_utf8_lossy(&result.stderr); + return Err(AppError::Channel(format!( + "Failed to read file: {}", + stderr_text.trim() + ))); + } + if content_hash(&result.stdout) != expected_hash { + return Ok(WriteRemoteTextResult::conflict( + props.mtime, + props.size, + None, + )); + } + } } let tmp = format!( @@ -1114,7 +1147,12 @@ impl RemoteFs for ScpNormalBackend { ))); } let props = self.stat(path).await?; - Ok(WriteRemoteTextResult::saved(props.mtime, props.size)) + Ok(WriteRemoteTextResult::saved( + props.mtime, + props.size, + None, + content_hash(content.as_bytes()), + )) } async fn download_file( diff --git a/src-tauri/src/core/sftp/sftp_backend/fs.rs b/src-tauri/src/core/sftp/sftp_backend/fs.rs index 85497f435..1abcd202f 100644 --- a/src-tauri/src/core/sftp/sftp_backend/fs.rs +++ b/src-tauri/src/core/sftp/sftp_backend/fs.rs @@ -426,6 +426,7 @@ impl RemoteFs for SftpBackend { let _ = sftp.close().await; ensure_text_bytes(&bytes, max_bytes)?; + let file_hash = content_hash(&bytes); let content = String::from_utf8(bytes) .map_err(|_| AppError::Config("Only UTF-8 text files are supported".to_string()))?; @@ -434,6 +435,8 @@ impl RemoteFs for SftpBackend { content, size, mtime, + mtime_nanos: None, + content_hash: file_hash, }) } @@ -474,6 +477,7 @@ impl RemoteFs for SftpBackend { content_bytes: bytes, size, mtime, + mtime_nanos: None, }) } @@ -483,8 +487,10 @@ impl RemoteFs for SftpBackend { content: &str, expected_mtime: Option, expected_size: Option, + expected_hash: Option<&str>, force: bool, ) -> AppResult { + use tokio::io::AsyncReadExt; use tokio::io::AsyncWriteExt; let sftp = self.open_sftp().await?; @@ -504,7 +510,32 @@ impl RemoteFs for SftpBackend { || expected_size.is_some_and(|size| size != current_size) { let _ = sftp.close().await; - return Ok(WriteRemoteTextResult::conflict(current_mtime, current_size)); + return Ok(WriteRemoteTextResult::conflict( + current_mtime, + current_size, + None, + )); + } + + if let Some(expected_hash) = expected_hash { + let mut file = sftp.open(path).await.map_err(|error| { + AppError::Channel(format!("Failed to open remote file: {error}")) + })?; + let mut current_bytes = Vec::with_capacity(current_size as usize); + file.read_to_end(&mut current_bytes) + .await + .map_err(|error| { + AppError::Channel(format!("Failed to read remote file: {error}")) + })?; + drop(file); + if content_hash(¤t_bytes) != expected_hash { + let _ = sftp.close().await; + return Ok(WriteRemoteTextResult::conflict( + current_mtime, + current_size, + None, + )); + } } } let original_permissions = original_attrs @@ -575,6 +606,8 @@ impl RemoteFs for SftpBackend { Ok(WriteRemoteTextResult::saved( u64::from(attrs.mtime.unwrap_or(0)), attrs.size.unwrap_or(content.len() as u64), + None, + content_hash(content.as_bytes()), )) } diff --git a/src-tauri/src/core/sftp/traits.rs b/src-tauri/src/core/sftp/traits.rs index 066940503..9a65ced31 100644 --- a/src-tauri/src/core/sftp/traits.rs +++ b/src-tauri/src/core/sftp/traits.rs @@ -59,6 +59,7 @@ pub(crate) trait RemoteFs: Send + Sync { content: &str, expected_mtime: Option, expected_size: Option, + expected_hash: Option<&str>, force: bool, ) -> AppResult; diff --git a/src-tauri/src/core/sftp/util.rs b/src-tauri/src/core/sftp/util.rs index c09ff44bc..ae0e4438a 100644 --- a/src-tauri/src/core/sftp/util.rs +++ b/src-tauri/src/core/sftp/util.rs @@ -4,6 +4,7 @@ use crate::error::{AppError, AppResult}; use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; pub(crate) const SFTP_FILE_TYPE_MASK: u32 = 0o170000; pub(crate) const POSIX_MODE_MASK: u32 = 0o7777; @@ -95,11 +96,15 @@ pub struct RemoteFileAttributeUpdate { } #[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] pub struct RemoteTextFile { pub path: String, pub content: String, pub size: u64, pub mtime: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub mtime_nanos: Option, + pub content_hash: String, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] @@ -126,9 +131,11 @@ pub fn classify_text_file(file: RemoteBinaryFile) -> TextFileOpenResult { Ok(content) => TextFileOpenResult::Text { file: RemoteTextFile { path: file.path, - content, size: file.size, mtime: file.mtime, + mtime_nanos: file.mtime_nanos, + content_hash: content_hash(content.as_bytes()), + content, }, }, Err(_) => TextFileOpenResult::Unsupported { @@ -144,6 +151,8 @@ pub struct RemoteBinaryFile { pub content_bytes: Vec, pub size: u64, pub mtime: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub mtime_nanos: Option, } #[derive(Debug, Clone, Serialize)] @@ -152,26 +161,40 @@ pub struct WriteRemoteTextResult { pub status: String, pub mtime: Option, pub size: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub mtime_nanos: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub content_hash: Option, } impl WriteRemoteTextResult { - pub fn saved(mtime: u64, size: u64) -> Self { + pub fn saved(mtime: u64, size: u64, mtime_nanos: Option, content_hash: String) -> Self { Self { status: "saved".to_string(), mtime: Some(mtime), size: Some(size), + mtime_nanos, + content_hash: Some(content_hash), } } - pub fn conflict(mtime: u64, size: u64) -> Self { + pub fn conflict(mtime: u64, size: u64, mtime_nanos: Option) -> Self { Self { status: "conflict".to_string(), mtime: Some(mtime), size: Some(size), + mtime_nanos, + content_hash: None, } } } +pub(crate) fn content_hash(bytes: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(bytes); + hex::encode(hasher.finalize()) +} + pub(crate) fn ensure_text_bytes(bytes: &[u8], max_bytes: u64) -> AppResult<()> { if bytes.len() as u64 > max_bytes { return Err(AppError::Config(format!( @@ -459,7 +482,7 @@ fn is_windows_reserved_device_name(name: &str) -> bool { mod tests { use super::{ FileProperties, RemoteBinaryFile, RemotePathRef, TextFileOpenResult, - TextFileUnsupportedReason, classify_text_file, decode_raw_path_token, + TextFileUnsupportedReason, classify_text_file, content_hash, decode_raw_path_token, permissions_string_to_octal_mode, raw_path_token, sanitize_download_file_name_for_platform, scp_finalize_replace_command, }; @@ -470,6 +493,7 @@ mod tests { path: "/tmp/file".to_string(), size: content_bytes.len() as u64, mtime: 1, + mtime_nanos: None, content_bytes, }; assert!(matches!( @@ -484,9 +508,11 @@ mod tests { reason: TextFileUnsupportedReason::UnsupportedEncoding } )); + let opened = classify_text_file(file(b"hello".to_vec())); assert!(matches!( - classify_text_file(file(b"hello".to_vec())), - TextFileOpenResult::Text { file } if file.content == "hello" + opened, + TextFileOpenResult::Text { ref file } + if file.content == "hello" && file.content_hash == content_hash(b"hello") )); } diff --git a/src-tauri/tauri.macos.conf.json b/src-tauri/tauri.macos.conf.json index fff78b48e..a009b901f 100644 --- a/src-tauri/tauri.macos.conf.json +++ b/src-tauri/tauri.macos.conf.json @@ -10,6 +10,10 @@ "visible": false, "decorations": true, "titleBarStyle": "Overlay", + "trafficLightPosition": { + "x": 12, + "y": 18 + }, "hiddenTitle": true, "create": false } diff --git a/src/ChildWindowRouter.tsx b/src/ChildWindowRouter.tsx index 693674cfd..3167d7084 100644 --- a/src/ChildWindowRouter.tsx +++ b/src/ChildWindowRouter.tsx @@ -6,7 +6,6 @@ import { isModalChildLabel, prepareForModalChildClose, setOwnerMainWindowLabel, - signalChildWindowReady, } from "./lib/windowManager"; const SettingsPage = lazy(() => import("./pages/SettingsPage")); @@ -31,16 +30,20 @@ const PAGES: Record = { "note-editor": NoteEditorPage, }; +function ChildWindowLoadingShell() { + return ( +
+ +
+ ); +} + function ReadyContent({ children }: { children: ReactNode }) { - useEffect(() => { - const timeoutId = window.setTimeout(() => { - void signalChildWindowReady(); - }, 0); - - return () => window.clearTimeout(timeoutId); - }, []); - - return children; + return
{children}
; } export default function ChildWindowRouter({ windowType }: { windowType: string }) { @@ -112,10 +115,10 @@ export default function ChildWindowRouter({ windowType }: { windowType: string } } return ( - - + + }> - - + + ); } diff --git a/src/components/app/AppPanelContent.test.tsx b/src/components/app/AppPanelContent.test.tsx index 08151f890..07228d635 100644 --- a/src/components/app/AppPanelContent.test.tsx +++ b/src/components/app/AppPanelContent.test.tsx @@ -71,7 +71,7 @@ describe("AppPanelContent file explorer", () => { file: { backend: "remote", path: "/tmp/notes.txt", - initial: { content: "notes", size: 5, mtime: 1 }, + initial: { content: "notes", size: 5, mtime: 1, contentHash: "hash-notes" }, }, }; diff --git a/src/components/dialog/connections/FolderDialog.test.tsx b/src/components/dialog/connections/FolderDialog.test.tsx new file mode 100644 index 000000000..a1423de0a --- /dev/null +++ b/src/components/dialog/connections/FolderDialog.test.tsx @@ -0,0 +1,37 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import FolderDialog from "./FolderDialog"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +function renderFolderDialog(onSubmit: () => void, open = true) { + return render( + , + ); +} + +describe("FolderDialog", () => { + it("submits only once when Enter and Save happen in the same turn", () => { + const onSubmit = vi.fn(); + renderFolderDialog(onSubmit); + + const input = screen.getByRole("textbox"); + const saveButton = screen.getByRole("button", { name: "dialog.save" }); + + act(() => { + fireEvent.keyDown(input, { key: "Enter" }); + fireEvent.click(saveButton); + }); + + expect(onSubmit).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/dialog/connections/FolderDialog.tsx b/src/components/dialog/connections/FolderDialog.tsx index 9d3fec891..2e5d3cc30 100644 --- a/src/components/dialog/connections/FolderDialog.tsx +++ b/src/components/dialog/connections/FolderDialog.tsx @@ -1,3 +1,4 @@ +import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; import { @@ -28,9 +29,29 @@ export default function FolderDialog({ onCancel, }: FolderDialogProps) { const { t } = useTranslation(); + const [isSubmitting, setIsSubmitting] = useState(false); + const submitInFlightRef = useRef(false); + + useEffect(() => { + if (!open) { + submitInFlightRef.current = false; + setIsSubmitting(false); + } + }, [open]); + + const handleSubmit = () => { + if (!name.trim() || submitInFlightRef.current) return; + submitInFlightRef.current = true; + setIsSubmitting(true); + onSubmit(); + }; return ( - !v && onCancel()}> + !v && !submitInFlightRef.current && onCancel()} + > @@ -46,15 +67,20 @@ export default function FolderDialog({ placeholder={t("savedConnections.folderNamePlaceholder")} value={name} onChange={(e) => onNameChange(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && onSubmit()} + onKeyDown={(e) => { + if (e.key !== "Enter") return; + e.preventDefault(); + handleSubmit(); + }} + disabled={isSubmitting} autoFocus /> - - diff --git a/src/components/layout/ChildWindowHeader.tsx b/src/components/layout/ChildWindowHeader.tsx index 3981d7577..10c848f2b 100644 --- a/src/components/layout/ChildWindowHeader.tsx +++ b/src/components/layout/ChildWindowHeader.tsx @@ -18,6 +18,7 @@ interface ChildWindowHeaderProps { icon?: ReactNode; windowControls?: boolean; alwaysOnTopControl?: boolean; + macOSDragOnly?: boolean; } export default function ChildWindowHeader({ @@ -26,6 +27,7 @@ export default function ChildWindowHeader({ icon, windowControls = false, alwaysOnTopControl = false, + macOSDragOnly = false, }: ChildWindowHeaderProps) { const { t } = useTranslation(); const [appWindow] = useState(() => getCurrentWindow()); @@ -83,18 +85,24 @@ export default function ChildWindowHeader({ setIsAlwaysOnTop(alwaysOnTop); }; + const hideHeaderContent = isMacOS && macOSDragOnly; + return (
-
- {icon ? {icon} : null} - {title} -
+ {hideHeaderContent ? ( +
+ ) : ( +
+ {icon ? {icon} : null} + {title} +
+ )} {!isMacOS && (
diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index 6fd84df30..ea3a82645 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -1799,7 +1799,7 @@ export default function Header({ className="h-10 border-b flex items-center gap-2 px-2 select-none shrink-0" style={{ backgroundColor: "var(--df-bg-panel)", borderColor: "var(--df-border)" }} > -
+
{!isMacOS && ( )} diff --git a/src/components/panel/file-explorer/FileDocumentEditor.test.tsx b/src/components/panel/file-explorer/FileDocumentEditor.test.tsx new file mode 100644 index 000000000..917f13c0c --- /dev/null +++ b/src/components/panel/file-explorer/FileDocumentEditor.test.tsx @@ -0,0 +1,116 @@ +import { act, render, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { getFileDocumentController } from "@/lib/fileDocumentRegistry"; +import { invoke } from "@/lib/invoke"; +import type { FileDocumentPane } from "@/types/global"; +import FileDocumentEditor from "./FileDocumentEditor"; + +vi.mock("@codemirror/state", () => ({ + EditorState: { + create: ({ doc }: { doc: string }) => ({ doc: { length: doc.length } }), + }, +})); + +vi.mock("@codemirror/view", () => ({ + EditorView: class { + static updateListener = { of: (listener: unknown) => listener }; + state: { doc: { length: number } }; + + constructor({ state }: { state: { doc: { length: number } } }) { + this.state = state; + } + + dispatch({ changes }: { changes: { insert: string } }) { + this.state = { doc: { length: changes.insert.length } }; + } + + destroy() {} + + focus() {} + }, +})); + +vi.mock("@/lib/codeMirrorFileView", () => ({ + codeMirrorFileViewExtensions: () => [], +})); + +vi.mock("@/lib/invoke", () => ({ invoke: vi.fn() })); +vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } })); + +function pane(): FileDocumentPane { + return { + id: "pane-file", + kind: "leaf", + paneKind: "file", + sessionId: "session-1", + name: "notes.md", + type: "SSH", + connectionId: "connection-1", + file: { + backend: "remote", + path: "/tmp/notes.md", + initial: { + content: "hello", + size: 5, + mtime: 10, + contentHash: "hash-open", + }, + }, + }; +} + +describe("FileDocumentEditor save baseline", () => { + afterEach(() => { + vi.mocked(invoke).mockReset(); + }); + + it("sends the opened content hash and updates the baseline after save", async () => { + vi.mocked(invoke) + .mockResolvedValueOnce({ + status: "saved", + mtime: 11, + size: 5, + contentHash: "hash-saved", + }) + .mockResolvedValueOnce({ + status: "saved", + mtime: 12, + size: 5, + contentHash: "hash-saved-again", + }); + + render(); + + await waitFor(() => expect(getFileDocumentController("pane-file")).not.toBeNull()); + + await act(async () => { + await getFileDocumentController("pane-file")?.save(); + }); + + expect(invoke).toHaveBeenLastCalledWith("write_remote_file_text", { + sessionId: "session-1", + path: "/tmp/notes.md", + content: "hello", + expectedMtime: 10, + expectedSize: 5, + expectedMtimeNanos: undefined, + expectedHash: "hash-open", + force: false, + }); + + await act(async () => { + await getFileDocumentController("pane-file")?.save(); + }); + + expect(invoke).toHaveBeenLastCalledWith("write_remote_file_text", { + sessionId: "session-1", + path: "/tmp/notes.md", + content: "hello", + expectedMtime: 11, + expectedSize: 5, + expectedMtimeNanos: undefined, + expectedHash: "hash-saved", + force: false, + }); + }); +}); diff --git a/src/components/panel/file-explorer/FileDocumentEditor.tsx b/src/components/panel/file-explorer/FileDocumentEditor.tsx index 8fdd850f9..10058e063 100644 --- a/src/components/panel/file-explorer/FileDocumentEditor.tsx +++ b/src/components/panel/file-explorer/FileDocumentEditor.tsx @@ -9,6 +9,7 @@ import RemoteFileConflictDialog from "@/components/dialog/remote-file-editor/Rem import { Button } from "@/components/ui/button"; import { codeMirrorFileViewExtensions } from "@/lib/codeMirrorFileView"; import { getErrorMessage } from "@/lib/errors"; +import { MAX_EDITOR_FILE_BYTES } from "@/lib/fileEditorLimits"; import { type FileDocumentSaveResult, registerFileDocument, @@ -19,12 +20,12 @@ import { formatSize } from "@/lib/utils"; import type { FileDocumentPane } from "@/types/global"; import { languageFromFilename, type TextFileOpenResult } from "./model"; -const MAX_EDITOR_FILE_BYTES = 5 * 1024 * 1024; - interface WriteFileTextResult { status: "saved" | "conflict"; mtime?: number; + mtimeNanos?: string; size?: number; + contentHash?: string; } interface FileDocumentEditorProps { @@ -38,11 +39,14 @@ export default function FileDocumentEditor({ pane, active }: FileDocumentEditorP const viewRef = useRef(null); const suppressUpdateRef = useRef(false); const savingRef = useRef(false); + const savePromiseRef = useRef | null>(null); const contentRef = useRef(pane.file.initial.content); const baseRef = useRef({ content: pane.file.initial.content, size: pane.file.initial.size, mtime: pane.file.initial.mtime, + mtimeNanos: pane.file.initial.mtimeNanos, + contentHash: pane.file.initial.contentHash, }); const [dirty, setDirty] = useState(false); const [saving, setSaving] = useState(false); @@ -75,47 +79,59 @@ export default function FileDocumentEditor({ pane, active }: FileDocumentEditorP const save = useCallback( async (force = false): Promise => { - if (savingRef.current) return "conflict"; - savingRef.current = true; - setSaving(true); - setError(""); - try { - const result = await invoke( - pane.file.backend === "local" ? "write_local_file_text" : "write_remote_file_text", - { - sessionId: pane.sessionId, - path: pane.file.path, - content: contentRef.current, - expectedMtime: baseRef.current.mtime, - expectedSize: baseRef.current.size, - force, - }, - ); - if (result.status === "conflict") { - setConflictOpen(true); - return "conflict"; - } + if (savingRef.current) return savePromiseRef.current ?? "saved"; + const runSave = async (): Promise => { + savingRef.current = true; + setSaving(true); + setError(""); + try { + const result = await invoke( + pane.file.backend === "local" ? "write_local_file_text" : "write_remote_file_text", + { + sessionId: pane.sessionId, + path: pane.file.path, + content: contentRef.current, + expectedMtime: baseRef.current.mtime, + expectedSize: baseRef.current.size, + expectedMtimeNanos: baseRef.current.mtimeNanos, + expectedHash: baseRef.current.contentHash, + force, + }, + ); + if (result.status === "conflict") { + setConflictOpen(true); + return "conflict"; + } - const nextSize = result.size ?? new Blob([contentRef.current]).size; - const nextMtime = result.mtime ?? baseRef.current.mtime; - baseRef.current = { - content: contentRef.current, - size: nextSize, - mtime: nextMtime, - }; - setSize(nextSize); - setMtime(nextMtime); - setDirty(false); - setLastSavedAt(Date.now()); - toast.success(t("fileEditor.saved")); - return "saved"; - } catch (saveError) { - setError(getErrorMessage(saveError) || t("fileEditor.saveFailed")); - return "conflict"; - } finally { - savingRef.current = false; - setSaving(false); - } + const nextSize = result.size ?? new Blob([contentRef.current]).size; + const nextMtime = result.mtime ?? baseRef.current.mtime; + const nextMtimeNanos = result.mtimeNanos ?? baseRef.current.mtimeNanos; + const nextContentHash = result.contentHash ?? baseRef.current.contentHash; + baseRef.current = { + content: contentRef.current, + size: nextSize, + mtime: nextMtime, + mtimeNanos: nextMtimeNanos, + contentHash: nextContentHash, + }; + setSize(nextSize); + setMtime(nextMtime); + setDirty(false); + setLastSavedAt(Date.now()); + toast.success(t("fileEditor.saved")); + return "saved"; + } catch (saveError) { + setError(getErrorMessage(saveError) || t("fileEditor.saveFailed")); + return "conflict"; + } finally { + savingRef.current = false; + savePromiseRef.current = null; + setSaving(false); + } + }; + const promise = runSave(); + savePromiseRef.current = promise; + return promise; }, [pane.file.backend, pane.file.path, pane.sessionId, t], ); @@ -146,6 +162,8 @@ export default function FileDocumentEditor({ pane, active }: FileDocumentEditorP content: result.file.content, size: result.file.size, mtime: result.file.mtime ?? baseRef.current.mtime, + mtimeNanos: result.file.mtimeNanos, + contentHash: result.file.contentHash, }; replaceEditorContent(result.file.content); setSize(result.file.size); diff --git a/src/components/panel/file-explorer/FileExplorer.tsx b/src/components/panel/file-explorer/FileExplorer.tsx index ae2403771..7bf620131 100644 --- a/src/components/panel/file-explorer/FileExplorer.tsx +++ b/src/components/panel/file-explorer/FileExplorer.tsx @@ -76,6 +76,7 @@ import { useTransfer } from "@/context/TransferContext"; import { resolveShortcutKeys } from "@/hooks/useShortcutMap"; import { openAIAssistant } from "@/lib/aiEvents"; import { getErrorMessage } from "@/lib/errors"; +import { MAX_EDITOR_FILE_BYTES } from "@/lib/fileEditorLimits"; import { invoke } from "@/lib/invoke"; import { logger } from "@/lib/logger"; import { sendSessionInput, sendSessionInputWithSync } from "@/lib/sessionInput"; @@ -136,8 +137,6 @@ import { } from "./model"; import { useExternalFileDrop } from "./useExternalFileDrop"; -const MAX_EDITOR_FILE_BYTES = 5 * 1024 * 1024; - const MemoizedFileExplorer = memo(FileExplorer); export default MemoizedFileExplorer; @@ -2778,6 +2777,8 @@ function FileExplorerPane({ content: result.file.content, size: result.file.size, mtime: result.file.mtime ?? entry.mtime, + mtimeNanos: result.file.mtimeNanos, + contentHash: result.file.contentHash, }, }); } catch (error) { diff --git a/src/components/panel/file-explorer/FilePreviewContent.test.tsx b/src/components/panel/file-explorer/FilePreviewContent.test.tsx index 8b99f22f2..cff08ec93 100644 --- a/src/components/panel/file-explorer/FilePreviewContent.test.tsx +++ b/src/components/panel/file-explorer/FilePreviewContent.test.tsx @@ -32,7 +32,7 @@ describe("FilePreviewContent modes", () => { file: { backend: "remote", path: `/tmp/${name}`, - initial: { content, size: content.length, mtime: 1 }, + initial: { content, size: content.length, mtime: 1, contentHash: `hash:${name}` }, }, }; @@ -48,6 +48,7 @@ describe("FilePreviewContent modes", () => { content: "# Preview heading", size: 17, mtime: 1, + contentHash: "hash-preview", }); render( diff --git a/src/components/panel/file-explorer/model.ts b/src/components/panel/file-explorer/model.ts index 48d6a1e20..f72a24680 100644 --- a/src/components/panel/file-explorer/model.ts +++ b/src/components/panel/file-explorer/model.ts @@ -10,6 +10,8 @@ export interface RemoteTextFile { content: string; size: number; mtime?: number; + mtimeNanos?: string; + contentHash: string; } export type TextFileOpenResult = @@ -21,6 +23,7 @@ export interface RemoteBinaryFile { contentBytes: number[] | Uint8Array | ArrayBuffer; size: number; mtime?: number; + mtimeNanos?: string; } export type FileExplorerSessionCache = { diff --git a/src/components/terminal/XTerminal.tsx b/src/components/terminal/XTerminal.tsx index a53593b26..700441a32 100644 --- a/src/components/terminal/XTerminal.tsx +++ b/src/components/terminal/XTerminal.tsx @@ -112,6 +112,12 @@ import { TerminalOutputDrain, type TerminalOutputDrainMode, } from "./terminalOutputDrain"; +import { AlternateScreenStateTracker } from "./alternateScreenStateTracker"; +import { + Dec2026FrameGate, + resolveDec2026FrameGateMode, +} from "./dec2026FrameGate"; +import { TerminalOutputScheduler } from "./terminalOutputScheduling"; import { useTerminalExternalDrop } from "./useTerminalExternalDrop"; import { useTerminalRefreshEffects } from "./useTerminalRefreshEffects"; import { @@ -348,6 +354,7 @@ export default function XTerminal({ beforeLine: number; ts: number; }> | null>(null); + const frameGateRef = useRef(null); const lineTimestampsRef = useRef>(new Map()); const gutterLineOffsetRef = useRef(0); const sessionTypeRef = useRef(sessionType); @@ -361,7 +368,7 @@ export default function XTerminal({ const visibleRef = useRef(visible); const activeRef = useRef(active); const performanceModeRef = useRef("normal"); - const lastAlternateScreenWriteAtRef = useRef(0); + const alternateScreenTrackerRef = useRef(new AlternateScreenStateTracker()); const handleVisibilityChangeRef = useRef<(() => void) | null>(null); const replaceInputCommandRef = useRef<((command: string) => void) | null>( null, @@ -799,9 +806,14 @@ export default function XTerminal({ setTerminalReady(false); lineTimestampsRef.current = new Map(); gutterLineOffsetRef.current = 0; + frameGateRef.current?.dispose({ + ackRemaining: true, + reason: "terminal_rebuild", + }); + frameGateRef.current = null; outputDrainRef.current?.dispose({ ackRemaining: true }); outputDrainRef.current = null; - lastAlternateScreenWriteAtRef.current = 0; + alternateScreenTrackerRef.current.reset(); disconnectedRef.current = false; disconnectedNoticeShownRef.current = false; disconnectedCloseRequestedRef.current = false; @@ -856,6 +868,9 @@ export default function XTerminal({ ); const serializeAddon = new SerializeAddon(); const unicodeGraphemesAddon = new UnicodeGraphemesAddon(); + let writeOrderedTerminalStatus = (data: string) => { + terminal.write(data); + }; const zmodemHandler = createZmodemEventHandler( terminal, sessionId, @@ -867,6 +882,9 @@ export default function XTerminal({ complete: completeExternalTransfer, fail: failExternalTransfer, }, + (data) => { + writeOrderedTerminalStatus(data); + }, ); terminal.options.linkHandler = oscLinkHandler; @@ -1969,6 +1987,9 @@ export default function XTerminal({ ); const writeParsedDisposable = terminal.onWriteParsed(() => { + alternateScreenTrackerRef.current.setXtermBufferType( + terminal.buffer.active.type, + ); if (terminal.buffer.active.type === "alternate") { dismissSuggestions(); } @@ -2145,20 +2166,17 @@ export default function XTerminal({ }; const isAlternateScreenActive = () => - terminal.buffer.active.type === "alternate"; + terminal.buffer.active.type === "alternate" || + alternateScreenTrackerRef.current.isAlternateScreenActive(); - const getWriteChunkBytes = () => - isAlternateScreenActive() - ? XTERM_PERFORMANCE_CONFIG.output.alternateScreenWriteChunkBytes - : XTERM_PERFORMANCE_CONFIG.output.writeChunkBytes; + const outputScheduler = new TerminalOutputScheduler({ + getQueueBytes: () => + (outputDrainRef.current?.getQueueBytes() ?? 0) + + (frameGateRef.current?.getHeldBytes() ?? 0), + isAlternateScreenActive, + }); - const getAlternateScreenWriteIntervalMs = () => - 1000 / XTERM_PERFORMANCE_CONFIG.output.alternateScreenMaxWriteFps; - - const shouldThrottleAlternateScreenWrite = () => - isAlternateScreenActive() && - (outputDrainRef.current?.getQueueBytes() ?? 0) > - XTERM_PERFORMANCE_CONFIG.output.alternateScreenThrottleBacklogBytes; + const getWriteChunkBytes = () => outputScheduler.getWriteChunkBytes(); const getRecoveryThresholdBytes = () => visibleRef.current @@ -2166,7 +2184,8 @@ export default function XTerminal({ : XTERM_PERFORMANCE_CONFIG.output.hiddenRecoveryThresholdBytes; const getPendingOutputBytes = () => - outputDrainRef.current?.getPendingBytes() ?? 0; + (outputDrainRef.current?.getPendingBytes() ?? 0) + + (frameGateRef.current?.getHeldBytes() ?? 0); const getNonOverloadedPressureMode = (): PerformanceMode => getPendingOutputBytes() >= @@ -2215,13 +2234,7 @@ export default function XTerminal({ }; const getForegroundDelayMs = () => { - if (!shouldThrottleAlternateScreenWrite()) return 0; - const now = Date.now(); - const intervalMs = getAlternateScreenWriteIntervalMs(); - const elapsedMs = now - lastAlternateScreenWriteAtRef.current; - return lastAlternateScreenWriteAtRef.current > 0 && elapsedMs < intervalMs - ? Math.max(1, intervalMs - elapsedMs) - : 0; + return outputScheduler.getForegroundDelayMs(); }; const updateOutputDrainMode = () => { @@ -2239,9 +2252,7 @@ export default function XTerminal({ shouldUseLowLatencyFlush, onAck: sendOutputAck, onWriteStart: () => { - if (visibleRef.current && isAlternateScreenActive()) { - lastAlternateScreenWriteAtRef.current = Date.now(); - } + outputScheduler.noteWriteStart(); return { beforeLine: getCurrentAbsoluteLine(), ts: Date.now() }; }, onWriteComplete: (_payload, context) => { @@ -2280,6 +2291,7 @@ export default function XTerminal({ visible: visibleRef.current, queue_bytes: outputDrainRef.current?.getQueueBytes() ?? 0, pending_bytes: outputDrainRef.current?.getPendingBytes() ?? 0, + frame_gate: frameGateRef.current?.snapshot(), performance_mode: performanceModeRef.current, }, }); @@ -2294,6 +2306,7 @@ export default function XTerminal({ queue_bytes: queueBytes, writing_bytes: writingBytes, unacked_bytes: unackedBytes, + frame_gate: frameGateRef.current?.snapshot(), performance_mode: performanceModeRef.current, buffer_type: terminal.buffer.active.type, }, @@ -2301,9 +2314,58 @@ export default function XTerminal({ }, }); outputDrainRef.current = outputDrain; + const frameGateMode = resolveDec2026FrameGateMode(); + const frameGate = new Dec2026FrameGate({ + mode: frameGateMode, + forward: (chunk) => outputDrain.enqueue(chunk), + ackDropped: sendOutputAck, + getPressureSnapshot: () => ({ + alternateScreen: isAlternateScreenActive(), + outputDrainQueueBytes: outputDrain.getQueueBytes(), + outputDrainPendingBytes: outputDrain.getPendingBytes(), + frameGateHeldBytes: frameGateRef.current?.getHeldBytes() ?? 0, + performanceMode: performanceModeRef.current, + }), + onPressureChange: () => { + maybeRecoverPerformanceMode(); + refreshOutputPressureMode(); + }, + logDebug: (event, message, data) => { + logger.debug({ + domain: "terminal.input", + event, + message, + ids: { session_id: sessionId }, + data: { + mode: frameGateMode, + ...(data ?? {}), + }, + }); + }, + }); + frameGateRef.current = frameGate; + logger.debug({ + domain: "terminal.input", + event: "terminal.dec2026_frame_gate.mode", + message: "Initialized DEC 2026 frame gate", + ids: { session_id: sessionId }, + data: { mode: frameGateMode }, + }); updateOutputDrainMode(); - const writeTerminalTextAfterOutputQueue = (data: string) => { + const flushFrameGateAndDrain = async (reason: string) => { + clearHibernateTimer(); + frameGateRef.current?.flush(reason); + const drained = await outputDrain.waitForIdle( + XTERM_PERFORMANCE_CONFIG.output.hibernateDrainTimeoutMs, + ); + maybeRecoverPerformanceMode(); + refreshOutputPressureMode(); + return drained; + }; + + const writeTerminalTextAfterOutputQueue = async (data: string) => { + await flushFrameGateAndDrain("terminal_status_write"); return outputDrain.writeExternal( () => new Promise((resolve) => { @@ -2328,15 +2390,13 @@ export default function XTerminal({ ); }; - const flushQueuedOutputBeforeStatusNotice = async () => { - clearHibernateTimer(); - await outputDrain.waitForIdle( - XTERM_PERFORMANCE_CONFIG.output.hibernateDrainTimeoutMs, - ); - maybeRecoverPerformanceMode(); - refreshOutputPressureMode(); + writeOrderedTerminalStatus = (data: string) => { + void writeTerminalTextAfterOutputQueue(data); }; + const flushQueuedOutputBeforeStatusNotice = async () => + flushFrameGateAndDrain("status_notice"); + const resetDisconnectedInputState = () => { inputStateRef.current = createTerminalInputState(); clearCredentialPromptInputMode(); @@ -2455,10 +2515,14 @@ export default function XTerminal({ inputStateRef.current = createTerminalInputState(); clearCredentialPromptInputMode(); dismissSuggestions(); - terminal.write(renderAiCommandStart(event.payload)); + void writeTerminalTextAfterOutputQueue( + renderAiCommandStart(event.payload), + ); } else if (event.payload.type === "commandEnd") { aiCapturingRef.current = false; - terminal.write(renderAiCommandEnd(event.payload)); + void writeTerminalTextAfterOutputQueue( + renderAiCommandEnd(event.payload), + ); } break; } @@ -2539,9 +2603,8 @@ export default function XTerminal({ "Draining terminal output before hibernation", { epoch }, ); - const drainedBeforeDetach = await outputDrain.waitForIdle( - XTERM_PERFORMANCE_CONFIG.output.hibernateDrainTimeoutMs, - ); + const drainedBeforeDetach = + await flushFrameGateAndDrain("hibernate_before_detach"); if (!drainedBeforeDetach) { hibernationPhaseRef.current = "idle"; logHibernation( @@ -2582,9 +2645,8 @@ export default function XTerminal({ return; } - const drainedAfterDetach = await outputDrain.waitForIdle( - XTERM_PERFORMANCE_CONFIG.output.hibernateDrainTimeoutMs, - ); + const drainedAfterDetach = + await flushFrameGateAndDrain("hibernate_after_detach"); if (!drainedAfterDetach) { logHibernation( "drain_timeout", @@ -2683,15 +2745,11 @@ export default function XTerminal({ return; } - outputDrain.enqueue({ - data: payload.data, - bytes: payload.bytes, - }); - const recentPayload = payload.data.length > 4096 ? payload.data.slice(-4096) : payload.data; + alternateScreenTrackerRef.current.ingest(payload.data); updateCredentialPromptInputMode(recentPayload); feedCredentialOutput(recentPayload); if (visibleRef.current && hasErrorKeyword(recentPayload)) { @@ -2706,6 +2764,10 @@ export default function XTerminal({ } noteSkippedOutput(payload.droppedBytes ?? 0); + frameGate.enqueue({ + data: payload.data, + bytes: payload.bytes, + }); if (!visibleRef.current) { maybeRecoverPerformanceMode(); @@ -2810,12 +2872,14 @@ export default function XTerminal({ clearCredentialPromptInputMode(); dismissSuggestions(); if (isTerminalAlive()) { - terminal.write(renderAiCommandStart(payload)); + void writeTerminalTextAfterOutputQueue( + renderAiCommandStart(payload), + ); } } else if (payload.type === "commandEnd") { aiCapturingRef.current = false; if (isTerminalAlive()) { - terminal.write(renderAiCommandEnd(payload)); + void writeTerminalTextAfterOutputQueue(renderAiCommandEnd(payload)); } } }, @@ -3394,11 +3458,15 @@ export default function XTerminal({ if (zmodemUnlisten) zmodemUnlisten(); if (commandAcceptedUnlisten) commandAcceptedUnlisten(); zmodemHandler.dispose(); + frameGate.dispose({ ackRemaining: true, reason: "terminal_cleanup" }); + if (frameGateRef.current === frameGate) { + frameGateRef.current = null; + } outputDrain.dispose(); if (outputDrainRef.current === outputDrain) { outputDrainRef.current = null; } - lastAlternateScreenWriteAtRef.current = 0; + outputScheduler.reset(); const latestLifecycleState = terminalLifecycleStateRef.current; if ( !hibernationCleanupRef.current && diff --git a/src/components/terminal/alternateScreenStateTracker.test.ts b/src/components/terminal/alternateScreenStateTracker.test.ts new file mode 100644 index 000000000..d21327f5b --- /dev/null +++ b/src/components/terminal/alternateScreenStateTracker.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { AlternateScreenStateTracker } from "./alternateScreenStateTracker"; + +describe("AlternateScreenStateTracker", () => { + it("detects alternate-screen enter and leave sequences", () => { + const tracker = new AlternateScreenStateTracker(); + + expect(tracker.ingest("before\x1b[?1049hafter").alternateScreen).toBe(true); + expect(tracker.ingest("\x1b[?1049l").alternateScreen).toBe(false); + expect(tracker.ingest("\x1b[?47h").alternateScreen).toBe(true); + expect(tracker.ingest("\x1b[?47l").alternateScreen).toBe(false); + }); + + it("detects split CSI sequences across chunks", () => { + const tracker = new AlternateScreenStateTracker(); + + expect(tracker.ingest("\x1b[?10").alternateScreen).toBe(false); + expect(tracker.snapshot().pendingSequence).toBe("\x1b[?10"); + expect(tracker.ingest("49hpayload").alternateScreen).toBe(true); + expect(tracker.snapshot().pendingSequence).toBe(""); + }); + + it("supports multiple CSI params without modifying payload ownership", () => { + const tracker = new AlternateScreenStateTracker(); + const payload = "\x1b[?1;1047hhello"; + + const before = payload; + expect(tracker.ingest(payload).alternateScreen).toBe(true); + expect(payload).toBe(before); + expect(tracker.ingest("\x1b[?1;1047l").alternateScreen).toBe(false); + }); + + it("bounds malformed CSI buffering", () => { + const tracker = new AlternateScreenStateTracker(); + + tracker.ingest("\x1b[?1234567890123456789012345678901234567890"); + + expect(tracker.snapshot().pendingSequence.length).toBeLessThanOrEqual(32); + expect(tracker.ingest("not-a-final").alternateScreen).toBe(false); + }); + + it("accepts xterm buffer type as authoritative after parser catches up", () => { + const tracker = new AlternateScreenStateTracker(); + + tracker.ingest("\x1b[?1049h"); + expect(tracker.isAlternateScreenActive()).toBe(true); + tracker.setXtermBufferType("normal"); + expect(tracker.isAlternateScreenActive()).toBe(false); + tracker.setXtermBufferType("alternate"); + expect(tracker.isAlternateScreenActive()).toBe(true); + }); +}); diff --git a/src/components/terminal/alternateScreenStateTracker.ts b/src/components/terminal/alternateScreenStateTracker.ts new file mode 100644 index 000000000..0a4719220 --- /dev/null +++ b/src/components/terminal/alternateScreenStateTracker.ts @@ -0,0 +1,88 @@ +const ALT_SCREEN_PARAMS = new Set(["47", "1047", "1049"]); +const MAX_PENDING_SEQUENCE_CHARS = 32; + +export interface AlternateScreenStateSnapshot { + alternateScreen: boolean; + pendingSequence: string; +} + +function detectAlternateScreen(sequence: string): boolean | null { + if (!sequence.startsWith("\x1b[?")) return null; + const final = sequence[sequence.length - 1]; + if (final !== "h" && final !== "l") return null; + + const params = sequence.slice(3, -1).split(";"); + if (!params.some((param) => ALT_SCREEN_PARAMS.has(param))) return null; + return final === "h"; +} + +function isPotentialPrefix(text: string): boolean { + if (!"\x1b[?".startsWith(text) && !text.startsWith("\x1b[?")) return false; + if (text.length > MAX_PENDING_SEQUENCE_CHARS) return false; + if (!text.startsWith("\x1b[?")) return true; + return /^\x1b\[\?[0-9;]*$/u.test(text); +} + +function findPendingSequenceSuffix(text: string): string { + const start = Math.max(0, text.length - MAX_PENDING_SEQUENCE_CHARS); + for (let index = text.length - 1; index >= start; index -= 1) { + if (text.charCodeAt(index) !== 0x1b) continue; + const suffix = text.slice(index); + if (isPotentialPrefix(suffix)) return suffix; + } + return ""; +} + +export class AlternateScreenStateTracker { + private alternateScreen = false; + private pendingSequence = ""; + + ingest(data: string): AlternateScreenStateSnapshot { + if (!data) return this.snapshot(); + + const text = `${this.pendingSequence}${data}`; + this.pendingSequence = ""; + + for (let index = 0; index < text.length; index += 1) { + if (text.charCodeAt(index) !== 0x1b) continue; + const candidate = text.slice(index, Math.min(text.length, index + MAX_PENDING_SEQUENCE_CHARS)); + const match = /^\x1b\[\?([0-9;]*)([hl])/u.exec(candidate); + if (!match) continue; + + const next = detectAlternateScreen(match[0]); + if (next !== null) { + this.alternateScreen = next; + } + index += match[0].length - 1; + } + + this.pendingSequence = findPendingSequenceSuffix(text); + return this.snapshot(); + } + + setXtermBufferType(type: string | undefined) { + if (type === "alternate") { + this.alternateScreen = true; + return; + } + if (type === "normal") { + this.alternateScreen = false; + } + } + + reset() { + this.alternateScreen = false; + this.pendingSequence = ""; + } + + isAlternateScreenActive() { + return this.alternateScreen; + } + + snapshot(): AlternateScreenStateSnapshot { + return { + alternateScreen: this.alternateScreen, + pendingSequence: this.pendingSequence, + }; + } +} diff --git a/src/components/terminal/dec2026FrameGate.test.ts b/src/components/terminal/dec2026FrameGate.test.ts new file mode 100644 index 000000000..47ae96971 --- /dev/null +++ b/src/components/terminal/dec2026FrameGate.test.ts @@ -0,0 +1,254 @@ +import { describe, expect, it } from "vitest"; +import { XTERM_PERFORMANCE_CONFIG } from "@/lib/xtermPerformance"; +import { + classifyDec2026Frame, + Dec2026FrameGate, + type Dec2026FrameGateMode, +} from "./dec2026FrameGate"; +import type { QueuedOutputChunk } from "./xterminalOutputQueue"; + +const encoder = new TextEncoder(); +const begin = "\x1b[?2026h"; +const end = "\x1b[?2026l"; +const c1Begin = "\x9b?2026h"; +const c1End = "\x9b?2026l"; +const resetClearHome = "\x1b[0m\x1b[2J\x1b[1;1H"; + +function bytes(text: string) { + return encoder.encode(text).length; +} + +function successorProof(content: string) { + const classification = classifyDec2026Frame(content); + return classification.kind === "replaceable-visual" + ? classification.successorProof + : null; +} + +function frame(content: string) { + return `${begin}${content}${end}`; +} + +function createHarness( + options: { + mode?: Dec2026FrameGateMode; + alternateScreen?: boolean; + queueBytes?: number; + pendingBytes?: number; + performanceMode?: string; + } = {}, +) { + let now = 0; + let nextTimer = 1; + const timers = new Map void }>(); + const forwarded: QueuedOutputChunk[] = []; + const acks: number[] = []; + let gate!: Dec2026FrameGate; + gate = new Dec2026FrameGate({ + mode: options.mode ?? "collapse", + forward: (chunk) => forwarded.push(chunk), + ackDropped: (count) => acks.push(count), + getPressureSnapshot: () => ({ + alternateScreen: options.alternateScreen ?? true, + outputDrainQueueBytes: + options.queueBytes ?? + XTERM_PERFORMANCE_CONFIG.output.alternateScreenThrottleBacklogBytes + 1, + outputDrainPendingBytes: options.pendingBytes ?? 0, + frameGateHeldBytes: gate.getHeldBytes(), + performanceMode: options.performanceMode ?? "strained", + }), + setTimeout: (callback, delay) => { + const id = nextTimer; + nextTimer += 1; + timers.set(id, { at: now + delay, callback }); + return id; + }, + clearTimeout: (id) => { + timers.delete(id); + }, + }); + + const enqueue = (data: string, ingressBytes = bytes(data)) => { + gate.enqueue({ data, bytes: ingressBytes }); + }; + + const advance = (ms: number) => { + now += ms; + const due = [...timers.entries()] + .filter(([, timer]) => timer.at <= now) + .sort((left, right) => left[1].at - right[1].at); + for (const [id, timer] of due) { + timers.delete(id); + timer.callback(); + } + }; + + return { + acks, + advance, + enqueue, + forwarded, + gate, + joined: () => forwarded.map((chunk) => chunk.data).join(""), + forwardedBytes: () => forwarded.reduce((total, chunk) => total + chunk.bytes, 0), + }; +} + +describe("Dec2026FrameGate detector", () => { + it("detects complete, split, repeated, and C1 DEC 2026 frames in shadow mode", () => { + const { enqueue, forwarded, gate, joined } = createHarness({ mode: "shadow" }); + const payload = `${frame("one")}${frame("two")}`; + + enqueue(payload); + enqueue(`${begin}thr`); + enqueue(`ee${end}`); + enqueue(`${c1Begin}four${c1End}`); + + expect(joined()).toBe(`${payload}${frame("three")}${c1Begin}four${c1End}`); + expect(forwarded.every((chunk) => chunk.bytes === bytes(chunk.data))).toBe(true); + expect(gate.snapshot().completeFrames).toBe(4); + expect(gate.snapshot().framesSeen).toBe(4); + }); + + it("fails open for close without open, nested open, and malformed partial state", () => { + const { advance, enqueue, gate, joined } = createHarness(); + + enqueue(`${end}plain`); + enqueue(`${begin}first${begin}nested`); + advance(200); + + expect(joined()).toContain(`${end}plain`); + expect(joined()).toContain(`${begin}first${begin}nested`); + expect(gate.snapshot().failOpenCandidates).toBeGreaterThanOrEqual(2); + }); +}); + +describe("Dec2026FrameGate classification", () => { + it("accepts pure visual printable, SGR, cursor movement, erase, and safe C0", () => { + expect(classifyDec2026Frame("hello中文é😀\r\t\b\x1b[31m\x1b[2K\x1b[10;20H").kind).toBe( + "replaceable-visual", + ); + }); + + it("rejects stateful and unknown sequences conservatively", () => { + expect(classifyDec2026Frame("\x07").kind).toBe("stateful"); + expect(classifyDec2026Frame("\x1b]0;title\x07").kind).toBe("stateful"); + expect(classifyDec2026Frame("\x1bPpayload\x1b\\").kind).toBe("stateful"); + expect(classifyDec2026Frame("\x1b[?25l").kind).toBe("stateful"); + expect(classifyDec2026Frame("\x1b[?1049h").kind).toBe("stateful"); + expect(classifyDec2026Frame("\x1b[3J").kind).toBe("stateful"); + expect(classifyDec2026Frame("\x1b[S").kind).toBe("stateful"); + expect(classifyDec2026Frame("\x1b[999z").kind).toBe("unknown"); + }); + + it("requires reset, ED2 after reset, and home before printable for replacement proof", () => { + expect(successorProof(`${resetClearHome}new`)).toBe("self-contained-replacement"); + expect(successorProof("\x1b[2J\x1b[1;1Hnew")).toBe("none"); + expect(successorProof("\x1b[0m\x1b[1;1Hnew")).toBe("none"); + expect(successorProof("\x1b[0m\x1b[2Jnew")).toBe("none"); + expect(successorProof(`new${resetClearHome}`)).toBe("none"); + }); +}); + +describe("Dec2026FrameGate collapse behavior", () => { + it("forwards everything immediately below pressure threshold", () => { + const { acks, enqueue, gate, joined } = createHarness({ + queueBytes: XTERM_PERFORMANCE_CONFIG.output.alternateScreenThrottleBacklogBytes, + }); + + enqueue(frame("A")); + enqueue(frame(`${resetClearHome}B`)); + + expect(joined()).toBe(`${frame("A")}${frame(`${resetClearHome}B`)}`); + expect(acks).toEqual([]); + expect(gate.snapshot().droppedFrames).toBe(0); + }); + + it("drops a pure visual predecessor when the successor is self-contained under pressure", () => { + const a = frame("old visual"); + const b = frame(`${resetClearHome}new visual`); + const { acks, enqueue, gate, joined } = createHarness(); + + enqueue(a); + expect(gate.getHeldBytes()).toBe(bytes(a)); + expect(joined()).toBe(""); + + enqueue(b); + expect(acks).toEqual([bytes(a)]); + expect(gate.snapshot().droppedFrames).toBe(1); + + gate.flush("test"); + expect(joined()).toBe(b); + }); + + it("does not collapse across a barrier", () => { + const a = frame("old visual"); + const barrier = "\x1b]0;title\x07"; + const b = frame(`${resetClearHome}new visual`); + const { acks, enqueue, gate, joined } = createHarness(); + + enqueue(a); + enqueue(barrier); + enqueue(b); + gate.flush("test"); + + expect(acks).toEqual([]); + expect(joined()).toBe(`${a}${barrier}${b}`); + }); + + it("keeps exact UTF-8 accounting for Unicode collapsed frames", () => { + const samples = ["ASCII", "中文", "é", "e\u0301", "😀", "👨‍👩‍👧‍👦", "\x1b[31m中文😀"]; + + for (const sample of samples) { + const a = frame(sample); + const b = frame(`${resetClearHome}${sample}`); + const { acks, enqueue, forwardedBytes, gate } = createHarness(); + + enqueue(a); + expect(forwardedBytes() + acks.reduce((sum, count) => sum + count, 0) + gate.getHeldBytes()).toBe( + bytes(a), + ); + enqueue(b); + gate.flush("unicode-test"); + + expect(forwardedBytes() + acks.reduce((sum, count) => sum + count, 0)).toBe( + bytes(a) + bytes(b), + ); + } + }); + + it("fails open on timeout and max held bytes", () => { + const timeoutHarness = createHarness(); + timeoutHarness.enqueue(`${begin}unterminated`); + timeoutHarness.advance(200); + expect(timeoutHarness.joined()).toBe(`${begin}unterminated`); + + const capHarness = createHarness(); + const large = `${begin}${"x".repeat(512 * 1024)}`; + capHarness.enqueue(large); + expect(capHarness.joined()).toBe(large); + }); + + it("fails open instead of guessing when ingress byte accounting mismatches", () => { + const a = frame("中文"); + const { acks, enqueue, gate, joined } = createHarness(); + + enqueue(a, a.length); + + expect(joined()).toBe(a); + expect(acks).toEqual([]); + expect(gate.snapshot().failOpenCandidates).toBe(1); + }); + + it("flushes held output before lifecycle text", () => { + const a = frame("held"); + const lifecycle = "\r\n[session closed]\r\n"; + const { enqueue, forwarded, gate, joined } = createHarness(); + + enqueue(a); + gate.flush("session-close"); + forwarded.push({ data: lifecycle, bytes: bytes(lifecycle) }); + + expect(joined()).toBe(`${a}${lifecycle}`); + }); +}); diff --git a/src/components/terminal/dec2026FrameGate.ts b/src/components/terminal/dec2026FrameGate.ts new file mode 100644 index 000000000..e52145af6 --- /dev/null +++ b/src/components/terminal/dec2026FrameGate.ts @@ -0,0 +1,707 @@ +import { XTERM_PERFORMANCE_CONFIG } from "@/lib/xtermPerformance"; +import type { QueuedOutputChunk } from "./xterminalOutputQueue"; + +export type Dec2026FrameGateMode = "off" | "shadow" | "collapse"; + +export type Dec2026FrameClassification = + | { + kind: "replaceable-visual"; + bytes: number; + successorProof: "self-contained-replacement" | "none"; + } + | { + kind: "stateful"; + bytes: number; + reason: string; + } + | { + kind: "unknown"; + bytes: number; + reason: string; + }; + +export interface Dec2026FrameGateSnapshot { + mode: Dec2026FrameGateMode; + framesSeen: number; + completeFrames: number; + partialFrames: number; + candidateFrames: number; + replaceableFrames: number; + wouldDropFrames: number; + wouldDropBytes: number; + droppedFrames: number; + droppedBytes: number; + statefulFrames: number; + malformedFrames: number; + failOpenCandidates: number; + maxFrameBytes: number; + heldBytes: number; + lastRejectReason: string | null; + lastCandidateContext: Dec2026FrameCandidateContext | null; +} + +export interface Dec2026FrameCandidateContext { + outputDrainPendingBytes: number; + outputDrainQueueBytes: number; + frameGateHeldBytes: number; + alternateScreen: boolean; + performanceMode: string; +} + +interface Dec2026FrameGateOptions { + mode: Dec2026FrameGateMode; + forward: (chunk: QueuedOutputChunk) => void; + ackDropped: (bytes: number) => void; + getPressureSnapshot: () => Dec2026FrameCandidateContext; + onPressureChange?: () => void; + logDebug?: (event: string, message: string, data?: Record) => void; + setTimeout?: (callback: () => void, delay: number) => number; + clearTimeout?: (handle: number) => void; +} + +interface Dec2026CompleteFrame { + data: string; + content: string; + bytes: number; + classification: Dec2026FrameClassification; +} + +interface Dec2026Boundary { + index: number; + sequence: string; + kind: "begin" | "end"; +} + +const DEC2026_BEGIN_ESC = "\x1b[?2026h"; +const DEC2026_END_ESC = "\x1b[?2026l"; +const DEC2026_BEGIN_C1 = "\x9b?2026h"; +const DEC2026_END_C1 = "\x9b?2026l"; +const DEC2026_SEQUENCES = [ + DEC2026_BEGIN_ESC, + DEC2026_END_ESC, + DEC2026_BEGIN_C1, + DEC2026_END_C1, +] as const; + +const MAX_PENDING_CSI_CHARS = 64; +const DEFAULT_PARTIAL_FRAME_FAIL_OPEN_MS = 200; +const DEFAULT_MAX_HELD_FRAME_BYTES = 512 * 1024; + +const textEncoder = new TextEncoder(); + +function utf8ByteLength(text: string): number { + return textEncoder.encode(text).length; +} + +export function resolveDec2026FrameGateMode(): Dec2026FrameGateMode { + const requested = import.meta.env.VITE_NYATERM_DEC2026_FRAME_GATE; + if (requested === "off" || requested === "shadow" || requested === "collapse") { + return requested; + } + return import.meta.env.DEV ? "shadow" : "off"; +} + +function boundaryKind(sequence: string): "begin" | "end" { + return sequence.endsWith("h") ? "begin" : "end"; +} + +function findNextBoundary(text: string, startIndex: number): Dec2026Boundary | null { + let next: Dec2026Boundary | null = null; + for (const sequence of DEC2026_SEQUENCES) { + const index = text.indexOf(sequence, startIndex); + if (index < 0) continue; + if (!next || index < next.index || (index === next.index && sequence.length > next.sequence.length)) { + next = { index, sequence, kind: boundaryKind(sequence) }; + } + } + return next; +} + +function pendingBoundarySuffix(text: string): string { + const max = Math.min(MAX_PENDING_CSI_CHARS, text.length); + for (let length = max; length > 0; length -= 1) { + const suffix = text.slice(text.length - length); + if (DEC2026_SEQUENCES.some((sequence) => sequence.startsWith(suffix))) { + return suffix; + } + } + return ""; +} + +function parseCsi( + text: string, + index: number, +): { endIndex: number; params: string; intermediates: string; final: string; raw: string } | null { + const isC1 = text.charCodeAt(index) === 0x9b; + const start = isC1 ? index + 1 : index + 2; + let cursor = start; + let params = ""; + let intermediates = ""; + + while (cursor < text.length) { + const code = text.charCodeAt(cursor); + if (code >= 0x30 && code <= 0x3f && intermediates.length === 0) { + params += text[cursor]; + cursor += 1; + continue; + } + if (code >= 0x20 && code <= 0x2f) { + intermediates += text[cursor]; + cursor += 1; + continue; + } + if (code >= 0x40 && code <= 0x7e) { + const final = text[cursor]; + const raw = text.slice(index, cursor + 1); + return { endIndex: cursor + 1, params, intermediates, final, raw }; + } + return null; + } + + return null; +} + +function numericParams(params: string): string[] { + return params.length === 0 ? [] : params.split(";"); +} + +function hasPrivateMarker(params: string): boolean { + return params.includes("?") || params.includes(">") || params.includes("<") || params.includes("="); +} + +function isDeviceReport(final: string): boolean { + return final === "c" || final === "n"; +} + +function isAllowedCursorCsi(final: string, params: string): boolean { + if (!"ABCDEFGHfd`".includes(final)) return false; + if (hasPrivateMarker(params)) return false; + return numericParams(params).every((param) => param === "" || /^\d+$/u.test(param)); +} + +function isAllowedEraseCsi(final: string, params: string): boolean { + if (final !== "J" && final !== "K") return false; + if (hasPrivateMarker(params)) return false; + const parts = numericParams(params); + if (!parts.every((param) => param === "" || /^\d+$/u.test(param))) return false; + if (final === "J" && parts.some((param) => param === "3")) return false; + return true; +} + +function isSgrReset(csi: { params: string; final: string; raw: string }): boolean { + return csi.final === "m" && csi.params === "0" && csi.raw.endsWith("0m"); +} + +function isEd2(csi: { params: string; final: string }): boolean { + return csi.final === "J" && csi.params === "2"; +} + +function isHome(csi: { params: string; final: string }): boolean { + if (csi.final === "H") { + return csi.params === "" || csi.params === "1;1"; + } + return csi.final === "f" && csi.params === "1;1"; +} + +function classifyCsi(csi: { + params: string; + intermediates: string; + final: string; +}): "allowed" | { kind: "stateful" | "unknown"; reason: string } { + if (csi.intermediates.length > 0) { + return { kind: "unknown", reason: "csi-intermediate" }; + } + if (isDeviceReport(csi.final)) { + return { kind: "stateful", reason: "device-report" }; + } + if (csi.final === "h" || csi.final === "l") { + return { kind: "stateful", reason: "mode-change" }; + } + if (csi.final === "r") { + return { kind: "stateful", reason: "scroll-region" }; + } + if ("@LMP".includes(csi.final)) { + return { kind: "stateful", reason: "insert-delete" }; + } + if (csi.final === "S" || csi.final === "T") { + return { kind: "stateful", reason: "scroll-up-down" }; + } + if (csi.final === "m") { + return hasPrivateMarker(csi.params) + ? { kind: "unknown", reason: "private-sgr" } + : "allowed"; + } + if (isAllowedCursorCsi(csi.final, csi.params)) return "allowed"; + if (csi.final === "J" && numericParams(csi.params).some((param) => param === "3")) { + return { kind: "stateful", reason: "clear-scrollback" }; + } + if (isAllowedEraseCsi(csi.final, csi.params)) { + return "allowed"; + } + return { kind: "unknown", reason: `unknown-csi-${csi.final}` }; +} + +export function classifyDec2026Frame( + content: string, + frameBytes = utf8ByteLength(content), +): Dec2026FrameClassification { + let sawResetBeforeEd2 = false; + let sawEd2AfterReset = false; + let sawHomeBeforePrintable = false; + let sawPrintable = false; + + for (let index = 0; index < content.length; ) { + const code = content.charCodeAt(index); + + if (code === 0x1b) { + const next = content[index + 1]; + if (next === "[") { + const csi = parseCsi(content, index); + if (!csi) return { kind: "unknown", bytes: frameBytes, reason: "malformed-csi" }; + const result = classifyCsi(csi); + if (result !== "allowed") { + return { kind: result.kind, bytes: frameBytes, reason: result.reason }; + } + if (!sawPrintable) { + if (isSgrReset(csi)) sawResetBeforeEd2 = true; + if (sawResetBeforeEd2 && isEd2(csi)) sawEd2AfterReset = true; + if (isHome(csi)) sawHomeBeforePrintable = true; + } + index = csi.endIndex; + continue; + } + if (next === "]") return { kind: "stateful", bytes: frameBytes, reason: "osc" }; + if (next === "P") return { kind: "stateful", bytes: frameBytes, reason: "dcs" }; + if (next === "_") return { kind: "stateful", bytes: frameBytes, reason: "apc" }; + if (next === "^") return { kind: "stateful", bytes: frameBytes, reason: "pm" }; + if (next === "X") return { kind: "stateful", bytes: frameBytes, reason: "sos" }; + if (next === "c") return { kind: "stateful", bytes: frameBytes, reason: "ris" }; + return { kind: "unknown", bytes: frameBytes, reason: "unknown-esc" }; + } + + if (code === 0x9b) { + const csi = parseCsi(content, index); + if (!csi) return { kind: "unknown", bytes: frameBytes, reason: "malformed-c1-csi" }; + const result = classifyCsi(csi); + if (result !== "allowed") { + return { kind: result.kind, bytes: frameBytes, reason: result.reason }; + } + if (!sawPrintable) { + if (isSgrReset(csi)) sawResetBeforeEd2 = true; + if (sawResetBeforeEd2 && isEd2(csi)) sawEd2AfterReset = true; + if (isHome(csi)) sawHomeBeforePrintable = true; + } + index = csi.endIndex; + continue; + } + + if (code === 0x9d) return { kind: "stateful", bytes: frameBytes, reason: "c1-osc" }; + if (code === 0x90) return { kind: "stateful", bytes: frameBytes, reason: "c1-dcs" }; + if (code === 0x9f) return { kind: "stateful", bytes: frameBytes, reason: "c1-apc" }; + if (code === 0x9e) return { kind: "stateful", bytes: frameBytes, reason: "c1-pm" }; + if (code === 0x98) return { kind: "stateful", bytes: frameBytes, reason: "c1-sos" }; + + if (code === 0x0d || code === 0x09 || code === 0x08) { + index += 1; + continue; + } + if (code === 0x0a) return { kind: "stateful", bytes: frameBytes, reason: "lf" }; + if (code === 0x07) return { kind: "stateful", bytes: frameBytes, reason: "bel" }; + if (code < 0x20 || code === 0x7f || (code >= 0x80 && code <= 0x9f)) { + return { kind: "unknown", bytes: frameBytes, reason: "unknown-control" }; + } + + sawPrintable = true; + const codePoint = content.codePointAt(index) ?? code; + index += codePoint > 0xffff ? 2 : 1; + } + + return { + kind: "replaceable-visual", + bytes: frameBytes, + successorProof: + sawResetBeforeEd2 && sawEd2AfterReset && sawHomeBeforePrintable + ? "self-contained-replacement" + : "none", + }; +} + +export class Dec2026FrameGate { + private readonly setTimer: (callback: () => void, delay: number) => number; + private readonly clearTimer: (handle: number) => void; + private pendingPrefix = ""; + private currentFrameData = ""; + private currentFrameContent = ""; + private heldFrame: Dec2026CompleteFrame | null = null; + private shadowPendingPrefix = ""; + private shadowCurrentFrameData = ""; + private shadowCurrentFrameContent = ""; + private shadowHeldFrame: Dec2026CompleteFrame | null = null; + private failOpenTimer: number | null = null; + private disposed = false; + private snapshotState: Dec2026FrameGateSnapshot; + + constructor(private readonly options: Dec2026FrameGateOptions) { + this.setTimer = options.setTimeout ?? ((callback, delay) => window.setTimeout(callback, delay)); + this.clearTimer = options.clearTimeout ?? ((handle) => window.clearTimeout(handle)); + this.snapshotState = { + mode: options.mode, + framesSeen: 0, + completeFrames: 0, + partialFrames: 0, + candidateFrames: 0, + replaceableFrames: 0, + wouldDropFrames: 0, + wouldDropBytes: 0, + droppedFrames: 0, + droppedBytes: 0, + statefulFrames: 0, + malformedFrames: 0, + failOpenCandidates: 0, + maxFrameBytes: 0, + heldBytes: 0, + lastRejectReason: null, + lastCandidateContext: null, + }; + } + + enqueue(chunk: QueuedOutputChunk) { + if (this.disposed || chunk.bytes <= 0 || !chunk.data) return; + if (chunk.bytes !== utf8ByteLength(chunk.data)) { + this.failOpen("byte-mismatch"); + this.options.forward(chunk); + return; + } + if (this.options.mode === "off") { + this.options.forward(chunk); + return; + } + if (this.options.mode === "shadow") { + this.scanShadow(chunk.data); + this.options.forward(chunk); + return; + } + if (!this.canCollapseNow()) { + this.scanShadow(chunk.data); + this.flush("pressure-open"); + this.options.forward(chunk); + return; + } + this.processCollapseText(chunk.data); + this.updateHeldBytes(); + } + + flush(reason = "flush") { + if (this.disposed) return; + this.clearFailOpenTimer(); + this.forwardText(this.pendingPrefix); + this.pendingPrefix = ""; + this.forwardText(this.currentFrameData); + this.currentFrameData = ""; + this.currentFrameContent = ""; + if (this.heldFrame) { + this.forwardText(this.heldFrame.data); + this.heldFrame = null; + } + this.options.logDebug?.("terminal.dec2026_frame_gate.flush", "Flushed DEC 2026 frame gate", { + reason, + }); + this.updateHeldBytes(); + } + + dispose(options: { ackRemaining?: boolean; reason?: string } = {}) { + if (this.disposed) return; + this.disposed = true; + this.clearFailOpenTimer(); + const remainingBytes = this.getHeldBytes(); + if (options.ackRemaining && remainingBytes > 0) { + this.options.ackDropped(remainingBytes); + this.options.logDebug?.( + "terminal.dec2026_frame_gate.teardown_ack", + "ACKed gate-owned bytes during terminal teardown", + { reason: options.reason ?? "dispose", bytes: remainingBytes }, + ); + } else { + this.forwardText(this.pendingPrefix); + this.forwardText(this.currentFrameData); + if (this.heldFrame) this.forwardText(this.heldFrame.data); + } + this.pendingPrefix = ""; + this.currentFrameData = ""; + this.currentFrameContent = ""; + this.heldFrame = null; + this.shadowPendingPrefix = ""; + this.shadowCurrentFrameData = ""; + this.shadowCurrentFrameContent = ""; + this.shadowHeldFrame = null; + this.updateHeldBytes(); + } + + reset() { + this.clearFailOpenTimer(); + this.pendingPrefix = ""; + this.currentFrameData = ""; + this.currentFrameContent = ""; + this.heldFrame = null; + this.shadowPendingPrefix = ""; + this.shadowCurrentFrameData = ""; + this.shadowCurrentFrameContent = ""; + this.shadowHeldFrame = null; + this.updateHeldBytes(); + } + + getHeldBytes() { + return ( + utf8ByteLength(this.pendingPrefix) + + utf8ByteLength(this.currentFrameData) + + (this.heldFrame?.bytes ?? 0) + ); + } + + snapshot(): Dec2026FrameGateSnapshot { + this.updateHeldBytes(); + return { ...this.snapshotState }; + } + + private scanShadow(data: string) { + const previousPrefix = this.pendingPrefix; + const previousFrameData = this.currentFrameData; + const previousFrameContent = this.currentFrameContent; + const previousHeldFrame = this.heldFrame; + const previousTimer = this.failOpenTimer; + this.failOpenTimer = null; + this.pendingPrefix = this.shadowPendingPrefix; + this.currentFrameData = this.shadowCurrentFrameData; + this.currentFrameContent = this.shadowCurrentFrameContent; + this.processCollapseText(data, true); + this.shadowPendingPrefix = this.pendingPrefix; + this.shadowCurrentFrameData = this.currentFrameData; + this.shadowCurrentFrameContent = this.currentFrameContent; + this.pendingPrefix = previousPrefix; + this.currentFrameData = previousFrameData; + this.currentFrameContent = previousFrameContent; + this.heldFrame = previousHeldFrame; + this.failOpenTimer = previousTimer; + this.updateHeldBytes(); + } + + private processCollapseText(data: string, shadow = false) { + let text = `${this.pendingPrefix}${data}`; + this.pendingPrefix = ""; + + while (text.length > 0) { + if (this.currentFrameData) { + const boundary = findNextBoundary(text, 0); + if (!boundary) { + this.appendCurrentFrame(text, shadow); + text = ""; + break; + } + const before = text.slice(0, boundary.index); + this.appendCurrentFrame(before, shadow); + if (boundary.kind === "begin") { + this.snapshotState.malformedFrames += 1; + this.snapshotState.failOpenCandidates += 1; + if (shadow) { + this.shadowHeldFrame = null; + } else { + this.flush("nested-open"); + this.forwardText(boundary.sequence); + } + text = text.slice(boundary.index + boundary.sequence.length); + continue; + } + this.currentFrameData += boundary.sequence; + const frameData = this.currentFrameData; + const frameContent = this.currentFrameContent; + this.currentFrameData = ""; + this.currentFrameContent = ""; + this.handleCompleteFrame(frameData, frameContent, shadow); + text = text.slice(boundary.index + boundary.sequence.length); + continue; + } + + const boundary = findNextBoundary(text, 0); + if (!boundary) { + const suffix = pendingBoundarySuffix(text); + const barrier = suffix ? text.slice(0, -suffix.length) : text; + this.handleBarrier(barrier, shadow); + this.pendingPrefix = suffix; + text = ""; + break; + } + + const before = text.slice(0, boundary.index); + this.handleBarrier(before, shadow); + if (boundary.kind === "end") { + this.snapshotState.failOpenCandidates += 1; + this.handleBarrier(boundary.sequence, shadow); + text = text.slice(boundary.index + boundary.sequence.length); + continue; + } + this.snapshotState.framesSeen += 1; + this.snapshotState.partialFrames += 1; + if (shadow) { + this.currentFrameData = boundary.sequence; + this.currentFrameContent = ""; + } else { + this.currentFrameData = boundary.sequence; + this.currentFrameContent = ""; + this.scheduleFailOpenTimer(); + } + text = text.slice(boundary.index + boundary.sequence.length); + } + + if (!shadow && this.getHeldBytes() >= DEFAULT_MAX_HELD_FRAME_BYTES) { + this.failOpen("held-byte-cap"); + } + } + + private appendCurrentFrame(text: string, shadow: boolean) { + if (!text) return; + this.currentFrameData += text; + this.currentFrameContent += text; + if (!shadow && utf8ByteLength(this.currentFrameData) >= DEFAULT_MAX_HELD_FRAME_BYTES) { + this.failOpen("partial-byte-cap"); + } + } + + private handleBarrier(text: string, shadow: boolean) { + if (!text) return; + if (shadow) { + this.shadowHeldFrame = null; + return; + } + this.flush("barrier"); + this.forwardText(text); + } + + private handleCompleteFrame(frameData: string, frameContent: string, shadow: boolean) { + const bytes = utf8ByteLength(frameData); + const contentBytes = utf8ByteLength(frameContent); + const classification = classifyDec2026Frame(frameContent, bytes); + const frame = { data: frameData, content: frameContent, bytes, classification }; + this.snapshotState.completeFrames += 1; + this.snapshotState.maxFrameBytes = Math.max(this.snapshotState.maxFrameBytes, bytes); + this.recordClassification(classification, contentBytes); + + if (shadow) { + this.simulateShadowFrame(frame); + return; + } + + if (classification.kind !== "replaceable-visual") { + this.snapshotState.lastRejectReason = classification.reason; + this.flush("stateful-frame"); + this.forwardText(frame.data); + return; + } + + this.snapshotState.lastCandidateContext = this.options.getPressureSnapshot(); + if (this.heldFrame) { + if (classification.successorProof === "self-contained-replacement") { + this.dropHeldFrame("successor-replacement"); + } else { + this.forwardText(this.heldFrame.data); + this.heldFrame = null; + } + } + this.heldFrame = frame; + this.scheduleFailOpenTimer(); + this.updateHeldBytes(); + } + + private simulateShadowFrame(frame: Dec2026CompleteFrame) { + if (frame.classification.kind !== "replaceable-visual") { + this.shadowHeldFrame = null; + return; + } + this.snapshotState.lastCandidateContext = this.options.getPressureSnapshot(); + if ( + this.canCollapseNow() && + this.shadowHeldFrame && + frame.classification.successorProof === "self-contained-replacement" + ) { + this.snapshotState.wouldDropFrames += 1; + this.snapshotState.wouldDropBytes += this.shadowHeldFrame.bytes; + } + this.shadowHeldFrame = this.canCollapseNow() ? frame : null; + } + + private recordClassification(classification: Dec2026FrameClassification, contentBytes: number) { + if (classification.kind === "replaceable-visual") { + this.snapshotState.candidateFrames += 1; + if (classification.successorProof === "self-contained-replacement") { + this.snapshotState.replaceableFrames += 1; + } + return; + } + this.snapshotState.lastRejectReason = classification.reason; + if (classification.kind === "stateful") { + this.snapshotState.statefulFrames += 1; + return; + } + this.snapshotState.malformedFrames += contentBytes > MAX_PENDING_CSI_CHARS ? 1 : 0; + } + + private canCollapseNow() { + if (this.options.mode !== "collapse") return false; + const pressure = this.options.getPressureSnapshot(); + return ( + pressure.alternateScreen && + pressure.outputDrainQueueBytes + pressure.frameGateHeldBytes > + XTERM_PERFORMANCE_CONFIG.output.alternateScreenThrottleBacklogBytes + ); + } + + private dropHeldFrame(reason: string) { + if (!this.heldFrame) return; + const bytes = this.heldFrame.bytes; + this.options.ackDropped(bytes); + this.snapshotState.wouldDropFrames += 1; + this.snapshotState.wouldDropBytes += bytes; + this.snapshotState.droppedFrames += 1; + this.snapshotState.droppedBytes += bytes; + this.options.logDebug?.( + "terminal.dec2026_frame_gate.drop", + "Dropped stale DEC 2026 frame", + { reason, bytes }, + ); + this.heldFrame = null; + this.updateHeldBytes(); + } + + private failOpen(reason: string) { + this.snapshotState.failOpenCandidates += 1; + this.options.logDebug?.( + "terminal.dec2026_frame_gate.fail_open", + "Fail-open forwarded DEC 2026 frame gate data", + { reason, held_bytes: this.getHeldBytes() }, + ); + this.flush(reason); + } + + private forwardText(text: string) { + if (!text) return; + this.options.forward({ data: text, bytes: utf8ByteLength(text) }); + } + + private scheduleFailOpenTimer() { + this.clearFailOpenTimer(); + this.failOpenTimer = this.setTimer(() => { + this.failOpenTimer = null; + this.failOpen("timeout"); + }, DEFAULT_PARTIAL_FRAME_FAIL_OPEN_MS); + } + + private clearFailOpenTimer() { + if (this.failOpenTimer === null) return; + this.clearTimer(this.failOpenTimer); + this.failOpenTimer = null; + } + + private updateHeldBytes() { + this.snapshotState.heldBytes = this.getHeldBytes(); + this.options.onPressureChange?.(); + } +} diff --git a/src/components/terminal/dec2026FrameGateBenchmark.test.ts b/src/components/terminal/dec2026FrameGateBenchmark.test.ts new file mode 100644 index 000000000..876eaaf8d --- /dev/null +++ b/src/components/terminal/dec2026FrameGateBenchmark.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it } from "vitest"; +import { XTERM_PERFORMANCE_CONFIG } from "@/lib/xtermPerformance"; +import { + Dec2026FrameGate, + type Dec2026FrameGateMode, +} from "./dec2026FrameGate"; +import type { QueuedOutputChunk } from "./xterminalOutputQueue"; + +const encoder = new TextEncoder(); +const begin = "\x1b[?2026h"; +const end = "\x1b[?2026l"; +const resetClearHome = "\x1b[0m\x1b[2J\x1b[1;1H"; + +interface BenchmarkMetrics { + maxOutputDrainQueueBytes: number; + maxFrameGateHeldBytes: number; + maxTotalFrontendPendingBytes: number; + framesReceived: number; + framesRendered: number; + framesCollapsed: number; + bytesCollapsed: number; + xtermWriteCount: number; + maxWriteCallbackLatencyMs: number; + longestForegroundStallMs: number; + severeFallbackTicks: number; + newestFrameWriteLagMs: number; +} + +function bytes(text: string) { + return encoder.encode(text).length; +} + +function makeFrame(index: number, safe: boolean) { + const body = `${String(index).padStart(4, "0")} ${"abcdef0123456789".repeat(128)}`; + if (safe) return `${begin}${resetClearHome}${body}${end}`; + if (index % 4 === 0) return `${begin}\x1b]0;title-${index}\x07${body}${end}`; + if (index % 4 === 1) return `${begin}\x1b[?25l${body}${end}`; + if (index % 4 === 2) return `${begin}\x1b[999z${body}${end}`; + return `${begin}${body}${end}`; +} + +function makeCorpus(safe: boolean, frameCount = 180) { + return Array.from({ length: frameCount }, (_, index) => makeFrame(index, safe)); +} + +function runSynthetic(mode: Dec2026FrameGateMode, corpus: string[]): BenchmarkMetrics { + let now = 0; + let outputDrainQueueBytes = XTERM_PERFORMANCE_CONFIG.output.alternateScreenThrottleBacklogBytes + 1; + let maxOutputDrainQueueBytes = outputDrainQueueBytes; + let maxFrameGateHeldBytes = 0; + let maxTotalFrontendPendingBytes = outputDrainQueueBytes; + let xtermWriteCount = 0; + let framesRendered = 0; + let maxWriteCallbackLatencyMs = 0; + let longestForegroundStallMs = 0; + let severeFallbackTicks = 0; + let newestFrameWriteLagMs = 0; + const frameArrivalTimes = new Map(); + + const writes: QueuedOutputChunk[] = []; + const acks: number[] = []; + let gate!: Dec2026FrameGate; + gate = new Dec2026FrameGate({ + mode, + forward: (chunk) => { + writes.push(chunk); + outputDrainQueueBytes += chunk.bytes; + maxOutputDrainQueueBytes = Math.max(maxOutputDrainQueueBytes, outputDrainQueueBytes); + const writeLatency = Math.min(24, Math.ceil(chunk.bytes / 4096)); + now += writeLatency; + xtermWriteCount += 1; + maxWriteCallbackLatencyMs = Math.max(maxWriteCallbackLatencyMs, writeLatency); + longestForegroundStallMs = Math.max(longestForegroundStallMs, writeLatency); + if ( + outputDrainQueueBytes > + XTERM_PERFORMANCE_CONFIG.output.alternateScreenThrottleBacklogBytes + ) { + severeFallbackTicks += 1; + } + outputDrainQueueBytes = Math.max(0, outputDrainQueueBytes - chunk.bytes); + framesRendered += (chunk.data.match(/\x1b\[\?2026l/g) ?? []).length; + const match = /(\d{4})/u.exec(chunk.data); + if (match) { + const index = Number(match[1]); + newestFrameWriteLagMs = Math.max( + newestFrameWriteLagMs, + now - (frameArrivalTimes.get(index) ?? now), + ); + } + }, + ackDropped: (count) => acks.push(count), + getPressureSnapshot: () => ({ + alternateScreen: true, + outputDrainQueueBytes, + outputDrainPendingBytes: outputDrainQueueBytes, + frameGateHeldBytes: gate.getHeldBytes(), + performanceMode: "strained", + }), + onPressureChange: () => { + maxFrameGateHeldBytes = Math.max(maxFrameGateHeldBytes, gate.getHeldBytes()); + maxTotalFrontendPendingBytes = Math.max( + maxTotalFrontendPendingBytes, + outputDrainQueueBytes + gate.getHeldBytes(), + ); + }, + setTimeout: () => 0, + clearTimeout: () => {}, + }); + + corpus.forEach((data, index) => { + frameArrivalTimes.set(index, now); + gate.enqueue({ data, bytes: bytes(data) }); + now += 1000 / 60; + }); + gate.flush("benchmark-end"); + + const snapshot = gate.snapshot(); + return { + maxOutputDrainQueueBytes, + maxFrameGateHeldBytes, + maxTotalFrontendPendingBytes, + framesReceived: corpus.length, + framesRendered, + framesCollapsed: snapshot.droppedFrames, + bytesCollapsed: acks.reduce((sum, count) => sum + count, 0), + xtermWriteCount, + maxWriteCallbackLatencyMs, + longestForegroundStallMs, + severeFallbackTicks, + newestFrameWriteLagMs, + }; +} + +describe("DEC 2026 synthetic frame gate benchmark", () => { + it("collapses only the safe self-contained corpus under pressure", () => { + const safeCorpus = makeCorpus(true); + const unsafeCorpus = makeCorpus(false); + const baselineSafe = runSynthetic("off", safeCorpus); + const shadowSafe = runSynthetic("shadow", safeCorpus); + const collapseSafe = runSynthetic("collapse", safeCorpus); + const collapseUnsafe = runSynthetic("collapse", unsafeCorpus); + + expect(shadowSafe.framesCollapsed).toBe(0); + expect(shadowSafe.framesRendered).toBe(baselineSafe.framesRendered); + expect(collapseSafe.framesCollapsed).toBeGreaterThan(0); + expect(collapseSafe.bytesCollapsed).toBeGreaterThan(0); + expect(collapseSafe.xtermWriteCount).toBeLessThan(baselineSafe.xtermWriteCount); + expect(collapseUnsafe.framesCollapsed).toBe(0); + expect(collapseUnsafe.framesRendered).toBe(unsafeCorpus.length); + expect(collapseSafe.maxFrameGateHeldBytes).toBeLessThan(512 * 1024); + }); +}); diff --git a/src/components/terminal/terminalOutputDrain.test.ts b/src/components/terminal/terminalOutputDrain.test.ts index 595c5d586..5b780013b 100644 --- a/src/components/terminal/terminalOutputDrain.test.ts +++ b/src/components/terminal/terminalOutputDrain.test.ts @@ -3,12 +3,20 @@ import { XTERM_PERFORMANCE_CONFIG } from "@/lib/xtermPerformance"; import { TerminalOutputDrain } from "./terminalOutputDrain"; const settle = async () => { - await Promise.resolve(); - await Promise.resolve(); - await Promise.resolve(); + for (let i = 0; i < 10; i += 1) { + await Promise.resolve(); + } }; -function createHarness(options: { writeChunkBytes?: number; autoCompleteWrites?: boolean } = {}) { +function createHarness( + options: { + writeChunkBytes?: number; + autoCompleteWrites?: boolean; + shouldUseLowLatencyFlush?: () => boolean; + getForegroundDelayMs?: () => number; + writeDurationMs?: number; + } = {}, +) { let now = 0; let nextTimerId = 1; let nextFrameId = 1; @@ -22,6 +30,7 @@ function createHarness(options: { writeChunkBytes?: number; autoCompleteWrites?: const terminal = { write: vi.fn((data: string, callback?: () => void) => { writes.push(data); + now += options.writeDurationMs ?? 0; if (!callback) return; if (options.autoCompleteWrites === false) { pendingWriteCallbacks.push(callback); @@ -35,6 +44,8 @@ function createHarness(options: { writeChunkBytes?: number; autoCompleteWrites?: sessionId: "session-1", getTerminal: () => terminal, getWriteChunkBytes: () => options.writeChunkBytes ?? 1024, + getForegroundDelayMs: options.getForegroundDelayMs, + shouldUseLowLatencyFlush: options.shouldUseLowLatencyFlush, onAck: (bytes) => acks.push(bytes), onPressureChange: (bytes) => pressure.push(bytes), timers: { @@ -90,6 +101,8 @@ function createHarness(options: { writeChunkBytes?: number; autoCompleteWrites?: terminal, timers, writes, + getFrameCount: () => frames.size, + getNow: () => now, }; } @@ -141,6 +154,61 @@ describe("TerminalOutputDrain", () => { expect(writes).toEqual(["abcd", "efgh", "ij"]); }); + it("uses the microtask fast path for light foreground pressure", async () => { + const { drain, getFrameCount, writes } = createHarness({ + shouldUseLowLatencyFlush: () => true, + writeChunkBytes: 16, + }); + + drain.setMode("foreground"); + drain.enqueue({ data: "hello", bytes: 5 }); + await settle(); + + expect(writes).toEqual(["hello"]); + expect(getFrameCount()).toBe(0); + }); + + it("yields to the next frame when a foreground drain turn exhausts its budget", async () => { + const { drain, flushFrame, getFrameCount, writes } = createHarness({ + shouldUseLowLatencyFlush: () => true, + writeChunkBytes: 4, + writeDurationMs: XTERM_PERFORMANCE_CONFIG.output.maxForegroundDrainTurnMs + 1, + }); + + drain.setMode("foreground"); + drain.enqueue({ data: "abcdefghijkl", bytes: 12 }); + await settle(); + + expect(writes).toEqual(["abcd"]); + expect(getFrameCount()).toBe(1); + + flushFrame(); + await settle(); + expect(writes).toEqual(["abcd", "efgh"]); + }); + + it("honors foreground delay only when the scheduler reports severe backlog", async () => { + let severeBacklog = false; + const { advance, drain, timers, writes } = createHarness({ + getForegroundDelayMs: () => (severeBacklog ? 50 : 0), + writeChunkBytes: 8, + }); + + drain.setMode("foreground"); + severeBacklog = true; + drain.enqueue({ data: "alt", bytes: 3 }); + expect(timers.size).toBe(1); + expect(writes).toEqual([]); + + advance(49); + await settle(); + expect(writes).toEqual([]); + + advance(1); + await settle(); + expect(writes).toEqual(["alt"]); + }); + it("acks only bytes completed by write callbacks", async () => { const { acks, drain, flushFrame, pendingWriteCallbacks } = createHarness({ autoCompleteWrites: false, diff --git a/src/components/terminal/terminalOutputDrain.ts b/src/components/terminal/terminalOutputDrain.ts index a68e22157..cf7a295f6 100644 --- a/src/components/terminal/terminalOutputDrain.ts +++ b/src/components/terminal/terminalOutputDrain.ts @@ -103,6 +103,7 @@ export class TerminalOutputDrain { private backgroundTimer: number | null = null; private ackTimer: number | null = null; private microtaskPending = false; + private foregroundTurnStartedAt: number | null = null; private disposed = false; constructor(private readonly options: TerminalOutputDrainOptions) { @@ -223,6 +224,7 @@ export class TerminalOutputDrain { private schedule() { if (this.disposed) return; if (!hasOutputQueueItems(this.queue)) { + this.foregroundTurnStartedAt = null; this.flushPendingAck(true); this.notifyPressure(); return; @@ -253,12 +255,13 @@ export class TerminalOutputDrain { if (delayMs > 0) { this.foregroundTimer = this.timers.setTimeout(() => { this.foregroundTimer = null; + this.foregroundTurnStartedAt = null; this.flushForeground(); }, delayMs); return; } - if (this.options.shouldUseLowLatencyFlush?.()) { + if (this.options.shouldUseLowLatencyFlush?.() && this.hasForegroundTurnBudgetRemaining()) { this.microtaskPending = true; this.timers.queueMicrotask(() => { this.microtaskPending = false; @@ -269,6 +272,7 @@ export class TerminalOutputDrain { this.foregroundFrame = this.timers.requestAnimationFrame(() => { this.foregroundFrame = null; + this.foregroundTurnStartedAt = null; this.flushForeground(); }); } @@ -286,6 +290,7 @@ export class TerminalOutputDrain { this.schedule(); return; } + this.beginForegroundTurn(); this.flushOne(this.options.getWriteChunkBytes()); } @@ -402,6 +407,7 @@ export class TerminalOutputDrain { this.foregroundTimer = null; } this.microtaskPending = false; + this.foregroundTurnStartedAt = null; } private cancelBackground() { @@ -421,4 +427,18 @@ export class TerminalOutputDrain { private notifyPressure() { this.options.onPressureChange?.(this.getPendingBytes()); } + + private beginForegroundTurn() { + if (this.foregroundTurnStartedAt === null) { + this.foregroundTurnStartedAt = this.timers.now(); + } + } + + private hasForegroundTurnBudgetRemaining() { + if (this.foregroundTurnStartedAt === null) return true; + return ( + this.timers.now() - this.foregroundTurnStartedAt < + XTERM_PERFORMANCE_CONFIG.output.maxForegroundDrainTurnMs + ); + } } diff --git a/src/components/terminal/terminalOutputScheduling.test.ts b/src/components/terminal/terminalOutputScheduling.test.ts new file mode 100644 index 000000000..5247fd4ef --- /dev/null +++ b/src/components/terminal/terminalOutputScheduling.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { XTERM_PERFORMANCE_CONFIG } from "@/lib/xtermPerformance"; +import { TerminalOutputScheduler } from "./terminalOutputScheduling"; + +describe("TerminalOutputScheduler", () => { + it("uses normal write chunks outside alternate screen", () => { + const scheduler = new TerminalOutputScheduler({ + getQueueBytes: () => 1024, + isAlternateScreenActive: () => false, + }); + + expect(scheduler.getWriteChunkBytes()).toBe(XTERM_PERFORMANCE_CONFIG.output.writeChunkBytes); + expect(scheduler.getForegroundDelayMs()).toBe(0); + }); + + it("uses alternate chunks without FPS delay until severe backlog", () => { + let queueBytes = XTERM_PERFORMANCE_CONFIG.output.alternateScreenThrottleBacklogBytes; + let now = 1000; + const scheduler = new TerminalOutputScheduler({ + getQueueBytes: () => queueBytes, + isAlternateScreenActive: () => true, + now: () => now, + }); + + expect(scheduler.getWriteChunkBytes()).toBe( + XTERM_PERFORMANCE_CONFIG.output.alternateScreenWriteChunkBytes, + ); + expect(scheduler.getForegroundDelayMs()).toBe(0); + + queueBytes += 1; + scheduler.noteWriteStart(); + now += 10; + + expect(scheduler.getForegroundDelayMs()).toBeGreaterThan(0); + }); +}); diff --git a/src/components/terminal/terminalOutputScheduling.ts b/src/components/terminal/terminalOutputScheduling.ts new file mode 100644 index 000000000..705ff7f5a --- /dev/null +++ b/src/components/terminal/terminalOutputScheduling.ts @@ -0,0 +1,74 @@ +import { XTERM_PERFORMANCE_CONFIG } from "@/lib/xtermPerformance"; + +export interface TerminalOutputSchedulerOptions { + getQueueBytes: () => number; + isAlternateScreenActive: () => boolean; + now?: () => number; +} + +export interface TerminalOutputSchedulingSnapshot { + alternateScreen: boolean; + queueBytes: number; + severeBacklog: boolean; + writeChunkBytes: number; + foregroundDelayMs: number; +} + +export class TerminalOutputScheduler { + private lastAlternateScreenWriteAt = 0; + private readonly now: () => number; + + constructor(private readonly options: TerminalOutputSchedulerOptions) { + this.now = options.now ?? (() => Date.now()); + } + + getWriteChunkBytes() { + return this.options.isAlternateScreenActive() + ? XTERM_PERFORMANCE_CONFIG.output.alternateScreenWriteChunkBytes + : XTERM_PERFORMANCE_CONFIG.output.writeChunkBytes; + } + + getForegroundDelayMs() { + if (!this.shouldUseSevereAlternateScreenThrottle()) return 0; + + const intervalMs = 1000 / XTERM_PERFORMANCE_CONFIG.output.alternateScreenMaxWriteFps; + const elapsedMs = this.now() - this.lastAlternateScreenWriteAt; + return this.lastAlternateScreenWriteAt > 0 && elapsedMs < intervalMs + ? Math.max(1, intervalMs - elapsedMs) + : 0; + } + + noteWriteStart() { + if (this.options.isAlternateScreenActive()) { + this.lastAlternateScreenWriteAt = this.now(); + } + } + + reset() { + this.lastAlternateScreenWriteAt = 0; + } + + snapshot(): TerminalOutputSchedulingSnapshot { + const queueBytes = this.options.getQueueBytes(); + const alternateScreen = this.options.isAlternateScreenActive(); + const severeBacklog = + alternateScreen && + queueBytes > XTERM_PERFORMANCE_CONFIG.output.alternateScreenThrottleBacklogBytes; + + return { + alternateScreen, + queueBytes, + severeBacklog, + writeChunkBytes: this.getWriteChunkBytes(), + foregroundDelayMs: this.getForegroundDelayMs(), + }; + } + + private shouldUseSevereAlternateScreenThrottle() { + return ( + this.options.isAlternateScreenActive() && + this.options.getQueueBytes() > + XTERM_PERFORMANCE_CONFIG.output.alternateScreenThrottleBacklogBytes + ); + } +} diff --git a/src/components/terminal/xterminalOutputQueue.test.ts b/src/components/terminal/xterminalOutputQueue.test.ts new file mode 100644 index 000000000..cc5673126 --- /dev/null +++ b/src/components/terminal/xterminalOutputQueue.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from "vitest"; +import { + createOutputQueue, + getOutputQueueDebugSnapshot, + hasOutputQueueItems, + peekOutputQueue, + pushOutputQueue, + replaceOutputQueueHead, + shiftOutputQueue, + splitOutputChunk, + type QueuedOutputChunk, +} from "./xterminalOutputQueue"; + +describe("OutputQueue", () => { + it("releases consumed chunk references while preserving order and bytes", () => { + const queue = createOutputQueue(); + const chunks: QueuedOutputChunk[] = Array.from({ length: 256 }, (_, index) => ({ + data: `${index}:`.padEnd(4096, "x"), + bytes: 4096, + })); + + for (const chunk of chunks) { + pushOutputQueue(queue, chunk); + } + + expect(queue.bytes).toBe(256 * 4096); + + for (let i = 0; i < 128; i += 1) { + expect(shiftOutputQueue(queue)).toBe(chunks[i]); + } + + const snapshot = getOutputQueueDebugSnapshot(queue); + expect(snapshot.bytes).toBe(128 * 4096); + expect(snapshot.liveSlots).toBe(128); + expect(snapshot.consumedSlots).toBe(128); + expect(queue.chunks.slice(0, queue.headIndex).every((slot) => slot === undefined)).toBe(true); + }); + + it("continues growing and consuming without retaining old consumed slots", () => { + const queue = createOutputQueue(); + const written: string[] = []; + + for (let round = 0; round < 40; round += 1) { + for (let i = 0; i < 10; i += 1) { + const data = `r${round}-c${i};`; + pushOutputQueue(queue, { data, bytes: data.length }); + } + + for (let i = 0; i < 7; i += 1) { + const chunk = shiftOutputQueue(queue); + if (chunk) written.push(chunk.data); + } + + expect(queue.chunks.slice(0, queue.headIndex).every((slot) => slot === undefined)).toBe( + true, + ); + } + + const remaining: string[] = []; + while (hasOutputQueueItems(queue)) { + const chunk = shiftOutputQueue(queue); + if (chunk) remaining.push(chunk.data); + } + + expect([...written, ...remaining].join("")).toBe( + Array.from({ length: 40 }, (_, round) => + Array.from({ length: 10 }, (_unused, i) => `r${round}-c${i};`).join(""), + ).join(""), + ); + expect(queue.bytes).toBe(0); + expect(getOutputQueueDebugSnapshot(queue).liveSlots).toBe(0); + }); + + it("handles split head replacement and final queue drain", () => { + const queue = createOutputQueue(); + const original = { data: "abcde", bytes: 5 }; + pushOutputQueue(queue, original); + pushOutputQueue(queue, { data: "fg", bytes: 2 }); + + const head = peekOutputQueue(queue); + expect(head).toBe(original); + expect(head).not.toBeNull(); + + const [splitHead, splitTail] = splitOutputChunk(head!, 2); + replaceOutputQueueHead(queue, splitTail); + queue.bytes = Math.max(0, queue.bytes - splitHead.bytes); + + expect(splitHead).toEqual({ data: "ab", bytes: 2 }); + expect(shiftOutputQueue(queue)).toEqual({ data: "cde", bytes: 3 }); + expect(queue.chunks.slice(0, queue.headIndex).every((slot) => slot === undefined)).toBe(true); + expect(shiftOutputQueue(queue)).toEqual({ data: "fg", bytes: 2 }); + expect(queue.bytes).toBe(0); + expect(hasOutputQueueItems(queue)).toBe(false); + }); + + it("does not retain a consumed large string reference in the queue slots", () => { + const queue = createOutputQueue(); + const large = "x".repeat(8 * 1024 * 1024); + const chunk = { data: large, bytes: large.length }; + + pushOutputQueue(queue, chunk); + expect(shiftOutputQueue(queue)).toBe(chunk); + + expect(queue.chunks.some((slot) => slot?.data === large)).toBe(false); + expect(getOutputQueueDebugSnapshot(queue)).toMatchObject({ + bytes: 0, + liveSlots: 0, + }); + }); +}); diff --git a/src/components/terminal/xterminalOutputQueue.ts b/src/components/terminal/xterminalOutputQueue.ts index 8dfc4d43f..5cde0bb72 100644 --- a/src/components/terminal/xterminalOutputQueue.ts +++ b/src/components/terminal/xterminalOutputQueue.ts @@ -8,7 +8,15 @@ export interface QueuedOutputChunk { } export interface OutputQueue { - chunks: QueuedOutputChunk[]; + chunks: Array; + headIndex: number; + bytes: number; +} + +export interface OutputQueueDebugSnapshot { + totalSlots: number; + liveSlots: number; + consumedSlots: number; headIndex: number; bytes: number; } @@ -147,6 +155,7 @@ export function pushOutputQueue(queue: OutputQueue, chunk: QueuedOutputChunk) { export function shiftOutputQueue(queue: OutputQueue): QueuedOutputChunk | null { const chunk = queue.chunks[queue.headIndex]; if (!chunk) return null; + queue.chunks[queue.headIndex] = undefined; queue.headIndex += 1; queue.bytes = Math.max(0, queue.bytes - chunk.bytes); compactOutputQueue(queue); @@ -167,6 +176,26 @@ export function hasOutputQueueItems(queue: OutputQueue) { return queue.headIndex < queue.chunks.length; } +export function getOutputQueueDebugSnapshot(queue: OutputQueue): OutputQueueDebugSnapshot { + let liveSlots = 0; + let consumedSlots = 0; + for (let i = 0; i < queue.chunks.length; i += 1) { + if (queue.chunks[i]) { + liveSlots += 1; + } else if (i < queue.headIndex) { + consumedSlots += 1; + } + } + + return { + totalSlots: queue.chunks.length, + liveSlots, + consumedSlots, + headIndex: queue.headIndex, + bytes: queue.bytes, + }; +} + export function outputQueueToBoundedString(queue: OutputQueue) { const maxBytes = XTERM_PERFORMANCE_CONFIG.lifecycle.snapshotMaxBytes; const parts: string[] = []; diff --git a/src/components/terminal/zmodemTerminalEvents.ts b/src/components/terminal/zmodemTerminalEvents.ts index 2373ec6b4..700dc517e 100644 --- a/src/components/terminal/zmodemTerminalEvents.ts +++ b/src/components/terminal/zmodemTerminalEvents.ts @@ -55,6 +55,8 @@ export interface ZmodemTransferProgressSink { fail: (id: string, reason: string) => void; } +type TerminalStatusWriter = (data: string) => void; + interface CurrentZmodemTransferFile { id: string; fileName: string; @@ -91,6 +93,7 @@ export function createZmodemEventHandler( getT: () => Translate, getDuplicateStrategy: () => string = () => "ask", progressSink?: ZmodemTransferProgressSink, + writeTerminalStatus: TerminalStatusWriter = (data) => terminal.write(data), ): ZmodemEventHandler { let pendingProgress: Extract | null = null; let progressRaf: number | null = null; @@ -146,7 +149,7 @@ export function createZmodemEventHandler( const totalSize = payload.totalSize ?? payload.total_size ?? 0; const percent = totalSize > 0 ? Math.round((bytesTransferred / totalSize) * 100) : 0; const t = getT(); - terminal.write(`\r\x1b[36m[ZMODEM] ${t("zmodem.downloading", { fileName, percent })}\x1b[K`); + writeTerminalStatus(`\r\x1b[36m[ZMODEM] ${t("zmodem.downloading", { fileName, percent })}\x1b[K`); }; const scheduleProgressRender = () => { @@ -272,7 +275,7 @@ export function createZmodemEventHandler( if (disposed) return; if (payload.direction === "download") { - terminal.write(`\r\n\x1b[36m[ZMODEM] ${t("zmodem.selectSaveDir")}\x1b[0m\r\n`); + writeTerminalStatus(`\r\n\x1b[36m[ZMODEM] ${t("zmodem.selectSaveDir")}\x1b[0m\r\n`); const dir = await openDialog({ directory: true, multiple: false }); if (disposed) return; if (dir) { @@ -282,7 +285,7 @@ export function createZmodemEventHandler( }); } else { await invoke("zmodem_cancel", { sessionId }); - terminal.write(`\r\n\x1b[33m[ZMODEM] ${t("zmodem.cancelled")}\x1b[0m\r\n`); + writeTerminalStatus(`\r\n\x1b[33m[ZMODEM] ${t("zmodem.cancelled")}\x1b[0m\r\n`); } return; } @@ -324,7 +327,7 @@ export function createZmodemEventHandler( if (resolvedPaths.length === 0) { await invoke("zmodem_cancel", { sessionId }); - terminal.write(`\r\n\x1b[33m[ZMODEM] ${t("zmodem.cancelled")}\x1b[0m\r\n`); + writeTerminalStatus(`\r\n\x1b[33m[ZMODEM] ${t("zmodem.cancelled")}\x1b[0m\r\n`); return; } @@ -340,7 +343,7 @@ export function createZmodemEventHandler( }); } else { await invoke("zmodem_cancel", { sessionId }); - terminal.write(`\r\n\x1b[33m[ZMODEM] ${t("zmodem.cancelled")}\x1b[0m\r\n`); + writeTerminalStatus(`\r\n\x1b[33m[ZMODEM] ${t("zmodem.cancelled")}\x1b[0m\r\n`); } }; @@ -375,7 +378,7 @@ export function createZmodemEventHandler( showUploadCompletedToast(); completePendingZmodemUpload(sessionId); } else { - terminal.write(`\r\n\x1b[32m[ZMODEM] ${getT()("zmodem.complete")}\x1b[0m\r\n`); + writeTerminalStatus(`\r\n\x1b[32m[ZMODEM] ${getT()("zmodem.complete")}\x1b[0m\r\n`); if (lastDownloadLocalPath) { void revealDownloadedFile(lastDownloadLocalPath); } @@ -404,7 +407,7 @@ export function createZmodemEventHandler( } failPendingZmodemUpload(sessionId, normalizedPayload.reason); } else { - terminal.write( + writeTerminalStatus( `\r\n\x1b[31m[ZMODEM] ${getT()("zmodem.failed", { reason: normalizedPayload.reason, })}\x1b[0m\r\n`, diff --git a/src/components/ui/context-menu.tsx b/src/components/ui/context-menu.tsx index 1af6dbafd..0301abd54 100644 --- a/src/components/ui/context-menu.tsx +++ b/src/components/ui/context-menu.tsx @@ -2,6 +2,7 @@ import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"; import { ContextMenu as ContextMenuPrimitive } from "radix-ui"; import type * as React from "react"; +import { isMacOS } from "@/lib/platform"; import { cn } from "@/lib/utils"; function ContextMenu({ ...props }: React.ComponentProps) { @@ -74,8 +75,19 @@ function ContextMenuSubContent({ function ContextMenuContent({ className, + onPointerUpCapture, ...props }: React.ComponentProps) { + const handlePointerUpCapture = (event: React.PointerEvent) => { + // Prevent Radix from converting a secondary pointerup into a click when the menu covers the cursor before right-button release. + if (event.button !== 0 || (isMacOS && event.ctrlKey)) { + event.preventDefault(); + event.stopPropagation(); + return; + } + onPointerUpCapture?.(event); + }; + return ( ); diff --git a/src/context/AppContext.tsx b/src/context/AppContext.tsx index f58f8ce96..68ed314b0 100644 --- a/src/context/AppContext.tsx +++ b/src/context/AppContext.tsx @@ -1,54 +1,4 @@ -import { listen } from "@tauri-apps/api/event"; -import { - createContext, - type ReactNode, - useCallback, - useContext, - useEffect, - useMemo, - useRef, - useState, -} from "react"; -import { useAppLockState } from "@/hooks/useAppLockState"; -import { DEFAULT_AI_SETTINGS } from "@/lib/aiSettings"; -import { DEFAULT_CLOUD_SYNC_SETTINGS } from "@/lib/cloudSync"; -import { updateConnectionAutoIconAfterSessionStart } from "@/lib/connectionAutoIcon"; -import { - DEFAULT_TERMINAL_FONT_FAMILY, - getDefaultUiFontFamily, -} from "@/lib/defaultFonts"; -import { getErrorMessage } from "@/lib/errors"; -import { - DEFAULT_COMMAND_SUGGESTION_MAX_CHARS, - DEFAULT_COMMAND_SUGGESTION_MIN_CHARS, - DEFAULT_TAB_DOUBLE_CLICK_ACTION, - DEFAULT_TAB_MIDDLE_CLICK_ACTION, - DEFAULT_TAB_RIGHT_CLICK_ACTION, -} from "@/lib/interactionSettings"; -import { - normalizeQuickCommandAppSettings, - normalizeQuickCommandUiConfig, -} from "@/lib/quickCommandSettings"; -import { - collectSessionPanes, - createFileDocumentPane, - createSessionPane, - createWorkspaceTab, - ensureActivePane, - findOpenFileDocument, - findSessionPaneById, - getFirstSessionPane, - getNextPersistOrder, - insertTabAfter, - moveTab, - removeSessionPane, - replaceSessionReferences as replacePaneSessionReferences, - restoreTabFromPersistence, - serializeTabsForPersistence, - splitSessionPane, - updateSessionPane, - updateSplitRatio as updateWorkspaceSplitRatio, -} from "@/lib/workspaceTabs"; +import { createContext, useContext } from "react"; import type { AppRuntimeInfo, AppSettings, @@ -65,17 +15,17 @@ import type { UiConfig, WorkspaceSessionType, } from "@/types/global"; -import { invoke } from "../lib/invoke"; -import { logger, setLoggerLevel } from "../lib/logger"; -import { DEFAULT_TERMINAL_FONT_SIZE } from "../lib/terminalFontSize"; -import { isPrimaryMainWindow } from "../lib/windowManager"; -type PaneConnectingUpdates = Partial> & { +export type PaneConnectingUpdates = Partial> & { display?: RemoteDesktopSessionPane["display"]; }; -interface AppContextType { - // Tabs +export interface PendingTabCreation { + tabId: string; + createRequestId: string; +} + +export interface AppContextType { tabs: Tab[]; activeTabId: string | null; setActiveTabId: (id: string | null) => void; @@ -87,7 +37,6 @@ interface AppContextType { extra?: Partial>, options?: { afterTabId?: string }, ) => string; - /** Immediately add a "connecting" tab and make it active. Returns the new tabId. */ addPendingTab: ( name: string, type: WorkspaceSessionType, @@ -96,24 +45,11 @@ interface AppContextType { options?: { afterTabId?: string }, paneOverrides?: Partial, ) => PendingTabCreation; - /** Swap the active pane's temporary sessionId for the real one and clear the connecting flag. */ updateTabSession: (tabId: string, sessionId: string) => void; - /** Mark the active pane in a tab as failed while keeping the tab visible. */ markTabConnectionFailed: (tabId: string, error: string) => void; - /** Update one specific pane's session binding. */ updatePaneSession: (tabId: string, paneId: string, sessionId: string) => void; - /** Replace every pane reference when a backend session reconnects with a new id. */ - replaceSessionReferences: ( - oldSessionId: string, - newSessionId: string, - ) => void; - /** Mark a specific pane as failed while keeping the layout intact. */ - markPaneConnectionFailed: ( - tabId: string, - paneId: string, - error: string, - ) => void; - /** Put a specific pane back into connecting state, optionally refreshing its metadata first. */ + replaceSessionReferences: (oldSessionId: string, newSessionId: string) => void; + markPaneConnectionFailed: (tabId: string, paneId: string, error: string) => void; markPaneConnecting: ( tabId: string, paneId: string, @@ -139,13 +75,8 @@ interface AppContextType { path: string; file: FileDocumentSnapshot; }) => { tabId: string; paneId: string; created: boolean }; - closePane: ( - tabId: string, - paneId: string, - options?: { immediatePersist?: boolean }, - ) => void; + closePane: (tabId: string, paneId: string, options?: { immediatePersist?: boolean }) => void; reorderTabs: (fromTabId: string, toIndex: number) => void; - /** Update user-editable tab properties (customName, tabColor, locked). */ updateTab: ( tabId: string, updates: Partial>, @@ -157,61 +88,34 @@ interface AppContextType { ) => void; closeTab: (tabId: string) => void; persistTabsNow: (extraUi?: Partial) => Promise; - - // App Settings (includes UI config) appSettings: AppSettings; updateAppSettings: ( - updates: - | Partial - | ((prev: AppSettings) => Partial), + updates: Partial | ((prev: AppSettings) => Partial), ) => void; replaceAppSettings: (next: AppSettings) => void; - updateUi: ( - updates: Partial | ((prev: UiConfig) => Partial), - ) => void; - - // Data + updateUi: (updates: Partial | ((prev: UiConfig) => Partial)) => void; savedConnections: SavedConnection[]; savedGroups: Group[]; refreshConnections: () => Promise; recordRecentConnection: (connectionId: string) => void; - - // Dialogs showNewSession: boolean; setShowNewSession: (show: boolean) => void; editingConnection: SavedConnection | undefined; setEditingConnection: (conn: SavedConnection | undefined) => void; showSettingsDialog: boolean; setShowSettingsDialog: (show: boolean) => void; - - // Sync Input Groups syncGroups: SyncGroup[]; - setSyncGroups: ( - groups: SyncGroup[] | ((prev: SyncGroup[]) => SyncGroup[]), - ) => void; + setSyncGroups: (groups: SyncGroup[] | ((prev: SyncGroup[]) => SyncGroup[])) => void; broadcastToAll: boolean; setBroadcastToAll: (value: boolean | ((prev: boolean) => boolean)) => void; - - // Idle Lock isLocked: boolean; setIsLocked: (locked: boolean) => void; - - // Loading settingsLoaded: boolean; startupRestoreComplete: boolean; runtimeInfo: AppRuntimeInfo; runtimeInfoLoaded: boolean; } -export interface PendingTabCreation { - tabId: string; - createRequestId: string; -} - -function createSessionRequestId() { - return crypto.randomUUID(); -} - export type TerminalAppSettings = Pick< AppSettings, | "appearance" @@ -224,1462 +128,9 @@ export type TerminalAppSettings = Pick< | "transfer" >; -/** - * App-wide state: tabs, settings (debounced save), saved connections (polled), - * and dialog visibility. Updates via setState/useCallback; config persisted to backend. - */ export const AppContext = createContext(null); -const TerminalAppSettingsContext = createContext( - null, -); +export const TerminalAppSettingsContext = createContext(null); -const DEFAULT_APP_SETTINGS: AppSettings = { - general: { - startup_restore: true, - startup_restore_window_layout: true, - minimize_to_tray: false, - boss_key: null, - confirm_on_close: true, - }, - appearance: { - theme: "github-dark", - custom_themes: [], - font_family: DEFAULT_TERMINAL_FONT_FAMILY, - ui_font_family: getDefaultUiFontFamily(), - font_size: DEFAULT_TERMINAL_FONT_SIZE, - font_weight: 400, - font_weight_bold: 700, - background_opacity: 1.0, - background_image_path: null, - background_image_fit: "cover", - background_image_opacity: 0.45, - cursor_style: "block", - cursor_blink: true, - ui_font_size: 16, - terminal_theme: null, - minimum_contrast_ratio: 1, - panel_multi_open: false, - window_transparency: "none", - window_transparency_tint: 1, - window_transparency_blur: false, - }, - proxy: { - enabled: false, - protocol: "socks5", - host: "127.0.0.1", - port: 1080, - }, - search: { - custom_engines: [ - { - name: "Google", - url_template: "https://google.com/search?q=%s", - show_in_menu: true, - }, - { - name: "Bing", - url_template: "https://bing.com/search?q=%s", - show_in_menu: true, - }, - { - name: "GitHub", - url_template: "https://github.com/search?q=%s", - show_in_menu: true, - }, - ], - }, - translation: { - target_language: "zh-CN", - deepl_api_key: "", - baidu_app_id: "", - baidu_app_key: "", - ali_app_id: "", - ali_app_key: "", - youdao_app_id: "", - youdao_app_key: "", - }, - security: { - use_os_keyring: true, - enable_screen_lock: false, - idle_lock_minutes: 0, - host_key_policy: "prompt", - }, - terminal: { - scrollback_lines: 10000, - keep_alive_mode: "compatible", - keep_alive_interval: 60, - font_size_delta: 0, - x11_display: "", - hardware_acceleration: false, - keyword_highlights_enabled: false, - keyword_highlights_across_wrapped_lines: false, - keyword_highlight_builtin_rules: {}, - keyword_highlights: [], - action_links_enabled: false, - action_links_matchers: { - ipv4: true, - archive: true, - host_port: true, - }, - show_workspace_padding: false, - show_line_numbers: false, - show_timestamps: false, - timestamp_format: "[HH:mm:ss]", - show_multi_line_paste_dialog: true, - paste_image_as_path: true, - }, - interaction: { - copy_on_select: false, - allow_osc52_clipboard_write: false, - right_click_paste: false, - terminal_zoom_enabled: true, - command_suggestions_enabled: true, - command_suggestion_min_chars: DEFAULT_COMMAND_SUGGESTION_MIN_CHARS, - command_suggestion_max_chars: DEFAULT_COMMAND_SUGGESTION_MAX_CHARS, - duplicate_session_command_delay_ms: 1000, - word_separators: " ()[]{}\"':=,;|&<>", - alt_as_meta: false, - ime_compatibility: false, - default_encoding: "UTF-8", - tab_double_click_action: DEFAULT_TAB_DOUBLE_CLICK_ACTION, - tab_middle_click_action: DEFAULT_TAB_MIDDLE_CLICK_ACTION, - tab_right_click_action: DEFAULT_TAB_RIGHT_CLICK_ACTION, - }, - recording: { - auto_start: false, - default_mode: "transcript", - base_path: "", - path_template: - "{group}/{session}/{yyyy}-{MM}-{dd}/{HH}-{mm}-{ss}-{SSS}-{session_short_id}.log", - include_timestamps: true, - include_io_labels: true, - include_session_metadata: true, - rotation: { type: "session" }, - existing_file_behavior: "unique", - memory_limit_bytes: 5 * 1024 * 1024, - include_binary_transfer_payloads: false, - }, - transfer: { - editor_type: "external", - download_threads: 3, - upload_threads: 3, - duplicate_strategy: "ask", - preserve_timestamps: true, - resume_broken_transfer: true, - default_file_permissions: "644", - max_transfer_retries: 2, - transfer_buffer_size: 32, - download_path: "", - ask_save_location: false, - default_editor: "", - recording_path: "", - recording_include_io_labels: true, - recording_include_timestamps: true, - recording_auto_start: false, - recording_memory_limit_bytes: 5 * 1024 * 1024, - }, - diagnostics: { - level: "info", - retention_days: 7, - }, - ai: { - ...DEFAULT_AI_SETTINGS, - }, - cloud_sync: DEFAULT_CLOUD_SYNC_SETTINGS, - ui: { - open_tabs: [], - terminal_window_layout: null, - start_workspace_mode: "workbench", - left_width: 256, - right_width: 288, - quick_cmd_height: 180, - quick_cmd_category_width: 176, - quick_cmd_view_mode: "tile", - quick_cmd_sort_mode: "created", - quick_cmd_selected_category: "all", - active_left_panel: "fileExplorer", - active_right_panel: "savedConnections", - left_open_panels: [], - right_open_panels: [], - panel_stack_sizes: {}, - network_panel_active_tab: "tunnel", - security_auth_panel_active_tab: "keys", - show_quick_cmd_bar: true, - show_serial_send_panel: false, - serial_send_height: 180, - zoom_level: 1.0, - language: "en", - header_status_mode: "session", - header_status_visible: true, - show_notes_panel: true, - show_remote_stats: true, - remote_stats_interval: 3, - show_gpu_monitor: false, - gpu_monitor_interval: 3, - show_ascend_npu_monitor: false, - ascend_npu_monitor_interval: 3, - show_process_manager: false, - process_manager_interval: 5, - show_docker_manager: false, - docker_manager_interval: 10, - saved_connections_sort_mode: "default", - saved_connections_expanded_group_ids: [], - asset_sort_key: null, - asset_sort_direction: null, - recent_connection_ids: [], - transfer_height: 180, - file_explorer_show_hidden_files: true, - file_explorer_auto_sync_cwd_connection_ids: [], - file_explorer_favorite_dirs_by_connection_id: {}, - notes_expanded_folder_ids: [], - notes_last_selected_node_id: null, - activity_bar_layout: { - left_top: ["fileExplorer", "notes", "network", "securityAuth"], - left_bottom: ["syncBackupHistory", "settings"], - right_top: [ - "savedConnections", - "aiAssistant", - "activeSessions", - "commandHistory", - "resourceMonitor", - "gpuMonitor", - "ascendNpuMonitor", - "processManager", - "dockerManager", - ], - right_bottom: ["quickCmdBar", "serialSend", "recording", "lock"], - show_labels: false, - }, - }, - keybindings: {}, -}; - -const RECENT_CONNECTION_LIMIT = 10; - -const DEFAULT_RUNTIME_INFO: AppRuntimeInfo = { - portable: false, - mode: "installed", - executableDir: "", - dataDir: "", - configDir: "", - logDir: "", - webviewDataDir: "", - portableMarkerPath: null, -}; - -function areSettingsValuesEqual(left: unknown, right: unknown): boolean { - if (Object.is(left, right)) return true; - if (typeof left !== typeof right) return false; - if (left === null || right === null) return left === right; - - if (Array.isArray(left) || Array.isArray(right)) { - if ( - !Array.isArray(left) || - !Array.isArray(right) || - left.length !== right.length - ) - return false; - for (let index = 0; index < left.length; index += 1) { - if (!areSettingsValuesEqual(left[index], right[index])) { - return false; - } - } - return true; - } - - if (typeof left !== "object" || typeof right !== "object") { - return false; - } - - const leftRecord = left as Record; - const rightRecord = right as Record; - const leftKeys = Object.keys(leftRecord); - const rightKeys = Object.keys(rightRecord); - if (leftKeys.length !== rightKeys.length) return false; - - for (const key of leftKeys) { - if (!(key in rightRecord)) return false; - if (!areSettingsValuesEqual(leftRecord[key], rightRecord[key])) { - return false; - } - } - - return true; -} - -function preserveAppSettingsReferences( - prev: AppSettings, - next: AppSettings, -): AppSettings { - const general = areSettingsValuesEqual(prev.general, next.general) - ? prev.general - : next.general; - const appearance = areSettingsValuesEqual(prev.appearance, next.appearance) - ? prev.appearance - : next.appearance; - const proxy = areSettingsValuesEqual(prev.proxy, next.proxy) - ? prev.proxy - : next.proxy; - const search = areSettingsValuesEqual(prev.search, next.search) - ? prev.search - : next.search; - const translation = areSettingsValuesEqual(prev.translation, next.translation) - ? prev.translation - : next.translation; - const security = areSettingsValuesEqual(prev.security, next.security) - ? prev.security - : next.security; - const terminal = areSettingsValuesEqual(prev.terminal, next.terminal) - ? prev.terminal - : next.terminal; - const interaction = areSettingsValuesEqual(prev.interaction, next.interaction) - ? prev.interaction - : next.interaction; - const transfer = areSettingsValuesEqual(prev.transfer, next.transfer) - ? prev.transfer - : next.transfer; - const diagnostics = areSettingsValuesEqual(prev.diagnostics, next.diagnostics) - ? prev.diagnostics - : next.diagnostics; - const ai = areSettingsValuesEqual(prev.ai, next.ai) ? prev.ai : next.ai; - const cloudSync = areSettingsValuesEqual(prev.cloud_sync, next.cloud_sync) - ? prev.cloud_sync - : next.cloud_sync; - const ui = areSettingsValuesEqual(prev.ui, next.ui) ? prev.ui : next.ui; - const keybindings = areSettingsValuesEqual(prev.keybindings, next.keybindings) - ? prev.keybindings - : next.keybindings; - - if ( - general === prev.general && - appearance === prev.appearance && - proxy === prev.proxy && - search === prev.search && - translation === prev.translation && - security === prev.security && - terminal === prev.terminal && - interaction === prev.interaction && - transfer === prev.transfer && - diagnostics === prev.diagnostics && - ai === prev.ai && - cloudSync === prev.cloud_sync && - ui === prev.ui && - keybindings === prev.keybindings - ) { - return prev; - } - - return { - ...next, - general, - appearance, - proxy, - search, - translation, - security, - terminal, - interaction, - transfer, - diagnostics, - ai, - cloud_sync: cloudSync, - ui, - keybindings, - }; -} - -/** Provides tabs, appSettings, savedConnections, and dialog state to the app. */ -export function AppProvider({ children }: { children: ReactNode }) { - // Tabs State - const [tabs, setTabs] = useState([]); - const tabsRef = useRef([]); - const [activeTabIdState, setActiveTabIdState] = useState(null); - const activeTabIdRef = useRef(null); - - // App Settings State (includes UI config) - const [appSettings, setAppSettings] = - useState(DEFAULT_APP_SETTINGS); - const appSettingsRef = useRef(DEFAULT_APP_SETTINGS); - const appSettingsLoaded = useRef(false); - const appSettingsSaveTimerRef = useRef | null>( - null, - ); - const uiSaveTimerRef = useRef | null>(null); - - // Data State - const [savedConnections, setSavedConnections] = useState( - [], - ); - const [savedGroups, setSavedGroups] = useState([]); - - // Dialog State - const [showNewSession, setShowNewSession] = useState(false); - const [editingConnection, setEditingConnection] = useState< - SavedConnection | undefined - >(undefined); - const [showSettingsDialog, setShowSettingsDialog] = useState(false); - - // Sync Input Groups - const [syncGroups, setSyncGroups] = useState([]); - const [broadcastToAll, setBroadcastToAll] = useState(false); - - // Idle Lock State - const { isLocked, setIsLocked, lockStateLoaded } = useAppLockState(); - - // Loading State - const [settingsLoaded, setSettingsLoaded] = useState(false); - const [startupRestoreComplete, setStartupRestoreComplete] = useState(false); - const [runtimeInfo, setRuntimeInfo] = - useState(DEFAULT_RUNTIME_INFO); - const [runtimeInfoLoaded, setRuntimeInfoLoaded] = useState(false); - - const setActiveTabId = useCallback((id: string | null) => { - activeTabIdRef.current = id; - setActiveTabIdState(id); - }, []); - - // 1. Load App Settings - useEffect(() => { - invoke("get_app_runtime_info") - .then((info) => { - setRuntimeInfo(info); - }) - .catch((error) => { - logger.error({ - domain: "app.lifecycle", - event: "runtime_info.load_failed", - message: "Failed to load app runtime info", - error, - }); - }) - .finally(() => { - setRuntimeInfoLoaded(true); - }); - - invoke("get_app_settings") - .then((cfg) => { - const normalized = normalizeQuickCommandAppSettings(cfg); - appSettingsRef.current = normalized; - setAppSettings(normalized); - setLoggerLevel(normalized.diagnostics.level); - appSettingsLoaded.current = true; - setSettingsLoaded(true); - if (isPrimaryMainWindow() && normalized.security?.enable_screen_lock) { - setIsLocked(true); - } - }) - .catch(() => { - appSettingsRef.current = DEFAULT_APP_SETTINGS; - appSettingsLoaded.current = true; - setAppSettings(DEFAULT_APP_SETTINGS); - setSettingsLoaded(true); - }); - }, [setIsLocked]); - - // Apply UI font size to root element - useEffect(() => { - document.documentElement.style.fontSize = `${appSettings.appearance.ui_font_size}px`; - }, [appSettings.appearance.ui_font_size]); - - useEffect(() => { - const fontFamily = appSettings.appearance.ui_font_family; - document.documentElement.style.setProperty("--font-sans", fontFamily); - document.documentElement.style.setProperty("--font-display", fontFamily); - }, [appSettings.appearance.ui_font_family]); - - // 2. Save App Settings Debounced - const updateAppSettings = useCallback( - ( - updates: - | Partial - | ((prev: AppSettings) => Partial), - ) => { - setAppSettings((prev) => { - const nextUpdates = - typeof updates === "function" ? updates(prev) : updates; - const next = normalizeQuickCommandAppSettings({ - ...prev, - ...nextUpdates, - }); - appSettingsRef.current = next; - setLoggerLevel(next.diagnostics.level); - if (appSettingsLoaded.current) { - if (appSettingsSaveTimerRef.current) - clearTimeout(appSettingsSaveTimerRef.current); - appSettingsSaveTimerRef.current = setTimeout(() => { - invoke("save_app_settings", { settings: next }).catch((e) => - logger.error({ - domain: "settings.persistence", - event: "settings.save_failed", - message: "Failed to save app settings", - error: e, - }), - ); - }, 500); - } - return next; - }); - }, - [], - ); - - const replaceAppSettings = useCallback((next: AppSettings) => { - if (appSettingsSaveTimerRef.current) { - clearTimeout(appSettingsSaveTimerRef.current); - appSettingsSaveTimerRef.current = null; - } - setAppSettings((current) => { - const normalized = preserveAppSettingsReferences( - current, - normalizeQuickCommandAppSettings(next), - ); - appSettingsRef.current = normalized; - setLoggerLevel(normalized.diagnostics.level); - return normalized; - }); - }, []); - - // Convenience helper to update just the UI config portion via lightweight path - const updateUi = useCallback( - (updates: Partial | ((prev: UiConfig) => Partial)) => { - setAppSettings((prev) => { - const nextUpdates = - typeof updates === "function" ? updates(prev.ui) : updates; - const nextUi = normalizeQuickCommandUiConfig({ - ...prev.ui, - ...nextUpdates, - }); - const next = { ...prev, ui: nextUi }; - appSettingsRef.current = next; - if (appSettingsLoaded.current) { - if (uiSaveTimerRef.current) clearTimeout(uiSaveTimerRef.current); - uiSaveTimerRef.current = setTimeout(() => { - invoke("save_app_ui_settings", { ui: nextUi }).catch((e) => - logger.error({ - domain: "settings.persistence", - event: "ui_settings.save_failed", - message: "Failed to save UI settings", - error: e, - }), - ); - }, 500); - } - return next; - }); - }, - [], - ); - - const recordRecentConnection = useCallback( - (connectionId: string) => { - if (!connectionId) return; - updateUi((prev) => ({ - recent_connection_ids: [ - connectionId, - ...(prev.recent_connection_ids ?? []).filter( - (id) => id !== connectionId, - ), - ].slice(0, RECENT_CONNECTION_LIMIT), - })); - }, - [updateUi], - ); - - // 3. Load Connections - const refreshConnections = useCallback(async () => { - try { - const [saved, groups] = await Promise.all([ - invoke("get_saved_connections"), - invoke("get_groups"), - ]); - setSavedConnections(saved); - setSavedGroups(groups); - } catch (e) { - logger.error({ - domain: "ui.error", - event: "connections.fetch_failed", - message: "Failed to fetch connections", - error: e, - }); - } - }, []); - - useEffect(() => { - refreshConnections(); - const unlisten = listen("connections-changed", () => { - refreshConnections(); - }); - return () => { - unlisten.then((fn) => fn()); - }; - }, [refreshConnections]); - - const syncOpenTabs = useCallback( - async (nextTabs: Tab[], options?: { immediatePersist?: boolean }) => { - if ( - !hasRestored.current || - !appSettingsRef.current.general.startup_restore - ) - return; - - const openTabs = serializeTabsForPersistence(nextTabs); - updateUi({ open_tabs: openTabs }); - - if (!options?.immediatePersist) return; - - const nextUi = { ...appSettingsRef.current.ui, open_tabs: openTabs }; - appSettingsRef.current = { ...appSettingsRef.current, ui: nextUi }; - await invoke("save_app_ui_settings", { ui: nextUi }); - }, - [updateUi], - ); - - const commitTabs = useCallback( - async ( - nextTabs: Tab[], - options?: { - syncPersisted?: boolean; - immediatePersist?: boolean; - }, - ) => { - const normalizedTabs = nextTabs.map(ensureActivePane); - tabsRef.current = normalizedTabs; - setTabs(normalizedTabs); - - if (options?.syncPersisted === false) return; - await syncOpenTabs(normalizedTabs, { - immediatePersist: options?.immediatePersist, - }); - }, - [syncOpenTabs], - ); - - // 4. Tab Logic - const addTab = useCallback( - ( - sessionId: string, - name: string, - type: WorkspaceSessionType, - connectionId?: string, - extra?: Partial>, - options?: { afterTabId?: string }, - ) => { - const pane = createSessionPane(name, type, connectionId, { sessionId }); - const newTab = createWorkspaceTab( - pane, - getNextPersistOrder(tabsRef.current), - extra, - ); - const nextTabs = options?.afterTabId - ? insertTabAfter(tabsRef.current, options.afterTabId, newTab) - : [...tabsRef.current, newTab]; - void commitTabs(nextTabs); - setActiveTabId(newTab.id); - - // Close dialogs when session starts - setShowNewSession(false); - setEditingConnection(undefined); - return newTab.id; - }, - [commitTabs, setActiveTabId], - ); - - const addPendingTab = useCallback( - ( - name: string, - type: WorkspaceSessionType, - connectionId?: string, - extra?: Partial>, - options?: { afterTabId?: string }, - paneOverrides?: Partial, - ): PendingTabCreation => { - const createRequestId = createSessionRequestId(); - const pane = createSessionPane(name, type, connectionId, { - ...paneOverrides, - connecting: true, - createRequestId, - }); - const newTab = createWorkspaceTab( - pane, - getNextPersistOrder(tabsRef.current), - extra, - ); - const nextTabs = options?.afterTabId - ? insertTabAfter(tabsRef.current, options.afterTabId, newTab) - : [...tabsRef.current, newTab]; - void commitTabs(nextTabs); - setActiveTabId(newTab.id); - return { tabId: newTab.id, createRequestId }; - }, - [commitTabs, setActiveTabId], - ); - - const updateTabSession = useCallback( - (tabId: string, sessionId: string) => { - const tab = tabsRef.current.find((item) => item.id === tabId); - if (!tab) return; - const paneId = tab.activePaneId; - const nextTabs = tabsRef.current.map((item) => - item.id === tabId - ? { - ...item, - root: updateSessionPane(item.root, paneId, { - sessionId, - connecting: false, - connectError: undefined, - createRequestId: undefined, - }), - } - : item, - ); - void commitTabs(nextTabs); - }, - [commitTabs], - ); - - const markTabConnectionFailed = useCallback( - (tabId: string, error: string) => { - const tab = tabsRef.current.find((item) => item.id === tabId); - if (!tab) return; - const paneId = tab.activePaneId; - const nextTabs = tabsRef.current.map((item) => - item.id === tabId - ? { - ...item, - root: updateSessionPane(item.root, paneId, { - connecting: false, - connectError: error, - createRequestId: undefined, - }), - } - : item, - ); - void commitTabs(nextTabs); - }, - [commitTabs], - ); - - const updatePaneSession = useCallback( - (tabId: string, paneId: string, sessionId: string) => { - const nextTabs = tabsRef.current.map((tab) => - tab.id === tabId - ? { - ...tab, - root: updateSessionPane(tab.root, paneId, { - sessionId, - connecting: false, - connectError: undefined, - createRequestId: undefined, - }), - } - : tab, - ); - void commitTabs(nextTabs); - }, - [commitTabs], - ); - - const replaceSessionReferences = useCallback( - (oldSessionId: string, newSessionId: string) => { - const nextTabs = tabsRef.current.map((tab) => ({ - ...tab, - root: replacePaneSessionReferences( - tab.root, - oldSessionId, - newSessionId, - ), - })); - void commitTabs(nextTabs); - }, - [commitTabs], - ); - - const markPaneConnectionFailed = useCallback( - (tabId: string, paneId: string, error: string) => { - const nextTabs = tabsRef.current.map((tab) => - tab.id === tabId - ? { - ...tab, - root: updateSessionPane(tab.root, paneId, { - connecting: false, - connectError: error, - createRequestId: undefined, - }), - } - : tab, - ); - void commitTabs(nextTabs); - }, - [commitTabs], - ); - - const markPaneConnecting = useCallback( - (tabId: string, paneId: string, updates?: PaneConnectingUpdates) => { - const createRequestId = createSessionRequestId(); - const nextTabs = tabsRef.current.map((tab) => - tab.id === tabId - ? { - ...tab, - root: updateSessionPane(tab.root, paneId, { - ...updates, - connecting: true, - connectError: undefined, - createRequestId, - }), - } - : tab, - ); - void commitTabs(nextTabs); - return tabsRef.current.some((tab) => tab.id === tabId) - ? createRequestId - : null; - }, - [commitTabs], - ); - - const hasTab = useCallback((tabId: string) => { - return tabsRef.current.some((tab) => tab.id === tabId); - }, []); - - const hasPane = useCallback((tabId: string, paneId: string) => { - const tab = tabsRef.current.find((item) => item.id === tabId); - return !!tab && !!findSessionPaneById(tab.root, paneId); - }, []); - - const setActivePane = useCallback( - (tabId: string, paneId: string) => { - const nextTabs = tabsRef.current.map((tab) => - tab.id === tabId - ? ensureActivePane({ ...tab, activePaneId: paneId }) - : tab, - ); - void commitTabs(nextTabs); - setActiveTabId(tabId); - }, - [commitTabs, setActiveTabId], - ); - - const splitPane = useCallback( - ( - tabId: string, - paneId: string, - direction: PaneSplitDirection, - pane: SessionPane, - options?: { immediatePersist?: boolean }, - ) => { - const tab = tabsRef.current.find((item) => item.id === tabId); - if (!tab) return null; - - const nextTabs = tabsRef.current.map((item) => - item.id === tabId - ? ensureActivePane({ - ...item, - activePaneId: pane.id, - root: splitSessionPane(item.root, paneId, direction, pane), - }) - : item, - ); - void commitTabs(nextTabs, { - immediatePersist: options?.immediatePersist, - }); - setActiveTabId(tabId); - return pane.id; - }, - [commitTabs, setActiveTabId], - ); - - const openFileDocument = useCallback( - (input: { - sessionId: string; - name: string; - type: SessionType; - connectionId?: string; - backend: FileDocumentBackend; - path: string; - file: FileDocumentSnapshot; - }) => { - const existing = findOpenFileDocument(tabsRef.current, input); - if (existing) { - setActivePane(existing.tabId, existing.paneId); - return { ...existing, created: false }; - } - - const pane = createFileDocumentPane(input); - const tab = createWorkspaceTab( - pane, - getNextPersistOrder(tabsRef.current), - ); - void commitTabs([...tabsRef.current, tab]); - setActiveTabId(tab.id); - return { tabId: tab.id, paneId: pane.id, created: true }; - }, - [commitTabs, setActivePane, setActiveTabId], - ); - - const updateSplitRatio = useCallback( - (tabId: string, splitId: string, ratio: number) => { - const nextTabs = tabsRef.current.map((tab) => - tab.id === tabId - ? { - ...tab, - root: updateWorkspaceSplitRatio(tab.root, splitId, ratio), - } - : tab, - ); - void commitTabs(nextTabs); - }, - [commitTabs], - ); - - const closePane = useCallback( - ( - tabId: string, - paneId: string, - options?: { immediatePersist?: boolean }, - ) => { - const currentTabs = tabsRef.current; - const index = currentTabs.findIndex((item) => item.id === tabId); - if (index === -1) return; - - const tab = currentTabs[index]; - const nextRoot = removeSessionPane(tab.root, paneId); - - if (!nextRoot) { - const nextTabs = currentTabs.filter((item) => item.id !== tabId); - if (activeTabIdRef.current === tabId) { - const fallback = - nextTabs[Math.max(0, index - 1)] ?? nextTabs[0] ?? null; - setActiveTabId(fallback?.id ?? null); - } - void commitTabs(nextTabs, { - immediatePersist: options?.immediatePersist, - }); - return; - } - - const nextActivePaneId = - tab.activePaneId === paneId - ? (getFirstSessionPane(nextRoot)?.id ?? tab.activePaneId) - : tab.activePaneId; - - const nextTabs = currentTabs.map((item) => - item.id === tabId - ? ensureActivePane({ - ...item, - activePaneId: nextActivePaneId, - root: nextRoot, - }) - : item, - ); - void commitTabs(nextTabs, { - immediatePersist: options?.immediatePersist, - }); - }, - [commitTabs, setActiveTabId], - ); - - const updateTab = useCallback( - async ( - tabId: string, - updates: Partial>, - options?: { immediatePersist?: boolean }, - ) => { - const nextTabs = tabsRef.current.map((tab) => - tab.id === tabId ? { ...tab, ...updates } : tab, - ); - await commitTabs(nextTabs, { - immediatePersist: options?.immediatePersist, - }); - }, - [commitTabs], - ); - - const closeTabs = useCallback( - ( - tabIds: string[], - options?: { immediatePersist?: boolean; nextActiveTabId?: string | null }, - ) => { - if (tabIds.length === 0) return; - - const idsToClose = new Set(tabIds); - const currentTabs = tabsRef.current; - const nextTabs = currentTabs.filter((tab) => !idsToClose.has(tab.id)); - const currentActiveTabId = activeTabIdRef.current; - - let nextActiveTabId = - options?.nextActiveTabId !== undefined - ? options.nextActiveTabId - : currentActiveTabId; - - if ( - nextActiveTabId && - !nextTabs.some((tab) => tab.id === nextActiveTabId) - ) { - nextActiveTabId = null; - } - - if ( - !nextActiveTabId && - currentActiveTabId && - idsToClose.has(currentActiveTabId) - ) { - const activeIndex = currentTabs.findIndex( - (tab) => tab.id === currentActiveTabId, - ); - const fallbackTab = - nextTabs[Math.max(0, activeIndex - 1)] ?? nextTabs[0] ?? null; - nextActiveTabId = fallbackTab?.id ?? null; - } - - if (!nextActiveTabId && nextTabs.length > 0) { - nextActiveTabId = nextTabs[0].id; - } - - if (nextActiveTabId !== currentActiveTabId) { - setActiveTabId(nextActiveTabId); - } - - void commitTabs(nextTabs, { - immediatePersist: options?.immediatePersist, - }); - }, - [commitTabs, setActiveTabId], - ); - - const closeTab = useCallback( - (tabId: string) => { - closeTabs([tabId]); - }, - [closeTabs], - ); - - const reorderTabs = useCallback( - (fromTabId: string, toIndex: number) => { - const nextTabs = moveTab(tabsRef.current, fromTabId, toIndex); - void commitTabs(nextTabs, { syncPersisted: false }); - }, - [commitTabs], - ); - - const persistTabsNow = useCallback(async (extraUi?: Partial) => { - if (!hasRestored.current || !appSettingsRef.current.general.startup_restore) - return; - const nextUi = { - ...appSettingsRef.current.ui, - open_tabs: serializeTabsForPersistence(tabsRef.current), - ...extraUi, - }; - appSettingsRef.current = { ...appSettingsRef.current, ui: nextUi }; - await invoke("save_app_ui_settings", { ui: nextUi }); - }, []); - - const closeStaleCreatedSession = useCallback(async (sessionId: string) => { - try { - await invoke("close_session", { sessionId }); - } catch (error) { - logger.error({ - domain: "session.lifecycle", - event: "session.stale_close_failed", - message: "Failed to close stale restored session", - ids: { session_id: sessionId }, - error, - }); - } - }, []); - - const handleRestoredSessionCreated = useCallback( - async ( - tabId: string, - paneId: string, - sessionId: string, - connectionId?: string, - ) => { - if (!hasPane(tabId, paneId)) { - await closeStaleCreatedSession(sessionId); - return; - } - updatePaneSession(tabId, paneId, sessionId); - if (connectionId) { - void updateConnectionAutoIconAfterSessionStart({ - connectionId, - sessionId, - remoteStatsEnabled: - appSettingsRef.current.ui.show_remote_stats ?? true, - }); - } - }, - [closeStaleCreatedSession, hasPane, updatePaneSession], - ); - - const handleRestoredSessionFailed = useCallback( - ( - tabId: string, - paneId: string, - sessionType: WorkspaceSessionType, - connectionId: string | undefined, - error: unknown, - ) => { - const errorMessage = getErrorMessage(error); - if ( - errorMessage.toLowerCase().includes("session creation cancelled") || - !hasPane(tabId, paneId) - ) { - return; - } - logger.error({ - domain: "session.lifecycle", - event: "session.restore_failed", - message: `Restore ${sessionType} failed`, - ids: connectionId ? { connection_id: connectionId } : undefined, - data: { - session_type: sessionType, - pane_id: paneId, - }, - error, - }); - markPaneConnectionFailed(tabId, paneId, errorMessage); - }, - [hasPane, markPaneConnectionFailed], - ); - - // 5. Startup Restore Logic - const hasRestored = useRef(false); - const pendingLockedStartupRestoreTabsRef = useRef(null); - - const restoreSessionsForTabs = useCallback( - (tabsToRestore: Tab[]) => { - tabsToRestore.forEach((tab) => { - const panes = collectSessionPanes(tab.root); - - panes.forEach((pane) => { - if (!hasPane(tab.id, pane.id)) return; - - const cid = pane.connectionId; - switch (pane.type) { - case "SSH": - if (!cid) { - markPaneConnectionFailed( - tab.id, - pane.id, - "Missing SSH connection id", - ); - return; - } - invoke("create_ssh_session", { - connectionId: cid, - createRequestId: pane.createRequestId, - }) - .then((sessionId) => - handleRestoredSessionCreated(tab.id, pane.id, sessionId, cid), - ) - .catch((e) => - handleRestoredSessionFailed( - tab.id, - pane.id, - "SSH", - pane.connectionId, - e, - ), - ); - break; - case "Local": - invoke("create_local_session", { - connectionId: cid || null, - createRequestId: pane.createRequestId, - }) - .then((sessionId) => - handleRestoredSessionCreated(tab.id, pane.id, sessionId), - ) - .catch((e) => - handleRestoredSessionFailed( - tab.id, - pane.id, - "Local", - pane.connectionId, - e, - ), - ); - break; - case "Telnet": - if (!cid) { - markPaneConnectionFailed( - tab.id, - pane.id, - "Missing Telnet connection id", - ); - return; - } - invoke("create_telnet_session", { - connectionId: cid, - createRequestId: pane.createRequestId, - }) - .then((sessionId) => - handleRestoredSessionCreated(tab.id, pane.id, sessionId), - ) - .catch((e) => - handleRestoredSessionFailed( - tab.id, - pane.id, - "Telnet", - pane.connectionId, - e, - ), - ); - break; - case "Serial": - if (!cid) { - markPaneConnectionFailed( - tab.id, - pane.id, - "Missing Serial connection id", - ); - return; - } - invoke("create_serial_session", { - connectionId: cid, - createRequestId: pane.createRequestId, - }) - .then((sessionId) => - handleRestoredSessionCreated(tab.id, pane.id, sessionId), - ) - .catch((e) => - handleRestoredSessionFailed( - tab.id, - pane.id, - "Serial", - pane.connectionId, - e, - ), - ); - break; - case "VNC": - if (!cid) { - markPaneConnectionFailed(tab.id, pane.id, "Missing VNC connection id"); - return; - } - invoke("create_vnc_session", { - connectionId: cid, - createRequestId: pane.createRequestId, - }) - .then((sessionId) => handleRestoredSessionCreated(tab.id, pane.id, sessionId, cid)) - .catch((e) => - handleRestoredSessionFailed(tab.id, pane.id, "VNC", pane.connectionId, e), - ); - break; - case "RDP": - if (!cid) { - markPaneConnectionFailed( - tab.id, - pane.id, - "Missing RDP connection id", - ); - return; - } - invoke("create_rdp_session", { - connectionId: cid, - createRequestId: pane.createRequestId, - }) - .then((sessionId) => - handleRestoredSessionCreated(tab.id, pane.id, sessionId, cid), - ) - .catch((e) => - handleRestoredSessionFailed( - tab.id, - pane.id, - "RDP", - pane.connectionId, - e, - ), - ); - break; - } - }); - }); - }, - [ - handleRestoredSessionCreated, - handleRestoredSessionFailed, - hasPane, - markPaneConnectionFailed, - ], - ); - - useEffect(() => { - if (hasRestored.current || !appSettingsLoaded.current || !lockStateLoaded) - return; - - hasRestored.current = true; - if ( - isPrimaryMainWindow() && - appSettings.general.startup_restore && - appSettings.ui.open_tabs && - appSettings.ui.open_tabs.length > 0 - ) { - const restoredTabs = appSettings.ui.open_tabs - .map((tab, index) => restoreTabFromPersistence(tab, index)) - .filter((tab): tab is Tab => tab !== null); - - tabsRef.current = restoredTabs; - setTabs(restoredTabs); - if (restoredTabs.length > 0) { - setActiveTabId(restoredTabs[restoredTabs.length - 1].id); - } - - if (appSettings.security.enable_screen_lock && isLocked) { - pendingLockedStartupRestoreTabsRef.current = restoredTabs; - } else { - restoreSessionsForTabs(restoredTabs); - } - } - - setStartupRestoreComplete(true); - }, [ - appSettings, - isLocked, - lockStateLoaded, - restoreSessionsForTabs, - setActiveTabId, - ]); - - useEffect(() => { - if (isLocked) return; - - const pendingTabs = pendingLockedStartupRestoreTabsRef.current; - if (!pendingTabs) return; - - pendingLockedStartupRestoreTabsRef.current = null; - restoreSessionsForTabs(pendingTabs); - }, [isLocked, restoreSessionsForTabs]); - - const contextValue = useMemo( - () => ({ - tabs, - activeTabId: activeTabIdState, - setActiveTabId, - addTab, - addPendingTab, - updateTabSession, - markTabConnectionFailed, - updatePaneSession, - replaceSessionReferences, - markPaneConnectionFailed, - markPaneConnecting, - hasTab, - hasPane, - setActivePane, - updateSplitRatio, - splitPane, - openFileDocument, - closePane, - reorderTabs, - updateTab, - closeTabs, - closeTab, - persistTabsNow, - appSettings, - updateAppSettings, - replaceAppSettings, - updateUi, - savedConnections, - savedGroups, - refreshConnections, - recordRecentConnection, - showNewSession, - setShowNewSession, - editingConnection, - setEditingConnection, - showSettingsDialog, - setShowSettingsDialog, - syncGroups, - setSyncGroups, - broadcastToAll, - setBroadcastToAll, - isLocked, - setIsLocked, - settingsLoaded, - startupRestoreComplete, - runtimeInfo, - runtimeInfoLoaded, - }), - [ - tabs, - activeTabIdState, - setActiveTabId, - addTab, - addPendingTab, - updateTabSession, - markTabConnectionFailed, - updatePaneSession, - replaceSessionReferences, - markPaneConnectionFailed, - markPaneConnecting, - hasTab, - hasPane, - setActivePane, - updateSplitRatio, - splitPane, - openFileDocument, - closePane, - reorderTabs, - updateTab, - closeTabs, - closeTab, - persistTabsNow, - appSettings, - updateAppSettings, - replaceAppSettings, - updateUi, - savedConnections, - savedGroups, - refreshConnections, - recordRecentConnection, - showNewSession, - editingConnection, - showSettingsDialog, - syncGroups, - broadcastToAll, - isLocked, - setIsLocked, - settingsLoaded, - startupRestoreComplete, - runtimeInfo, - runtimeInfoLoaded, - ], - ); - - const terminalAppSettingsValue = useMemo( - () => ({ - appearance: appSettings.appearance, - interaction: appSettings.interaction, - terminal: appSettings.terminal, - translation: appSettings.translation, - search: appSettings.search, - ai: appSettings.ai, - keybindings: appSettings.keybindings, - transfer: appSettings.transfer, - }), - [ - appSettings.appearance, - appSettings.interaction, - appSettings.terminal, - appSettings.translation, - appSettings.search, - appSettings.ai, - appSettings.keybindings, - appSettings.transfer, - ], - ); - - return ( - - - {lockStateLoaded && settingsLoaded ? children : null} - - - ); -} - -/** Hook to access AppContext. Throws if used outside AppProvider. */ export function useApp() { const context = useContext(AppContext); if (!context) throw new Error("useApp must be used within AppProvider"); diff --git a/src/context/AppProvider.tsx b/src/context/AppProvider.tsx new file mode 100644 index 000000000..5579a9208 --- /dev/null +++ b/src/context/AppProvider.tsx @@ -0,0 +1,1345 @@ +import { listen } from "@tauri-apps/api/event"; +import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useAppLockState } from "@/hooks/useAppLockState"; +import { DEFAULT_AI_SETTINGS } from "@/lib/aiSettings"; +import { DEFAULT_CLOUD_SYNC_SETTINGS } from "@/lib/cloudSync"; +import { updateConnectionAutoIconAfterSessionStart } from "@/lib/connectionAutoIcon"; +import { DEFAULT_TERMINAL_FONT_FAMILY, getDefaultUiFontFamily } from "@/lib/defaultFonts"; +import { getErrorMessage } from "@/lib/errors"; +import { + DEFAULT_COMMAND_SUGGESTION_MAX_CHARS, + DEFAULT_COMMAND_SUGGESTION_MIN_CHARS, + DEFAULT_TAB_DOUBLE_CLICK_ACTION, + DEFAULT_TAB_MIDDLE_CLICK_ACTION, + DEFAULT_TAB_RIGHT_CLICK_ACTION, +} from "@/lib/interactionSettings"; +import { + normalizeQuickCommandAppSettings, + normalizeQuickCommandUiConfig, +} from "@/lib/quickCommandSettings"; +import { + collectSessionPanes, + createFileDocumentPane, + createSessionPane, + createWorkspaceTab, + ensureActivePane, + findOpenFileDocument, + findSessionPaneById, + getFirstSessionPane, + getNextPersistOrder, + insertTabAfter, + moveTab, + removeSessionPane, + replaceSessionReferences as replacePaneSessionReferences, + restoreTabFromPersistence, + serializeTabsForPersistence, + splitSessionPane, + updateSessionPane, + updateSplitRatio as updateWorkspaceSplitRatio, +} from "@/lib/workspaceTabs"; +import type { + AppRuntimeInfo, + AppSettings, + FileDocumentBackend, + FileDocumentSnapshot, + Group, + PaneSplitDirection, + SavedConnection, + SessionPane, + SessionType, + SyncGroup, + Tab, + UiConfig, + WorkspaceSessionType, +} from "@/types/global"; +import { invoke } from "../lib/invoke"; +import { logger, setLoggerLevel } from "../lib/logger"; +import { DEFAULT_TERMINAL_FONT_SIZE } from "../lib/terminalFontSize"; +import { isPrimaryMainWindow } from "../lib/windowManager"; +import { + AppContext, + type PaneConnectingUpdates, + type PendingTabCreation, + TerminalAppSettingsContext, +} from "./AppContext"; + +function createSessionRequestId() { + return crypto.randomUUID(); +} + +const DEFAULT_APP_SETTINGS: AppSettings = { + general: { + startup_restore: true, + startup_restore_window_layout: true, + minimize_to_tray: false, + boss_key: null, + confirm_on_close: true, + }, + appearance: { + theme: "github-dark", + custom_themes: [], + font_family: DEFAULT_TERMINAL_FONT_FAMILY, + ui_font_family: getDefaultUiFontFamily(), + font_size: DEFAULT_TERMINAL_FONT_SIZE, + font_weight: 400, + font_weight_bold: 700, + background_opacity: 1.0, + background_image_path: null, + background_image_fit: "cover", + background_image_opacity: 0.45, + cursor_style: "block", + cursor_blink: true, + ui_font_size: 16, + terminal_theme: null, + minimum_contrast_ratio: 1, + panel_multi_open: false, + window_transparency: "none", + window_transparency_tint: 1, + window_transparency_blur: false, + }, + proxy: { + enabled: false, + protocol: "socks5", + host: "127.0.0.1", + port: 1080, + }, + search: { + custom_engines: [ + { name: "Google", url_template: "https://google.com/search?q=%s", show_in_menu: true }, + { name: "Bing", url_template: "https://bing.com/search?q=%s", show_in_menu: true }, + { name: "GitHub", url_template: "https://github.com/search?q=%s", show_in_menu: true }, + ], + }, + translation: { + target_language: "zh-CN", + deepl_api_key: "", + baidu_app_id: "", + baidu_app_key: "", + ali_app_id: "", + ali_app_key: "", + youdao_app_id: "", + youdao_app_key: "", + }, + security: { + use_os_keyring: true, + enable_screen_lock: false, + idle_lock_minutes: 0, + host_key_policy: "prompt", + }, + terminal: { + scrollback_lines: 10000, + keep_alive_mode: "compatible", + keep_alive_interval: 60, + font_size_delta: 0, + x11_display: "", + hardware_acceleration: false, + keyword_highlights_enabled: false, + keyword_highlights_across_wrapped_lines: false, + keyword_highlight_builtin_rules: {}, + keyword_highlights: [], + action_links_enabled: false, + action_links_matchers: { + ipv4: true, + archive: true, + host_port: true, + }, + show_workspace_padding: false, + show_line_numbers: false, + show_timestamps: false, + timestamp_format: "[HH:mm:ss]", + show_multi_line_paste_dialog: true, + paste_image_as_path: true, + }, + interaction: { + copy_on_select: false, + allow_osc52_clipboard_write: false, + right_click_paste: false, + terminal_zoom_enabled: true, + command_suggestions_enabled: true, + command_suggestion_min_chars: DEFAULT_COMMAND_SUGGESTION_MIN_CHARS, + command_suggestion_max_chars: DEFAULT_COMMAND_SUGGESTION_MAX_CHARS, + duplicate_session_command_delay_ms: 1000, + word_separators: " ()[]{}\"':=,;|&<>", + alt_as_meta: false, + ime_compatibility: false, + default_encoding: "UTF-8", + tab_double_click_action: DEFAULT_TAB_DOUBLE_CLICK_ACTION, + tab_middle_click_action: DEFAULT_TAB_MIDDLE_CLICK_ACTION, + tab_right_click_action: DEFAULT_TAB_RIGHT_CLICK_ACTION, + }, + recording: { + auto_start: false, + default_mode: "transcript", + base_path: "", + path_template: "{group}/{session}/{yyyy}-{MM}-{dd}/{HH}-{mm}-{ss}-{SSS}-{session_short_id}.log", + include_timestamps: true, + include_io_labels: true, + include_session_metadata: true, + rotation: { type: "session" }, + existing_file_behavior: "unique", + memory_limit_bytes: 5 * 1024 * 1024, + include_binary_transfer_payloads: false, + }, + transfer: { + editor_type: "external", + download_threads: 3, + upload_threads: 3, + duplicate_strategy: "ask", + preserve_timestamps: true, + resume_broken_transfer: true, + default_file_permissions: "644", + max_transfer_retries: 2, + transfer_buffer_size: 32, + download_path: "", + ask_save_location: false, + default_editor: "", + recording_path: "", + recording_include_io_labels: true, + recording_include_timestamps: true, + recording_auto_start: false, + recording_memory_limit_bytes: 5 * 1024 * 1024, + }, + diagnostics: { + level: "info", + retention_days: 7, + }, + ai: { + ...DEFAULT_AI_SETTINGS, + }, + cloud_sync: DEFAULT_CLOUD_SYNC_SETTINGS, + ui: { + open_tabs: [], + terminal_window_layout: null, + start_workspace_mode: "workbench", + left_width: 256, + right_width: 288, + quick_cmd_height: 180, + quick_cmd_category_width: 176, + quick_cmd_view_mode: "tile", + quick_cmd_sort_mode: "created", + quick_cmd_selected_category: "all", + active_left_panel: "fileExplorer", + active_right_panel: "savedConnections", + left_open_panels: [], + right_open_panels: [], + panel_stack_sizes: {}, + network_panel_active_tab: "tunnel", + security_auth_panel_active_tab: "keys", + show_quick_cmd_bar: true, + show_serial_send_panel: false, + serial_send_height: 180, + zoom_level: 1.0, + language: "en", + header_status_mode: "session", + header_status_visible: true, + show_notes_panel: true, + show_remote_stats: true, + remote_stats_interval: 3, + show_gpu_monitor: false, + gpu_monitor_interval: 3, + show_ascend_npu_monitor: false, + ascend_npu_monitor_interval: 3, + show_process_manager: false, + process_manager_interval: 5, + show_docker_manager: false, + docker_manager_interval: 10, + saved_connections_sort_mode: "default", + saved_connections_expanded_group_ids: [], + asset_sort_key: null, + asset_sort_direction: null, + recent_connection_ids: [], + transfer_height: 180, + file_explorer_show_hidden_files: true, + file_explorer_auto_sync_cwd_connection_ids: [], + file_explorer_favorite_dirs_by_connection_id: {}, + notes_expanded_folder_ids: [], + notes_last_selected_node_id: null, + activity_bar_layout: { + left_top: ["fileExplorer", "notes", "network", "securityAuth"], + left_bottom: ["syncBackupHistory", "settings"], + right_top: [ + "savedConnections", + "aiAssistant", + "activeSessions", + "commandHistory", + "resourceMonitor", + "gpuMonitor", + "ascendNpuMonitor", + "processManager", + "dockerManager", + ], + right_bottom: ["quickCmdBar", "serialSend", "recording", "lock"], + show_labels: false, + }, + }, + keybindings: {}, +}; + +const RECENT_CONNECTION_LIMIT = 10; + +const DEFAULT_RUNTIME_INFO: AppRuntimeInfo = { + portable: false, + mode: "installed", + executableDir: "", + dataDir: "", + configDir: "", + logDir: "", + webviewDataDir: "", + portableMarkerPath: null, +}; + +function areSettingsValuesEqual(left: unknown, right: unknown): boolean { + if (Object.is(left, right)) return true; + if (typeof left !== typeof right) return false; + if (left === null || right === null) return left === right; + + if (Array.isArray(left) || Array.isArray(right)) { + if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) return false; + for (let index = 0; index < left.length; index += 1) { + if (!areSettingsValuesEqual(left[index], right[index])) { + return false; + } + } + return true; + } + + if (typeof left !== "object" || typeof right !== "object") { + return false; + } + + const leftRecord = left as Record; + const rightRecord = right as Record; + const leftKeys = Object.keys(leftRecord); + const rightKeys = Object.keys(rightRecord); + if (leftKeys.length !== rightKeys.length) return false; + + for (const key of leftKeys) { + if (!(key in rightRecord)) return false; + if (!areSettingsValuesEqual(leftRecord[key], rightRecord[key])) { + return false; + } + } + + return true; +} + +function preserveAppSettingsReferences(prev: AppSettings, next: AppSettings): AppSettings { + const general = areSettingsValuesEqual(prev.general, next.general) ? prev.general : next.general; + const appearance = areSettingsValuesEqual(prev.appearance, next.appearance) + ? prev.appearance + : next.appearance; + const proxy = areSettingsValuesEqual(prev.proxy, next.proxy) ? prev.proxy : next.proxy; + const search = areSettingsValuesEqual(prev.search, next.search) ? prev.search : next.search; + const translation = areSettingsValuesEqual(prev.translation, next.translation) + ? prev.translation + : next.translation; + const security = areSettingsValuesEqual(prev.security, next.security) + ? prev.security + : next.security; + const terminal = areSettingsValuesEqual(prev.terminal, next.terminal) + ? prev.terminal + : next.terminal; + const interaction = areSettingsValuesEqual(prev.interaction, next.interaction) + ? prev.interaction + : next.interaction; + const transfer = areSettingsValuesEqual(prev.transfer, next.transfer) + ? prev.transfer + : next.transfer; + const diagnostics = areSettingsValuesEqual(prev.diagnostics, next.diagnostics) + ? prev.diagnostics + : next.diagnostics; + const ai = areSettingsValuesEqual(prev.ai, next.ai) ? prev.ai : next.ai; + const cloudSync = areSettingsValuesEqual(prev.cloud_sync, next.cloud_sync) + ? prev.cloud_sync + : next.cloud_sync; + const ui = areSettingsValuesEqual(prev.ui, next.ui) ? prev.ui : next.ui; + const keybindings = areSettingsValuesEqual(prev.keybindings, next.keybindings) + ? prev.keybindings + : next.keybindings; + + if ( + general === prev.general && + appearance === prev.appearance && + proxy === prev.proxy && + search === prev.search && + translation === prev.translation && + security === prev.security && + terminal === prev.terminal && + interaction === prev.interaction && + transfer === prev.transfer && + diagnostics === prev.diagnostics && + ai === prev.ai && + cloudSync === prev.cloud_sync && + ui === prev.ui && + keybindings === prev.keybindings + ) { + return prev; + } + + return { + ...next, + general, + appearance, + proxy, + search, + translation, + security, + terminal, + interaction, + transfer, + diagnostics, + ai, + cloud_sync: cloudSync, + ui, + keybindings, + }; +} + +/** Provides tabs, appSettings, savedConnections, and dialog state to the app. */ +export function AppProvider({ children }: { children: ReactNode }) { + // Tabs State + const [tabs, setTabs] = useState([]); + const tabsRef = useRef([]); + const [activeTabIdState, setActiveTabIdState] = useState(null); + const activeTabIdRef = useRef(null); + + // App Settings State (includes UI config) + const [appSettings, setAppSettings] = useState(DEFAULT_APP_SETTINGS); + const appSettingsRef = useRef(DEFAULT_APP_SETTINGS); + const appSettingsLoaded = useRef(false); + const appSettingsSaveTimerRef = useRef | null>(null); + const uiSaveTimerRef = useRef | null>(null); + + // Data State + const [savedConnections, setSavedConnections] = useState([]); + const [savedGroups, setSavedGroups] = useState([]); + + // Dialog State + const [showNewSession, setShowNewSession] = useState(false); + const [editingConnection, setEditingConnection] = useState( + undefined, + ); + const [showSettingsDialog, setShowSettingsDialog] = useState(false); + + // Sync Input Groups + const [syncGroups, setSyncGroups] = useState([]); + const [broadcastToAll, setBroadcastToAll] = useState(false); + + // Idle Lock State + const { isLocked, setIsLocked, lockStateLoaded } = useAppLockState(); + + // Loading State + const [settingsLoaded, setSettingsLoaded] = useState(false); + const [startupRestoreComplete, setStartupRestoreComplete] = useState(false); + const [runtimeInfo, setRuntimeInfo] = useState(DEFAULT_RUNTIME_INFO); + const [runtimeInfoLoaded, setRuntimeInfoLoaded] = useState(false); + + const setActiveTabId = useCallback((id: string | null) => { + activeTabIdRef.current = id; + setActiveTabIdState(id); + }, []); + + // 1. Load App Settings + useEffect(() => { + invoke("get_app_runtime_info") + .then((info) => { + setRuntimeInfo(info); + }) + .catch((error) => { + logger.error({ + domain: "app.lifecycle", + event: "runtime_info.load_failed", + message: "Failed to load app runtime info", + error, + }); + }) + .finally(() => { + setRuntimeInfoLoaded(true); + }); + + invoke("get_app_settings") + .then((cfg) => { + const normalized = normalizeQuickCommandAppSettings(cfg); + appSettingsRef.current = normalized; + setAppSettings(normalized); + setLoggerLevel(normalized.diagnostics.level); + appSettingsLoaded.current = true; + setSettingsLoaded(true); + if (isPrimaryMainWindow() && normalized.security?.enable_screen_lock) { + setIsLocked(true); + } + }) + .catch(() => { + appSettingsRef.current = DEFAULT_APP_SETTINGS; + appSettingsLoaded.current = true; + setAppSettings(DEFAULT_APP_SETTINGS); + setSettingsLoaded(true); + }); + }, [setIsLocked]); + + // Apply UI font size to root element + useEffect(() => { + document.documentElement.style.fontSize = `${appSettings.appearance.ui_font_size}px`; + }, [appSettings.appearance.ui_font_size]); + + useEffect(() => { + const fontFamily = appSettings.appearance.ui_font_family; + document.documentElement.style.setProperty("--font-sans", fontFamily); + document.documentElement.style.setProperty("--font-display", fontFamily); + }, [appSettings.appearance.ui_font_family]); + + // 2. Save App Settings Debounced + const updateAppSettings = useCallback( + (updates: Partial | ((prev: AppSettings) => Partial)) => { + setAppSettings((prev) => { + const nextUpdates = typeof updates === "function" ? updates(prev) : updates; + const next = normalizeQuickCommandAppSettings({ + ...prev, + ...nextUpdates, + }); + appSettingsRef.current = next; + setLoggerLevel(next.diagnostics.level); + if (appSettingsLoaded.current) { + if (appSettingsSaveTimerRef.current) clearTimeout(appSettingsSaveTimerRef.current); + appSettingsSaveTimerRef.current = setTimeout(() => { + invoke("save_app_settings", { settings: next }).catch((e) => + logger.error({ + domain: "settings.persistence", + event: "settings.save_failed", + message: "Failed to save app settings", + error: e, + }), + ); + }, 500); + } + return next; + }); + }, + [], + ); + + const replaceAppSettings = useCallback((next: AppSettings) => { + if (appSettingsSaveTimerRef.current) { + clearTimeout(appSettingsSaveTimerRef.current); + appSettingsSaveTimerRef.current = null; + } + setAppSettings((current) => { + const normalized = preserveAppSettingsReferences( + current, + normalizeQuickCommandAppSettings(next), + ); + appSettingsRef.current = normalized; + setLoggerLevel(normalized.diagnostics.level); + return normalized; + }); + }, []); + + // Convenience helper to update just the UI config portion via lightweight path + const updateUi = useCallback( + (updates: Partial | ((prev: UiConfig) => Partial)) => { + setAppSettings((prev) => { + const nextUpdates = typeof updates === "function" ? updates(prev.ui) : updates; + const nextUi = normalizeQuickCommandUiConfig({ + ...prev.ui, + ...nextUpdates, + }); + const next = { ...prev, ui: nextUi }; + appSettingsRef.current = next; + if (appSettingsLoaded.current) { + if (uiSaveTimerRef.current) clearTimeout(uiSaveTimerRef.current); + uiSaveTimerRef.current = setTimeout(() => { + invoke("save_app_ui_settings", { ui: nextUi }).catch((e) => + logger.error({ + domain: "settings.persistence", + event: "ui_settings.save_failed", + message: "Failed to save UI settings", + error: e, + }), + ); + }, 500); + } + return next; + }); + }, + [], + ); + + const recordRecentConnection = useCallback( + (connectionId: string) => { + if (!connectionId) return; + updateUi((prev) => ({ + recent_connection_ids: [ + connectionId, + ...(prev.recent_connection_ids ?? []).filter((id) => id !== connectionId), + ].slice(0, RECENT_CONNECTION_LIMIT), + })); + }, + [updateUi], + ); + + // 3. Load Connections + const refreshConnections = useCallback(async () => { + try { + const [saved, groups] = await Promise.all([ + invoke("get_saved_connections"), + invoke("get_groups"), + ]); + setSavedConnections(saved); + setSavedGroups(groups); + } catch (e) { + logger.error({ + domain: "ui.error", + event: "connections.fetch_failed", + message: "Failed to fetch connections", + error: e, + }); + } + }, []); + + useEffect(() => { + refreshConnections(); + const unlisten = listen("connections-changed", () => { + refreshConnections(); + }); + return () => { + unlisten.then((fn) => fn()); + }; + }, [refreshConnections]); + + const syncOpenTabs = useCallback( + async (nextTabs: Tab[], options?: { immediatePersist?: boolean }) => { + if (!hasRestored.current || !appSettingsRef.current.general.startup_restore) return; + + const openTabs = serializeTabsForPersistence(nextTabs); + updateUi({ open_tabs: openTabs }); + + if (!options?.immediatePersist) return; + + const nextUi = { ...appSettingsRef.current.ui, open_tabs: openTabs }; + appSettingsRef.current = { ...appSettingsRef.current, ui: nextUi }; + await invoke("save_app_ui_settings", { ui: nextUi }); + }, + [updateUi], + ); + + const commitTabs = useCallback( + async ( + nextTabs: Tab[], + options?: { + syncPersisted?: boolean; + immediatePersist?: boolean; + }, + ) => { + const normalizedTabs = nextTabs.map(ensureActivePane); + tabsRef.current = normalizedTabs; + setTabs(normalizedTabs); + + if (options?.syncPersisted === false) return; + await syncOpenTabs(normalizedTabs, { immediatePersist: options?.immediatePersist }); + }, + [syncOpenTabs], + ); + + // 4. Tab Logic + const addTab = useCallback( + ( + sessionId: string, + name: string, + type: WorkspaceSessionType, + connectionId?: string, + extra?: Partial>, + options?: { afterTabId?: string }, + ) => { + const pane = createSessionPane(name, type, connectionId, { sessionId }); + const newTab = createWorkspaceTab(pane, getNextPersistOrder(tabsRef.current), extra); + const nextTabs = options?.afterTabId + ? insertTabAfter(tabsRef.current, options.afterTabId, newTab) + : [...tabsRef.current, newTab]; + void commitTabs(nextTabs); + setActiveTabId(newTab.id); + + // Close dialogs when session starts + setShowNewSession(false); + setEditingConnection(undefined); + return newTab.id; + }, + [commitTabs, setActiveTabId], + ); + + const addPendingTab = useCallback( + ( + name: string, + type: WorkspaceSessionType, + connectionId?: string, + extra?: Partial>, + options?: { afterTabId?: string }, + paneOverrides?: Partial, + ): PendingTabCreation => { + const createRequestId = createSessionRequestId(); + const pane = createSessionPane(name, type, connectionId, { + ...paneOverrides, + connecting: true, + createRequestId, + }); + const newTab = createWorkspaceTab(pane, getNextPersistOrder(tabsRef.current), extra); + const nextTabs = options?.afterTabId + ? insertTabAfter(tabsRef.current, options.afterTabId, newTab) + : [...tabsRef.current, newTab]; + void commitTabs(nextTabs); + setActiveTabId(newTab.id); + return { tabId: newTab.id, createRequestId }; + }, + [commitTabs, setActiveTabId], + ); + + const updateTabSession = useCallback( + (tabId: string, sessionId: string) => { + const tab = tabsRef.current.find((item) => item.id === tabId); + if (!tab) return; + const paneId = tab.activePaneId; + const nextTabs = tabsRef.current.map((item) => + item.id === tabId + ? { + ...item, + root: updateSessionPane(item.root, paneId, { + sessionId, + connecting: false, + connectError: undefined, + createRequestId: undefined, + }), + } + : item, + ); + void commitTabs(nextTabs); + }, + [commitTabs], + ); + + const markTabConnectionFailed = useCallback( + (tabId: string, error: string) => { + const tab = tabsRef.current.find((item) => item.id === tabId); + if (!tab) return; + const paneId = tab.activePaneId; + const nextTabs = tabsRef.current.map((item) => + item.id === tabId + ? { + ...item, + root: updateSessionPane(item.root, paneId, { + connecting: false, + connectError: error, + createRequestId: undefined, + }), + } + : item, + ); + void commitTabs(nextTabs); + }, + [commitTabs], + ); + + const updatePaneSession = useCallback( + (tabId: string, paneId: string, sessionId: string) => { + const nextTabs = tabsRef.current.map((tab) => + tab.id === tabId + ? { + ...tab, + root: updateSessionPane(tab.root, paneId, { + sessionId, + connecting: false, + connectError: undefined, + createRequestId: undefined, + }), + } + : tab, + ); + void commitTabs(nextTabs); + }, + [commitTabs], + ); + + const replaceSessionReferences = useCallback( + (oldSessionId: string, newSessionId: string) => { + const nextTabs = tabsRef.current.map((tab) => ({ + ...tab, + root: replacePaneSessionReferences(tab.root, oldSessionId, newSessionId), + })); + void commitTabs(nextTabs); + }, + [commitTabs], + ); + + const markPaneConnectionFailed = useCallback( + (tabId: string, paneId: string, error: string) => { + const nextTabs = tabsRef.current.map((tab) => + tab.id === tabId + ? { + ...tab, + root: updateSessionPane(tab.root, paneId, { + connecting: false, + connectError: error, + createRequestId: undefined, + }), + } + : tab, + ); + void commitTabs(nextTabs); + }, + [commitTabs], + ); + + const markPaneConnecting = useCallback( + (tabId: string, paneId: string, updates?: PaneConnectingUpdates) => { + const createRequestId = createSessionRequestId(); + const nextTabs = tabsRef.current.map((tab) => + tab.id === tabId + ? { + ...tab, + root: updateSessionPane(tab.root, paneId, { + ...updates, + connecting: true, + connectError: undefined, + createRequestId, + }), + } + : tab, + ); + void commitTabs(nextTabs); + return tabsRef.current.some((tab) => tab.id === tabId) ? createRequestId : null; + }, + [commitTabs], + ); + + const hasTab = useCallback((tabId: string) => { + return tabsRef.current.some((tab) => tab.id === tabId); + }, []); + + const hasPane = useCallback((tabId: string, paneId: string) => { + const tab = tabsRef.current.find((item) => item.id === tabId); + return !!tab && !!findSessionPaneById(tab.root, paneId); + }, []); + + const setActivePane = useCallback( + (tabId: string, paneId: string) => { + const nextTabs = tabsRef.current.map((tab) => + tab.id === tabId ? ensureActivePane({ ...tab, activePaneId: paneId }) : tab, + ); + void commitTabs(nextTabs); + setActiveTabId(tabId); + }, + [commitTabs, setActiveTabId], + ); + + const splitPane = useCallback( + ( + tabId: string, + paneId: string, + direction: PaneSplitDirection, + pane: SessionPane, + options?: { immediatePersist?: boolean }, + ) => { + const tab = tabsRef.current.find((item) => item.id === tabId); + if (!tab) return null; + + const nextTabs = tabsRef.current.map((item) => + item.id === tabId + ? ensureActivePane({ + ...item, + activePaneId: pane.id, + root: splitSessionPane(item.root, paneId, direction, pane), + }) + : item, + ); + void commitTabs(nextTabs, { immediatePersist: options?.immediatePersist }); + setActiveTabId(tabId); + return pane.id; + }, + [commitTabs, setActiveTabId], + ); + + const openFileDocument = useCallback( + (input: { + sessionId: string; + name: string; + type: SessionType; + connectionId?: string; + backend: FileDocumentBackend; + path: string; + file: FileDocumentSnapshot; + }) => { + const existing = findOpenFileDocument(tabsRef.current, input); + if (existing) { + setActivePane(existing.tabId, existing.paneId); + return { ...existing, created: false }; + } + + const pane = createFileDocumentPane(input); + const tab = createWorkspaceTab(pane, getNextPersistOrder(tabsRef.current)); + void commitTabs([...tabsRef.current, tab]); + setActiveTabId(tab.id); + return { tabId: tab.id, paneId: pane.id, created: true }; + }, + [commitTabs, setActivePane, setActiveTabId], + ); + + const updateSplitRatio = useCallback( + (tabId: string, splitId: string, ratio: number) => { + const nextTabs = tabsRef.current.map((tab) => + tab.id === tabId + ? { + ...tab, + root: updateWorkspaceSplitRatio(tab.root, splitId, ratio), + } + : tab, + ); + void commitTabs(nextTabs); + }, + [commitTabs], + ); + + const closePane = useCallback( + (tabId: string, paneId: string, options?: { immediatePersist?: boolean }) => { + const currentTabs = tabsRef.current; + const index = currentTabs.findIndex((item) => item.id === tabId); + if (index === -1) return; + + const tab = currentTabs[index]; + const nextRoot = removeSessionPane(tab.root, paneId); + + if (!nextRoot) { + const nextTabs = currentTabs.filter((item) => item.id !== tabId); + if (activeTabIdRef.current === tabId) { + const fallback = nextTabs[Math.max(0, index - 1)] ?? nextTabs[0] ?? null; + setActiveTabId(fallback?.id ?? null); + } + void commitTabs(nextTabs, { immediatePersist: options?.immediatePersist }); + return; + } + + const nextActivePaneId = + tab.activePaneId === paneId + ? (getFirstSessionPane(nextRoot)?.id ?? tab.activePaneId) + : tab.activePaneId; + + const nextTabs = currentTabs.map((item) => + item.id === tabId + ? ensureActivePane({ + ...item, + activePaneId: nextActivePaneId, + root: nextRoot, + }) + : item, + ); + void commitTabs(nextTabs, { immediatePersist: options?.immediatePersist }); + }, + [commitTabs, setActiveTabId], + ); + + const updateTab = useCallback( + async ( + tabId: string, + updates: Partial>, + options?: { immediatePersist?: boolean }, + ) => { + const nextTabs = tabsRef.current.map((tab) => + tab.id === tabId ? { ...tab, ...updates } : tab, + ); + await commitTabs(nextTabs, { immediatePersist: options?.immediatePersist }); + }, + [commitTabs], + ); + + const closeTabs = useCallback( + ( + tabIds: string[], + options?: { immediatePersist?: boolean; nextActiveTabId?: string | null }, + ) => { + if (tabIds.length === 0) return; + + const idsToClose = new Set(tabIds); + const currentTabs = tabsRef.current; + const nextTabs = currentTabs.filter((tab) => !idsToClose.has(tab.id)); + const currentActiveTabId = activeTabIdRef.current; + + let nextActiveTabId = + options?.nextActiveTabId !== undefined ? options.nextActiveTabId : currentActiveTabId; + + if (nextActiveTabId && !nextTabs.some((tab) => tab.id === nextActiveTabId)) { + nextActiveTabId = null; + } + + if (!nextActiveTabId && currentActiveTabId && idsToClose.has(currentActiveTabId)) { + const activeIndex = currentTabs.findIndex((tab) => tab.id === currentActiveTabId); + const fallbackTab = nextTabs[Math.max(0, activeIndex - 1)] ?? nextTabs[0] ?? null; + nextActiveTabId = fallbackTab?.id ?? null; + } + + if (!nextActiveTabId && nextTabs.length > 0) { + nextActiveTabId = nextTabs[0].id; + } + + if (nextActiveTabId !== currentActiveTabId) { + setActiveTabId(nextActiveTabId); + } + + void commitTabs(nextTabs, { immediatePersist: options?.immediatePersist }); + }, + [commitTabs, setActiveTabId], + ); + + const closeTab = useCallback( + (tabId: string) => { + closeTabs([tabId]); + }, + [closeTabs], + ); + + const reorderTabs = useCallback( + (fromTabId: string, toIndex: number) => { + const nextTabs = moveTab(tabsRef.current, fromTabId, toIndex); + void commitTabs(nextTabs, { syncPersisted: false }); + }, + [commitTabs], + ); + + const persistTabsNow = useCallback(async (extraUi?: Partial) => { + if (!hasRestored.current || !appSettingsRef.current.general.startup_restore) return; + const nextUi = { + ...appSettingsRef.current.ui, + open_tabs: serializeTabsForPersistence(tabsRef.current), + ...extraUi, + }; + appSettingsRef.current = { ...appSettingsRef.current, ui: nextUi }; + await invoke("save_app_ui_settings", { ui: nextUi }); + }, []); + + const closeStaleCreatedSession = useCallback(async (sessionId: string) => { + try { + await invoke("close_session", { sessionId }); + } catch (error) { + logger.error({ + domain: "session.lifecycle", + event: "session.stale_close_failed", + message: "Failed to close stale restored session", + ids: { session_id: sessionId }, + error, + }); + } + }, []); + + const handleRestoredSessionCreated = useCallback( + async (tabId: string, paneId: string, sessionId: string, connectionId?: string) => { + if (!hasPane(tabId, paneId)) { + await closeStaleCreatedSession(sessionId); + return; + } + updatePaneSession(tabId, paneId, sessionId); + if (connectionId) { + void updateConnectionAutoIconAfterSessionStart({ + connectionId, + sessionId, + remoteStatsEnabled: appSettingsRef.current.ui.show_remote_stats ?? true, + }); + } + }, + [closeStaleCreatedSession, hasPane, updatePaneSession], + ); + + const handleRestoredSessionFailed = useCallback( + ( + tabId: string, + paneId: string, + sessionType: WorkspaceSessionType, + connectionId: string | undefined, + error: unknown, + ) => { + const errorMessage = getErrorMessage(error); + if ( + errorMessage.toLowerCase().includes("session creation cancelled") || + !hasPane(tabId, paneId) + ) { + return; + } + logger.error({ + domain: "session.lifecycle", + event: "session.restore_failed", + message: `Restore ${sessionType} failed`, + ids: connectionId ? { connection_id: connectionId } : undefined, + data: { + session_type: sessionType, + pane_id: paneId, + }, + error, + }); + markPaneConnectionFailed(tabId, paneId, errorMessage); + }, + [hasPane, markPaneConnectionFailed], + ); + + // 5. Startup Restore Logic + const hasRestored = useRef(false); + const pendingLockedStartupRestoreTabsRef = useRef(null); + + const restoreSessionsForTabs = useCallback( + (tabsToRestore: Tab[]) => { + tabsToRestore.forEach((tab) => { + const panes = collectSessionPanes(tab.root); + + panes.forEach((pane) => { + if (!hasPane(tab.id, pane.id)) return; + + const cid = pane.connectionId; + switch (pane.type) { + case "SSH": + if (!cid) { + markPaneConnectionFailed(tab.id, pane.id, "Missing SSH connection id"); + return; + } + invoke("create_ssh_session", { + connectionId: cid, + createRequestId: pane.createRequestId, + }) + .then((sessionId) => handleRestoredSessionCreated(tab.id, pane.id, sessionId, cid)) + .catch((e) => + handleRestoredSessionFailed(tab.id, pane.id, "SSH", pane.connectionId, e), + ); + break; + case "Local": + invoke("create_local_session", { + connectionId: cid || null, + createRequestId: pane.createRequestId, + }) + .then((sessionId) => handleRestoredSessionCreated(tab.id, pane.id, sessionId)) + .catch((e) => + handleRestoredSessionFailed(tab.id, pane.id, "Local", pane.connectionId, e), + ); + break; + case "Telnet": + if (!cid) { + markPaneConnectionFailed(tab.id, pane.id, "Missing Telnet connection id"); + return; + } + invoke("create_telnet_session", { + connectionId: cid, + createRequestId: pane.createRequestId, + }) + .then((sessionId) => handleRestoredSessionCreated(tab.id, pane.id, sessionId)) + .catch((e) => + handleRestoredSessionFailed(tab.id, pane.id, "Telnet", pane.connectionId, e), + ); + break; + case "Serial": + if (!cid) { + markPaneConnectionFailed(tab.id, pane.id, "Missing Serial connection id"); + return; + } + invoke("create_serial_session", { + connectionId: cid, + createRequestId: pane.createRequestId, + }) + .then((sessionId) => handleRestoredSessionCreated(tab.id, pane.id, sessionId)) + .catch((e) => + handleRestoredSessionFailed(tab.id, pane.id, "Serial", pane.connectionId, e), + ); + break; + case "VNC": + if (!cid) { + markPaneConnectionFailed(tab.id, pane.id, "Missing VNC connection id"); + return; + } + invoke("create_vnc_session", { + connectionId: cid, + createRequestId: pane.createRequestId, + }) + .then((sessionId) => handleRestoredSessionCreated(tab.id, pane.id, sessionId, cid)) + .catch((e) => + handleRestoredSessionFailed(tab.id, pane.id, "VNC", pane.connectionId, e), + ); + break; + case "RDP": + if (!cid) { + markPaneConnectionFailed(tab.id, pane.id, "Missing RDP connection id"); + return; + } + invoke("create_rdp_session", { + connectionId: cid, + createRequestId: pane.createRequestId, + }) + .then((sessionId) => handleRestoredSessionCreated(tab.id, pane.id, sessionId, cid)) + .catch((e) => + handleRestoredSessionFailed(tab.id, pane.id, "RDP", pane.connectionId, e), + ); + break; + } + }); + }); + }, + [handleRestoredSessionCreated, handleRestoredSessionFailed, hasPane, markPaneConnectionFailed], + ); + + useEffect(() => { + if (hasRestored.current || !appSettingsLoaded.current || !lockStateLoaded) return; + + hasRestored.current = true; + if ( + isPrimaryMainWindow() && + appSettings.general.startup_restore && + appSettings.ui.open_tabs && + appSettings.ui.open_tabs.length > 0 + ) { + const restoredTabs = appSettings.ui.open_tabs + .map((tab, index) => restoreTabFromPersistence(tab, index)) + .filter((tab): tab is Tab => tab !== null); + + tabsRef.current = restoredTabs; + setTabs(restoredTabs); + if (restoredTabs.length > 0) { + setActiveTabId(restoredTabs[restoredTabs.length - 1].id); + } + + if (appSettings.security.enable_screen_lock && isLocked) { + pendingLockedStartupRestoreTabsRef.current = restoredTabs; + } else { + restoreSessionsForTabs(restoredTabs); + } + } + + setStartupRestoreComplete(true); + }, [appSettings, isLocked, lockStateLoaded, restoreSessionsForTabs, setActiveTabId]); + + useEffect(() => { + if (isLocked) return; + + const pendingTabs = pendingLockedStartupRestoreTabsRef.current; + if (!pendingTabs) return; + + pendingLockedStartupRestoreTabsRef.current = null; + restoreSessionsForTabs(pendingTabs); + }, [isLocked, restoreSessionsForTabs]); + + const contextValue = useMemo( + () => ({ + tabs, + activeTabId: activeTabIdState, + setActiveTabId, + addTab, + addPendingTab, + updateTabSession, + markTabConnectionFailed, + updatePaneSession, + replaceSessionReferences, + markPaneConnectionFailed, + markPaneConnecting, + hasTab, + hasPane, + setActivePane, + updateSplitRatio, + splitPane, + openFileDocument, + closePane, + reorderTabs, + updateTab, + closeTabs, + closeTab, + persistTabsNow, + appSettings, + updateAppSettings, + replaceAppSettings, + updateUi, + savedConnections, + savedGroups, + refreshConnections, + recordRecentConnection, + showNewSession, + setShowNewSession, + editingConnection, + setEditingConnection, + showSettingsDialog, + setShowSettingsDialog, + syncGroups, + setSyncGroups, + broadcastToAll, + setBroadcastToAll, + isLocked, + setIsLocked, + settingsLoaded, + startupRestoreComplete, + runtimeInfo, + runtimeInfoLoaded, + }), + [ + tabs, + activeTabIdState, + setActiveTabId, + addTab, + addPendingTab, + updateTabSession, + markTabConnectionFailed, + updatePaneSession, + replaceSessionReferences, + markPaneConnectionFailed, + markPaneConnecting, + hasTab, + hasPane, + setActivePane, + updateSplitRatio, + splitPane, + openFileDocument, + closePane, + reorderTabs, + updateTab, + closeTabs, + closeTab, + persistTabsNow, + appSettings, + updateAppSettings, + replaceAppSettings, + updateUi, + savedConnections, + savedGroups, + refreshConnections, + recordRecentConnection, + showNewSession, + editingConnection, + showSettingsDialog, + syncGroups, + broadcastToAll, + isLocked, + setIsLocked, + settingsLoaded, + startupRestoreComplete, + runtimeInfo, + runtimeInfoLoaded, + ], + ); + + const terminalAppSettingsValue = useMemo( + () => ({ + appearance: appSettings.appearance, + interaction: appSettings.interaction, + terminal: appSettings.terminal, + translation: appSettings.translation, + search: appSettings.search, + ai: appSettings.ai, + keybindings: appSettings.keybindings, + transfer: appSettings.transfer, + }), + [ + appSettings.appearance, + appSettings.interaction, + appSettings.terminal, + appSettings.translation, + appSettings.search, + appSettings.ai, + appSettings.keybindings, + appSettings.transfer, + ], + ); + + return ( + + + {lockStateLoaded && settingsLoaded ? children : null} + + + ); +} diff --git a/src/context/ChildAppProvider.tsx b/src/context/ChildAppProvider.tsx index 6d0285167..9e8be23cf 100644 --- a/src/context/ChildAppProvider.tsx +++ b/src/context/ChildAppProvider.tsx @@ -36,7 +36,6 @@ import i18n from "../i18n"; import { invoke } from "../lib/invoke"; import { logger, setLoggerLevel } from "../lib/logger"; import { DEFAULT_TERMINAL_FONT_SIZE } from "../lib/terminalFontSize"; -import { signalChildWindowReady } from "../lib/windowManager"; import { AppContext } from "./AppContext"; const DEFAULT_APP_SETTINGS: AppSettings = { @@ -329,16 +328,6 @@ export function ChildAppProvider({ children }: { children: ReactNode }) { } }, [appSettings.ui?.language]); - useEffect(() => { - if (!settingsLoaded || !lockStateLoaded || !isLocked) return; - - const timeoutId = window.setTimeout(() => { - void signalChildWindowReady(); - }, 0); - - return () => window.clearTimeout(timeoutId); - }, [isLocked, lockStateLoaded, settingsLoaded]); - useIdleLock( appSettings.security.enable_screen_lock ? appSettings.security.idle_lock_minutes @@ -490,6 +479,15 @@ export function ChildAppProvider({ children }: { children: ReactNode }) { return ( + {!appStateReady ? ( +
+ +
+ ) : null} {showContent ? children : null} {appStateReady && isLocked ? ( ({ + listen: vi.fn(), + signalReady: vi.fn(), + signalFailed: vi.fn(), +})); + +vi.mock("@tauri-apps/api/event", () => ({ listen: mocks.listen })); +vi.mock("@/lib/childWindowLifecycle", () => ({ + signalChildWindowCommandReady: mocks.signalReady, + signalChildWindowLoadFailed: mocks.signalFailed, +})); + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +function Probe({ handler = vi.fn() }: { handler?: (payload: { tab: string }) => void }) { + useChildWindowCommand(CHILD_WINDOW_COMMANDS.settingsOpenTab, handler); + return null; +} + +beforeEach(() => { + vi.clearAllMocks(); + mocks.signalReady.mockResolvedValue(undefined); + mocks.signalFailed.mockResolvedValue(undefined); +}); + +it("signals ready only after the active StrictMode listener resolves", async () => { + const first = deferred<() => void>(); + const second = deferred<() => void>(); + const disposeFirst = vi.fn(); + const disposeSecond = vi.fn(); + mocks.listen.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise); + + render( + + + , + ); + + first.resolve(disposeFirst); + await waitFor(() => expect(disposeFirst).toHaveBeenCalledOnce()); + expect(mocks.signalReady).not.toHaveBeenCalled(); + + second.resolve(disposeSecond); + await waitFor(() => expect(mocks.signalReady).toHaveBeenCalledOnce()); +}); + +it("uses the latest handler without registering another Tauri listener", async () => { + const listener = deferred<() => void>(); + const firstHandler = vi.fn(); + const secondHandler = vi.fn(); + mocks.listen.mockReturnValueOnce(listener.promise); + + const view = render(); + listener.resolve(vi.fn()); + await waitFor(() => expect(mocks.signalReady).toHaveBeenCalledOnce()); + + view.rerender(); + const receive = mocks.listen.mock.calls[0][1]; + receive({ payload: { tab: "appearance" } }); + + expect(firstHandler).not.toHaveBeenCalled(); + expect(secondHandler).toHaveBeenCalledWith({ tab: "appearance" }); + expect(mocks.listen).toHaveBeenCalledOnce(); +}); + +it("reports listener registration failures without signaling ready", async () => { + mocks.listen.mockRejectedValueOnce(new Error("listen failed")); + + render(); + + await waitFor(() => expect(mocks.signalFailed).toHaveBeenCalledWith("command-listener")); + expect(mocks.signalReady).not.toHaveBeenCalled(); +}); diff --git a/src/hooks/useChildWindowCommand.ts b/src/hooks/useChildWindowCommand.ts new file mode 100644 index 000000000..fa93955b8 --- /dev/null +++ b/src/hooks/useChildWindowCommand.ts @@ -0,0 +1,42 @@ +import { listen } from "@tauri-apps/api/event"; +import { useEffect, useRef } from "react"; +import { + signalChildWindowCommandReady, + signalChildWindowLoadFailed, +} from "@/lib/childWindowLifecycle"; +import type { ChildWindowCommandName } from "@/lib/childWindowProtocol"; + +/** + * 注册子窗口业务命令,并在当前 effect 的 listener 确认可用后报告 ready。 + * active 标记会忽略 StrictMode 首轮已清理的异步注册,避免在有效 listener 建立前释放队列。 + */ +export function useChildWindowCommand( + event: ChildWindowCommandName, + handler: (payload: T) => void, +) { + const handlerRef = useRef(handler); + handlerRef.current = handler; + + useEffect(() => { + let active = true; + let dispose: (() => void) | undefined; + + void listen(event, ({ payload }) => handlerRef.current(payload)) + .then((unlisten) => { + if (!active) { + unlisten(); + return; + } + dispose = unlisten; + void signalChildWindowCommandReady(event).catch(() => {}); + }) + .catch(() => { + if (active) void signalChildWindowLoadFailed("command-listener").catch(() => {}); + }); + + return () => { + active = false; + dispose?.(); + }; + }, [event]); +} diff --git a/src/hooks/useTerminalSettings.ts b/src/hooks/useTerminalSettings.ts index baf5a73d6..2213f09fc 100644 --- a/src/hooks/useTerminalSettings.ts +++ b/src/hooks/useTerminalSettings.ts @@ -9,6 +9,21 @@ import { XTERM_PERFORMANCE_CONFIG } from "@/lib/xtermPerformance"; import type { TerminalFitScheduler } from "@/components/terminal/terminalFitScheduler"; import type { AppSettings } from "@/types/global"; +type TerminalRendererPreference = "dom" | "webgl" | "auto"; +type ResolvedTerminalRendererMode = "dom" | "webgl"; + +function resolveTerminalRendererMode(options: { + preference: TerminalRendererPreference; + transparencyEnabled: boolean; + webglCircuitBroken: boolean; +}): ResolvedTerminalRendererMode { + if (options.preference === "dom") return "dom"; + if (options.transparencyEnabled || options.webglCircuitBroken) return "dom"; + if (options.preference === "webgl") return "webgl"; + + return "dom"; +} + export function useTerminalSettings( terminalRef: RefObject, fitSchedulerRef: RefObject, @@ -131,10 +146,12 @@ export function useTerminalSettings( disposeWebgl(); } - const shouldUseWebgl = - terminalSettings.hardware_acceleration && - !terminalTransparencyEnabled && - !webglCircuitBrokenRef.current; + const rendererMode = resolveTerminalRendererMode({ + preference: terminalSettings.hardware_acceleration ? "webgl" : "dom", + transparencyEnabled: terminalTransparencyEnabled, + webglCircuitBroken: webglCircuitBrokenRef.current, + }); + const shouldUseWebgl = rendererMode === "webgl"; if (!shouldUseWebgl) { clearHiddenWebglDisposeTimer(); diff --git a/src/lib/childWindowCommandQueue.test.ts b/src/lib/childWindowCommandQueue.test.ts new file mode 100644 index 000000000..b91b0ecbb --- /dev/null +++ b/src/lib/childWindowCommandQueue.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "vitest"; +import { ChildWindowCommandQueue } from "./childWindowCommandQueue"; +import { CHILD_WINDOW_COMMANDS } from "./childWindowProtocol"; + +describe("ChildWindowCommandQueue", () => { + it("keeps commands in FIFO order until the matching listener is ready", () => { + const queue = new ChildWindowCommandQueue(); + queue.register("file-editor-main", "token-new", CHILD_WINDOW_COMMANDS.remoteFileEditorOpen); + + expect( + queue.dispatch("file-editor-main", CHILD_WINDOW_COMMANDS.remoteFileEditorOpen, { name: "a" }), + ).toEqual([]); + expect( + queue.dispatch("file-editor-main", CHILD_WINDOW_COMMANDS.remoteFileEditorOpen, { name: "b" }), + ).toEqual([]); + + expect( + queue.markReady("file-editor-main", "token-new", CHILD_WINDOW_COMMANDS.remoteFileEditorOpen), + ).toEqual([ + { event: CHILD_WINDOW_COMMANDS.remoteFileEditorOpen, payload: { name: "a" } }, + { event: CHILD_WINDOW_COMMANDS.remoteFileEditorOpen, payload: { name: "b" } }, + ]); + }); + + it("ignores a ready event from a stale WebView token", () => { + const queue = new ChildWindowCommandQueue(); + queue.register("settings", "token-new", CHILD_WINDOW_COMMANDS.settingsOpenTab); + queue.dispatch("settings", CHILD_WINDOW_COMMANDS.settingsOpenTab, { tab: "appearance" }); + + expect(queue.markReady("settings", "token-old", CHILD_WINDOW_COMMANDS.settingsOpenTab)).toEqual( + [], + ); + }); + + it("dispatches immediately after the listener is ready", () => { + const queue = new ChildWindowCommandQueue(); + queue.register("settings", "token", CHILD_WINDOW_COMMANDS.settingsOpenTab); + queue.markReady("settings", "token", CHILD_WINDOW_COMMANDS.settingsOpenTab); + + expect( + queue.dispatch("settings", CHILD_WINDOW_COMMANDS.settingsOpenTab, { tab: "general" }), + ).toEqual([{ event: CHILD_WINDOW_COMMANDS.settingsOpenTab, payload: { tab: "general" } }]); + }); + + it("queues new commands again while the child page reloads", () => { + const queue = new ChildWindowCommandQueue(); + queue.register("settings", "token", CHILD_WINDOW_COMMANDS.settingsOpenTab); + queue.markReady("settings", "token", CHILD_WINDOW_COMMANDS.settingsOpenTab); + + expect(queue.markLoading("settings", "token")).toBe(true); + expect( + queue.dispatch("settings", CHILD_WINDOW_COMMANDS.settingsOpenTab, { tab: "appearance" }), + ).toEqual([]); + expect(queue.markReady("settings", "token", CHILD_WINDOW_COMMANDS.settingsOpenTab)).toEqual([ + { event: CHILD_WINDOW_COMMANDS.settingsOpenTab, payload: { tab: "appearance" } }, + ]); + }); + + it("keeps direct dispatch compatibility for an untracked window", () => { + const queue = new ChildWindowCommandQueue(); + + expect( + queue.dispatch("legacy-window", CHILD_WINDOW_COMMANDS.filePreviewOpen, { name: "a.png" }), + ).toEqual([{ event: CHILD_WINDOW_COMMANDS.filePreviewOpen, payload: { name: "a.png" } }]); + }); + + it("clears state when a window is destroyed", () => { + const queue = new ChildWindowCommandQueue(); + queue.register("settings", "token", CHILD_WINDOW_COMMANDS.settingsOpenTab); + queue.dispatch("settings", CHILD_WINDOW_COMMANDS.settingsOpenTab, { tab: "appearance" }); + + queue.clear("settings"); + + expect( + queue.dispatch("settings", CHILD_WINDOW_COMMANDS.settingsOpenTab, { tab: "general" }), + ).toEqual([{ event: CHILD_WINDOW_COMMANDS.settingsOpenTab, payload: { tab: "general" } }]); + }); + + it("marks a matching token as failed and drops queued commands", () => { + const queue = new ChildWindowCommandQueue(); + queue.register("settings", "token", CHILD_WINDOW_COMMANDS.settingsOpenTab); + queue.dispatch("settings", CHILD_WINDOW_COMMANDS.settingsOpenTab, { tab: "appearance" }); + + expect(queue.markFailed("settings", "token")).toBe(true); + expect(queue.isFailed("settings", "token")).toBe(true); + expect( + queue.dispatch("settings", CHILD_WINDOW_COMMANDS.settingsOpenTab, { tab: "general" }), + ).toEqual([]); + expect(queue.markReady("settings", "token", CHILD_WINDOW_COMMANDS.settingsOpenTab)).toEqual([]); + }); + + it("recovers from a failed state when a new token is registered", () => { + const queue = new ChildWindowCommandQueue(); + queue.register("settings", "token-old", CHILD_WINDOW_COMMANDS.settingsOpenTab); + queue.dispatch("settings", CHILD_WINDOW_COMMANDS.settingsOpenTab, { tab: "appearance" }); + queue.markFailed("settings", "token-old"); + + queue.register("settings", "token-new", CHILD_WINDOW_COMMANDS.settingsOpenTab); + expect(queue.isFailed("settings", "token-new")).toBe(false); + expect( + queue.dispatch("settings", CHILD_WINDOW_COMMANDS.settingsOpenTab, { tab: "general" }), + ).toEqual([]); + expect(queue.markReady("settings", "token-new", CHILD_WINDOW_COMMANDS.settingsOpenTab)).toEqual([ + { event: CHILD_WINDOW_COMMANDS.settingsOpenTab, payload: { tab: "general" } }, + ]); + }); + + it("ignores a failed event from a stale WebView token", () => { + const queue = new ChildWindowCommandQueue(); + queue.register("settings", "token-new", CHILD_WINDOW_COMMANDS.settingsOpenTab); + queue.dispatch("settings", CHILD_WINDOW_COMMANDS.settingsOpenTab, { tab: "appearance" }); + + expect(queue.markFailed("settings", "token-old")).toBe(false); + expect(queue.isFailed("settings")).toBe(false); + expect(queue.markReady("settings", "token-new", CHILD_WINDOW_COMMANDS.settingsOpenTab)).toEqual([ + { event: CHILD_WINDOW_COMMANDS.settingsOpenTab, payload: { tab: "appearance" } }, + ]); + }); +}); diff --git a/src/lib/childWindowCommandQueue.ts b/src/lib/childWindowCommandQueue.ts new file mode 100644 index 000000000..9b7927688 --- /dev/null +++ b/src/lib/childWindowCommandQueue.ts @@ -0,0 +1,88 @@ +import type { ChildWindowCommandName } from "./childWindowProtocol"; + +export interface ChildWindowCommandEnvelope { + event: ChildWindowCommandName; + payload: unknown; +} + +interface ChildWindowCommandState { + token: string; + expectedEvent: ChildWindowCommandName; + status: "loading" | "ready" | "failed"; + pending: ChildWindowCommandEnvelope[]; +} + +/** + * 父窗口只在内存中保存尚未被子页面消费的命令。状态以窗口 label 和 ready token + * 共同隔离,避免已销毁 WebView 的迟到事件释放新窗口队列。 + */ +export class ChildWindowCommandQueue { + private readonly states = new Map(); + + register(label: string, token: string, expectedEvent: ChildWindowCommandName) { + const current = this.states.get(label); + if (current?.token === token && current.expectedEvent === expectedEvent) return; + + this.states.set(label, { + token, + expectedEvent, + status: "loading", + pending: [], + }); + } + + dispatch( + label: string, + event: ChildWindowCommandName, + payload: unknown, + ): ChildWindowCommandEnvelope[] { + const command = { event, payload }; + const state = this.states.get(label); + if (!state || state.expectedEvent !== event || state.status === "ready") { + return [command]; + } + if (state.status === "failed") return []; + + state.pending.push(command); + return []; + } + + markReady( + label: string, + token: string, + event: ChildWindowCommandName, + ): ChildWindowCommandEnvelope[] { + const state = this.states.get(label); + if (!state || state.token !== token || state.expectedEvent !== event) return []; + state.status = "ready"; + return state.pending.splice(0); + } + + markLoading(label: string, token: string) { + const state = this.states.get(label); + if (!state || state.token !== token) return false; + if (state.status === "failed") return false; + + state.status = "loading"; + return true; + } + + markFailed(label: string, token: string) { + const state = this.states.get(label); + if (!state || state.token !== token) return false; + + state.status = "failed"; + state.pending = []; + return true; + } + + isFailed(label: string, token?: string) { + const state = this.states.get(label); + if (!state || state.status !== "failed") return false; + return token === undefined || state.token === token; + } + + clear(label: string) { + this.states.delete(label); + } +} diff --git a/src/lib/childWindowLifecycle.test.ts b/src/lib/childWindowLifecycle.test.ts new file mode 100644 index 000000000..da1519c29 --- /dev/null +++ b/src/lib/childWindowLifecycle.test.ts @@ -0,0 +1,55 @@ +import { beforeEach, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + emit: vi.fn(), + getCurrentWindow: vi.fn(() => ({ label: "file-preview-main" })), +})); + +vi.mock("@tauri-apps/api/event", () => ({ emit: mocks.emit })); +vi.mock("@tauri-apps/api/window", () => ({ getCurrentWindow: mocks.getCurrentWindow })); + +beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + mocks.emit.mockResolvedValue(undefined); + window.history.replaceState({}, "", "/?window=file-preview&readyToken=token"); +}); + +it("sends load-started once before later lifecycle phases", async () => { + const lifecycle = await import("./childWindowLifecycle"); + + await Promise.all([ + lifecycle.signalChildWindowLoadStarted(), + lifecycle.signalChildWindowLoadStarted(), + lifecycle.signalChildWindowCommandReady("file-preview-open"), + ]); + + expect(mocks.emit).toHaveBeenCalledTimes(2); + expect(mocks.emit.mock.calls.map((call) => call[1])).toEqual([ + { + label: "file-preview-main", + token: "token", + phase: "load-started", + }, + { + label: "file-preview-main", + token: "token", + phase: "command-ready", + command: "file-preview-open", + }, + ]); +}); + +it("allows a later lifecycle signal to retry a failed load-started emit", async () => { + mocks.emit.mockRejectedValueOnce(new Error("emit failed")).mockResolvedValue(undefined); + const lifecycle = await import("./childWindowLifecycle"); + + await expect(lifecycle.signalChildWindowLoadStarted()).rejects.toThrow("emit failed"); + await lifecycle.signalChildWindowLoadFailed("bootstrap-import"); + + expect(mocks.emit.mock.calls.map((call) => call[1].phase)).toEqual([ + "load-started", + "load-started", + "load-failed", + ]); +}); diff --git a/src/lib/childWindowLifecycle.ts b/src/lib/childWindowLifecycle.ts new file mode 100644 index 000000000..74b07b2d1 --- /dev/null +++ b/src/lib/childWindowLifecycle.ts @@ -0,0 +1,134 @@ +import { emit } from "@tauri-apps/api/event"; +import { getCurrentWindow } from "@tauri-apps/api/window"; +import { + CHILD_WINDOW_LIFECYCLE_EVENT, + CHILD_WINDOW_READY_TOKEN_PARAM, + type ChildWindowCommandName, + type ChildWindowLifecyclePayload, + type ChildWindowLoadFailureStage, +} from "./childWindowProtocol"; + +// load-started 不阻塞 shell 渲染;后续信号复用该 Promise,保持单次加载内的顺序。 +let loadStartedPromise: Promise | undefined; + +function lifecycleIdentity() { + const token = new URLSearchParams(window.location.search).get(CHILD_WINDOW_READY_TOKEN_PARAM); + return { + label: getCurrentWindow().label, + token: token ?? undefined, + }; +} + +function emitChildWindowLifecycle( + payload: + | { phase: "load-started" } + | { phase: "shell-ready" } + | { phase: "command-ready"; command: ChildWindowCommandName } + | { phase: "load-failed"; stage: ChildWindowLoadFailureStage }, +) { + return emit(CHILD_WINDOW_LIFECYCLE_EVENT, { + ...lifecycleIdentity(), + ...payload, + } satisfies ChildWindowLifecyclePayload); +} + +export function signalChildWindowLoadStarted() { + loadStartedPromise ??= emitChildWindowLifecycle({ phase: "load-started" }).catch((error) => { + loadStartedPromise = undefined; + throw error; + }); + return loadStartedPromise; +} + +function signalChildWindowLifecycle( + payload: + | { phase: "shell-ready" } + | { phase: "command-ready"; command: ChildWindowCommandName } + | { phase: "load-failed"; stage: ChildWindowLoadFailureStage }, +) { + return signalChildWindowLoadStarted().then(() => emitChildWindowLifecycle(payload)); +} + +export function signalChildWindowCommandReady(command: ChildWindowCommandName) { + return signalChildWindowLifecycle({ phase: "command-ready", command }); +} + +export function signalChildWindowLoadFailed(stage: ChildWindowLoadFailureStage) { + return signalChildWindowLifecycle({ phase: "load-failed", stage }); +} + +/** + * loading shell 形成稳定布局后再通知父窗口显示。隐藏 WebView 可能暂停 rAF, + * 因此 fallback 只确认 shell 已挂载,不等待字体、provider 或业务页面。 + */ +export function scheduleChildWindowShellReady() { + let settled = false; + let firstFrameId: number | undefined; + let secondFrameId: number | undefined; + let contentPollTimeoutId: number | undefined; + let fallbackTimeoutId: number | undefined; + + const cleanup = () => { + settled = true; + if (firstFrameId !== undefined) window.cancelAnimationFrame(firstFrameId); + if (secondFrameId !== undefined) window.cancelAnimationFrame(secondFrameId); + if (contentPollTimeoutId !== undefined) window.clearTimeout(contentPollTimeoutId); + if (fallbackTimeoutId !== undefined) window.clearTimeout(fallbackTimeoutId); + }; + + const hasMountedContent = () => { + const root = document.getElementById("root"); + if (!root?.firstElementChild) return false; + const rect = root.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }; + + const signalReady = () => { + cleanup(); + void signalChildWindowLifecycle({ phase: "shell-ready" }).catch(() => {}); + }; + + const waitForMountedContent = () => { + if (settled) return; + if (hasMountedContent()) { + waitForPaint(); + return; + } + contentPollTimeoutId = window.setTimeout(waitForMountedContent, 16); + }; + + const emitReady = () => { + if (settled) return; + if (!hasMountedContent()) { + waitForMountedContent(); + return; + } + signalReady(); + }; + + const emitReadyFromFallback = () => { + if (settled) return; + if (hasMountedContent()) { + signalReady(); + return; + } + fallbackTimeoutId = window.setTimeout(emitReadyFromFallback, 16); + }; + + function waitForPaint() { + if (settled) return; + if (typeof window.requestAnimationFrame !== "function") { + emitReady(); + return; + } + + firstFrameId = window.requestAnimationFrame(() => { + secondFrameId = window.requestAnimationFrame(emitReady); + }); + } + + waitForMountedContent(); + fallbackTimeoutId = window.setTimeout(emitReadyFromFallback, 250); + + return cleanup; +} diff --git a/src/lib/childWindowProtocol.ts b/src/lib/childWindowProtocol.ts new file mode 100644 index 000000000..b3cb913d1 --- /dev/null +++ b/src/lib/childWindowProtocol.ts @@ -0,0 +1,29 @@ +export const CHILD_WINDOW_LIFECYCLE_EVENT = "child-window-lifecycle"; +export const CHILD_WINDOW_READY_TOKEN_PARAM = "readyToken"; + +export const CHILD_WINDOW_COMMANDS = { + settingsOpenTab: "settings-open-tab", + remoteFileEditorOpen: "remote-file-editor-open", + filePreviewOpen: "file-preview-open", +} as const; + +export type ChildWindowCommandName = + (typeof CHILD_WINDOW_COMMANDS)[keyof typeof CHILD_WINDOW_COMMANDS]; + +export type ChildWindowLoadFailureStage = "bootstrap-import" | "command-listener"; + +export type ChildWindowLifecyclePayload = + | { label: string; token?: string; phase: "load-started" } + | { label: string; token?: string; phase: "shell-ready" } + | { + label: string; + token?: string; + phase: "command-ready"; + command: ChildWindowCommandName; + } + | { + label: string; + token?: string; + phase: "load-failed"; + stage: ChildWindowLoadFailureStage; + }; diff --git a/src/lib/fileEditorLimits.ts b/src/lib/fileEditorLimits.ts new file mode 100644 index 000000000..5d48f2cad --- /dev/null +++ b/src/lib/fileEditorLimits.ts @@ -0,0 +1 @@ +export const MAX_EDITOR_FILE_BYTES = 5 * 1024 * 1024; diff --git a/src/lib/tabWindows.test.ts b/src/lib/tabWindows.test.ts index e2b13aca6..7ef050468 100644 --- a/src/lib/tabWindows.test.ts +++ b/src/lib/tabWindows.test.ts @@ -12,7 +12,7 @@ describe("terminal window persistence", () => { connectionId: "ssh-1", backend: "remote", path: "/srv/notes.md", - file: { content: "notes", size: 5, mtime: 42 }, + file: { content: "notes", size: 5, mtime: 42, contentHash: "hash-notes" }, }), 0, ); diff --git a/src/lib/windowManager.test.ts b/src/lib/windowManager.test.ts index 450535d9f..ed0ef550f 100644 --- a/src/lib/windowManager.test.ts +++ b/src/lib/windowManager.test.ts @@ -1,5 +1,162 @@ -import { describe, expect, it } from "vitest"; -import { centerWindowRectInWorkArea, rectOverlapsWorkArea } from "./windowManager"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { CHILD_WINDOW_LIFECYCLE_EVENT, type ChildWindowLifecyclePayload } from "./childWindowProtocol"; + +const mocks = vi.hoisted(() => { + type MockWindow = { + label: string; + close: () => Promise; + hide: () => Promise; + isVisible: () => Promise; + once: (event: string, handler: () => void) => Promise; + outerPosition: () => Promise<{ x: number; y: number }>; + outerSize: () => Promise<{ width: number; height: number }>; + requestUserAttention: () => Promise; + setAlwaysOnTop: () => Promise; + setEnabled: () => Promise; + setFocus: () => Promise; + setFocusable: () => Promise; + setPosition: () => Promise; + setTitle: () => Promise; + show: () => Promise; + }; + + const listeners = new Map void>(); + const windows = new Map(); + const currentWindow = createMockWindow("main", windows); + + function createMockWindow(label: string, registry: Map): MockWindow { + const destroyedHandlers: Array<() => void> = []; + const win = { + label, + close: vi.fn(async () => { + registry.delete(label); + for (const handler of destroyedHandlers) handler(); + }), + hide: vi.fn(async () => {}), + isVisible: vi.fn(async () => true), + once: vi.fn(async (event: string, handler: () => void) => { + if (event === "tauri://destroyed") destroyedHandlers.push(handler); + }), + outerPosition: vi.fn(async () => ({ x: 0, y: 0 })), + outerSize: vi.fn(async () => ({ width: 800, height: 560 })), + requestUserAttention: vi.fn(async () => {}), + setAlwaysOnTop: vi.fn(async () => {}), + setEnabled: vi.fn(async () => {}), + setFocus: vi.fn(async () => {}), + setFocusable: vi.fn(async () => {}), + setPosition: vi.fn(async () => {}), + setTitle: vi.fn(async () => {}), + show: vi.fn(async () => {}), + }; + return win; + } + + return { + availableMonitors: vi.fn(async () => [ + { + workArea: { + position: { x: 0, y: 0 }, + size: { width: 1920, height: 1040 }, + }, + }, + ]), + createMockWindow, + currentWindow, + emit: vi.fn(async () => {}), + getAll: vi.fn(async () => Array.from(windows.values())), + getByLabel: vi.fn(async (label: string) => windows.get(label) ?? null), + getCurrentWindow: vi.fn(() => currentWindow), + invoke: vi.fn(async (_command: string, args?: { options?: { label?: string } }) => { + const label = args?.options?.label; + if (label) windows.set(label, createMockWindow(label, windows)); + }), + listen: vi.fn(async (event: string, handler: (event: { payload: unknown }) => void) => { + listeners.set(event, handler); + return () => listeners.delete(event); + }), + listeners, + primaryMonitor: vi.fn(async () => ({ + workArea: { + position: { x: 0, y: 0 }, + size: { width: 1920, height: 1040 }, + }, + })), + windows, + }; +}); + +vi.mock("@tauri-apps/api/event", () => ({ + emit: mocks.emit, + listen: mocks.listen, +})); + +vi.mock("@tauri-apps/api/webviewWindow", () => ({ + WebviewWindow: { + getAll: mocks.getAll, + getByLabel: mocks.getByLabel, + }, +})); + +vi.mock("@tauri-apps/api/window", () => ({ + availableMonitors: mocks.availableMonitors, + getCurrentWindow: mocks.getCurrentWindow, + PhysicalPosition: class PhysicalPosition { + constructor( + public x: number, + public y: number, + ) {} + }, + primaryMonitor: mocks.primaryMonitor, + UserAttentionType: { Critical: 1 }, +})); + +vi.mock("./invoke", () => ({ invoke: mocks.invoke })); +vi.mock("../i18n", () => ({ default: { t: (key: string) => key } })); +vi.mock("./logger", () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + }, +})); + +beforeEach(() => { + vi.clearAllMocks(); + vi.resetModules(); + mocks.listeners.clear(); + mocks.windows.clear(); +}); + +async function importWindowManager() { + return import("./windowManager"); +} + +function emitLifecycle(payload: ChildWindowLifecyclePayload) { + mocks.listeners.get(CHILD_WINDOW_LIFECYCLE_EVENT)?.({ payload }); +} + +async function waitForInvoke() { + await vi.waitFor(() => expect(mocks.invoke).toHaveBeenCalledWith("open_child_window", expect.anything())); +} + +function createdToken(callIndex = 0) { + const args = mocks.invoke.mock.calls[callIndex][1] as { options: { url: string } }; + const params = new URLSearchParams(args.options.url.slice(args.options.url.indexOf("?") + 1)); + const token = params.get("readyToken"); + expect(token).toBeTruthy(); + return token as string; +} + +describe("child window command mapping", () => { + it.each([ + ["index.html?window=settings", "settings-open-tab"], + ["index.html?window=file-editor", "remote-file-editor-open"], + ["index.html?window=file-preview", "file-preview-open"], + ["index.html?window=new-session", undefined], + ])("maps %s to %s", async (url, expected) => { + const { childWindowCommandForUrl } = await importWindowManager(); + expect(childWindowCommandForUrl(url)).toBe(expected); + }); +}); describe("child window work-area helpers", () => { const primaryWorkArea = { @@ -7,22 +164,105 @@ describe("child window work-area helpers", () => { size: { width: 1920, height: 1040 }, }; - it("detects a child window completely outside disconnected monitor bounds", () => { - expect( - rectOverlapsWorkArea({ x: 2500, y: 100, width: 800, height: 560 }, primaryWorkArea), - ).toBe(false); + it("detects a child window completely outside disconnected monitor bounds", async () => { + const { rectOverlapsWorkArea } = await importWindowManager(); + expect(rectOverlapsWorkArea({ x: 2500, y: 100, width: 800, height: 560 }, primaryWorkArea)).toBe( + false, + ); }); - it("keeps a child window that still intersects the visible work area", () => { - expect( - rectOverlapsWorkArea({ x: 1800, y: 100, width: 800, height: 560 }, primaryWorkArea), - ).toBe(true); + it("keeps a child window that still intersects the visible work area", async () => { + const { rectOverlapsWorkArea } = await importWindowManager(); + expect(rectOverlapsWorkArea({ x: 1800, y: 100, width: 800, height: 560 }, primaryWorkArea)).toBe( + true, + ); }); - it("centers an off-screen child window in the selected work area", () => { + it("centers an off-screen child window in the selected work area", async () => { + const { centerWindowRectInWorkArea } = await importWindowManager(); expect(centerWindowRectInWorkArea({ width: 800, height: 560 }, primaryWorkArea)).toEqual({ x: 560, y: 240, }); }); }); + +describe("child window load failure recovery", () => { + it("closes and clears a revealed window after the command listener fails", async () => { + const { openSettings } = await importWindowManager(); + const open = openSettings("appearance"); + await waitForInvoke(); + const token = createdToken(); + emitLifecycle({ label: "settings", token, phase: "shell-ready" }); + await open; + + const win = mocks.windows.get("settings"); + expect(win?.show).toHaveBeenCalled(); + emitLifecycle({ label: "settings", token, phase: "load-failed", stage: "command-listener" }); + + await vi.waitFor(() => expect(win?.close).toHaveBeenCalledOnce()); + expect(mocks.windows.has("settings")).toBe(false); + }); + + it("recreates a failed existing window on the next open", async () => { + const { openSettings } = await importWindowManager(); + const firstOpen = openSettings("appearance"); + await waitForInvoke(); + const firstToken = createdToken(); + emitLifecycle({ label: "settings", token: firstToken, phase: "shell-ready" }); + await firstOpen; + + const firstWindow = mocks.windows.get("settings"); + emitLifecycle({ + label: "settings", + token: firstToken, + phase: "load-failed", + stage: "command-listener", + }); + await vi.waitFor(() => expect(firstWindow?.close).toHaveBeenCalledOnce()); + + const secondOpen = openSettings("general"); + await vi.waitFor(() => expect(mocks.invoke).toHaveBeenCalledTimes(2)); + const secondToken = createdToken(1); + emitLifecycle({ label: "settings", token: secondToken, phase: "shell-ready" }); + await secondOpen; + + const secondWindow = mocks.windows.get("settings"); + expect(secondWindow).toBeTruthy(); + expect(secondWindow).not.toBe(firstWindow); + }); + + it("fails first open promptly and closes the orphan when bootstrap fails before shell ready", async () => { + const { openSettings } = await importWindowManager(); + const open = openSettings("appearance"); + await waitForInvoke(); + const token = createdToken(); + const win = mocks.windows.get("settings"); + + emitLifecycle({ label: "settings", token, phase: "load-failed", stage: "bootstrap-import" }); + + await expect(open).rejects.toThrow("Child window did not finish rendering: settings"); + await vi.waitFor(() => expect(win?.close).toHaveBeenCalled()); + }); + + it("ignores stale load-failed events from an old token", async () => { + const { openSettings } = await importWindowManager(); + const open = openSettings("appearance"); + await waitForInvoke(); + const token = createdToken(); + emitLifecycle({ label: "settings", token, phase: "shell-ready" }); + await open; + + const win = mocks.windows.get("settings"); + emitLifecycle({ + label: "settings", + token: "stale-token", + phase: "load-failed", + stage: "command-listener", + }); + + await Promise.resolve(); + expect(win?.close).not.toHaveBeenCalled(); + expect(mocks.windows.get("settings")).toBe(win); + }); +}); diff --git a/src/lib/windowManager.ts b/src/lib/windowManager.ts index 88fbdf66e..f2497f8f1 100644 --- a/src/lib/windowManager.ts +++ b/src/lib/windowManager.ts @@ -9,6 +9,14 @@ import { UserAttentionType, } from "@tauri-apps/api/window"; import i18n from "../i18n"; +import { ChildWindowCommandQueue } from "./childWindowCommandQueue"; +import { + CHILD_WINDOW_COMMANDS, + CHILD_WINDOW_LIFECYCLE_EVENT, + CHILD_WINDOW_READY_TOKEN_PARAM, + type ChildWindowCommandName, + type ChildWindowLifecyclePayload, +} from "./childWindowProtocol"; import { invoke } from "./invoke"; import { logger } from "./logger"; import { isMacOS } from "./platform"; @@ -51,11 +59,15 @@ const MODAL_CHILD_BASE_LABELS = new Set([ ]); const MODAL_GROUP_RAISE_SUPPRESS_MS = 250; const MODAL_TOPMOST_PULSE_MS = 120; -const CHILD_WINDOW_READY_EVENT = "child-window-ready"; const CHILD_WINDOW_READY_TIMEOUT_MS = 5_000; const INIT_URL_ONLY_WINDOW_TYPES = new Set(["new-session", "quick-command"]); -const registeredDestroyedHandlers = new Set(); +const registeredDestroyedHandlers = new Map(); const pendingChildWindowOpens = new Map(); +const childWindowCommands = new ChildWindowCommandQueue(); +const childWindowTokens = new Map(); +const childWindowShellWaiters = new Map(); +const failedChildWindowClosures = new Map(); +let childWindowLifecycleListenerPromise: Promise | undefined; let ownerMainWindowLabel = MAIN_WINDOW_LABEL; let modalGroupRaiseInFlight = false; let suppressChildFocusSyncUntil = 0; @@ -70,13 +82,13 @@ interface ModalGroupRaiseOptions { reason?: ModalGroupRaiseReason; } -interface ChildWindowReadyPayload { - label: string; -} - -interface ChildWindowReadyWaiter { +interface ChildWindowLifecycleWaiter { + token: string; promise: Promise; + resolve: () => void; cancel: () => void; + fail: () => void; + failed: () => boolean; } interface PendingChildWindowOpen { @@ -114,10 +126,6 @@ export function isPrimaryMainWindow() { return ownerMainWindowLabel === MAIN_WINDOW_LABEL; } -export function signalChildWindowReady() { - return emit(CHILD_WINDOW_READY_EVENT, { label: getCurrentWindow().label }); -} - function scopedModalLabel(baseLabel: string, ownerLabel = ownerMainWindowLabel) { return ownerLabel === MAIN_WINDOW_LABEL ? baseLabel : `${baseLabel}-${ownerLabel}`; } @@ -178,6 +186,30 @@ function childWindowTypeFromUrl(url: string) { } } +export function childWindowCommandForUrl(url: string): ChildWindowCommandName | undefined { + switch (childWindowTypeFromUrl(url)) { + case "settings": + return CHILD_WINDOW_COMMANDS.settingsOpenTab; + case "file-editor": + return CHILD_WINDOW_COMMANDS.remoteFileEditorOpen; + case "file-preview": + return CHILD_WINDOW_COMMANDS.filePreviewOpen; + default: + return undefined; + } +} + +function appendChildWindowReadyToken(url: string, token: string) { + const separator = url.includes("?") ? "&" : "?"; + return `${url}${separator}${CHILD_WINDOW_READY_TOKEN_PARAM}=${encodeURIComponent(token)}`; +} + +function createChildWindowReadyToken() { + return typeof crypto.randomUUID === "function" + ? crypto.randomUUID() + : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; +} + function shouldWarnPendingOpenConflict(existingUrl: string, requestedUrl: string) { const existingWindowType = childWindowTypeFromUrl(existingUrl); const requestedWindowType = childWindowTypeFromUrl(requestedUrl); @@ -399,17 +431,33 @@ export async function raiseModalChildWindowGroup(options: ModalGroupRaiseOptions } } -function attachChildWindowDestroyedHandler(label: string, win: WebviewWindow) { - if (registeredDestroyedHandlers.has(label)) return; - registeredDestroyedHandlers.add(label); +async function attachChildWindowDestroyedHandler(label: string, win: WebviewWindow) { + const lifecycleToken = childWindowTokens.get(label); + // 回调绑定注册时的窗口代际;旧实例迟到的 destroyed 不得清理同 label 新实例。 + const registrationId = + lifecycleToken ?? registeredDestroyedHandlers.get(label) ?? createChildWindowReadyToken(); + if (registeredDestroyedHandlers.get(label) === registrationId) return; + registeredDestroyedHandlers.set(label, registrationId); - win.once("tauri://destroyed", () => { - registeredDestroyedHandlers.delete(label); - emit("child-window-closed", { label }); - if (isModalChildLabel(label)) { - void prepareForModalChildClose(label); + try { + await win.once("tauri://destroyed", () => { + if (registeredDestroyedHandlers.get(label) !== registrationId) return; + const currentToken = childWindowTokens.get(label); + if (currentToken && currentToken !== lifecycleToken) return; + + registeredDestroyedHandlers.delete(label); + clearChildWindowLifecycle(label, lifecycleToken, true); + void emit("child-window-closed", { label }).catch(() => {}); + if (isModalChildLabel(label)) { + void prepareForModalChildClose(label).catch(() => {}); + } + }); + } catch (error) { + if (registeredDestroyedHandlers.get(label) === registrationId) { + registeredDestroyedHandlers.delete(label); } - }); + throw error; + } } export async function syncMainWindowModalState() { @@ -424,60 +472,194 @@ export async function bounceTopModalWindow() { await raiseModalChildWindowGroup({ requestAttention: true, reason: "backdrop" }); } -async function createChildWindowReadyWaiter(label: string): Promise { +function emitChildWindowCommands(commands: ReturnType) { + for (const command of commands) { + void emit(command.event, command.payload).catch((error) => { + logger.warn({ + domain: "window.lifecycle", + event: "child_command_emit_failed", + message: "Failed to emit a command to a child window", + data: { command: command.event }, + error, + }); + }); + } +} + +function dispatchChildWindowCommand( + label: string, + event: ChildWindowCommandName, + payload: unknown, +) { + emitChildWindowCommands(childWindowCommands.dispatch(label, event, payload)); +} + +async function closeFailedChildWindow(label: string, token: string) { + failedChildWindowClosures.set(label, token); + try { + const win = await WebviewWindow.getByLabel(label).catch(() => null); + await win?.close().catch((error) => { + logger.warn({ + domain: "window.lifecycle", + event: "child_failed_window_close_failed", + message: "Failed to close a child window after load failure", + data: { label }, + error, + }); + }); + } finally { + if (failedChildWindowClosures.get(label) === token) { + failedChildWindowClosures.delete(label); + } + clearChildWindowLifecycle(label, token, true); + } +} + +function handleChildWindowLifecycle(payload: ChildWindowLifecyclePayload) { + if (!payload.token || childWindowTokens.get(payload.label) !== payload.token) return; + + if (payload.phase === "load-started") { + childWindowCommands.markLoading(payload.label, payload.token); + return; + } + + switch (payload.phase) { + case "shell-ready": { + const waiter = childWindowShellWaiters.get(payload.label); + if (waiter?.token === payload.token) waiter.resolve(); + break; + } + case "command-ready": + emitChildWindowCommands( + childWindowCommands.markReady(payload.label, payload.token, payload.command), + ); + break; + case "load-failed": + childWindowCommands.markFailed(payload.label, payload.token); + { + const waiter = childWindowShellWaiters.get(payload.label); + if (waiter?.token === payload.token) waiter.fail(); + } + logger.warn({ + domain: "window.lifecycle", + event: "child_load_failed", + message: "Child window failed to finish loading", + data: { label: payload.label, stage: payload.stage }, + }); + void closeFailedChildWindow(payload.label, payload.token); + break; + } +} + +async function ensureChildWindowLifecycleListener() { + if (!childWindowLifecycleListenerPromise) { + childWindowLifecycleListenerPromise = listen( + CHILD_WINDOW_LIFECYCLE_EVENT, + ({ payload }) => handleChildWindowLifecycle(payload), + ) + .then(() => undefined) + .catch((error) => { + childWindowLifecycleListenerPromise = undefined; + logger.warn({ + domain: "window.lifecycle", + event: "child_lifecycle_listener_failed", + message: "Failed to listen for child window lifecycle events", + error, + }); + throw error; + }); + } + await childWindowLifecycleListenerPromise; +} + +function clearChildWindowLifecycle(label: string, token?: string, failWaiter = false) { + if (token && childWindowTokens.get(label) !== token) return; + + childWindowTokens.delete(label); + childWindowCommands.clear(label); + const waiter = childWindowShellWaiters.get(label); + if (failWaiter) waiter?.fail(); + else waiter?.cancel(); +} + +async function createChildWindowLifecycleWaiter( + label: string, + token: string, + expectedCommand: ChildWindowCommandName | undefined, +): Promise { + await ensureChildWindowLifecycleListener(); + + childWindowShellWaiters.get(label)?.cancel(); + childWindowTokens.set(label, token); + if (expectedCommand) { + childWindowCommands.register(label, token, expectedCommand); + } + let settled = false; let timeoutId: number | undefined; - let unlisten: (() => void) | undefined; + let failed = false; let resolveReady: () => void = () => {}; const promise = new Promise((resolve) => { resolveReady = resolve; }); - const settle = () => { + const settle = (didFail: boolean) => { if (settled) return; settled = true; + failed = didFail; if (timeoutId !== undefined) { window.clearTimeout(timeoutId); } - unlisten?.(); + if (childWindowShellWaiters.get(label)?.token === token) { + childWindowShellWaiters.delete(label); + } resolveReady(); }; - try { - unlisten = await listen(CHILD_WINDOW_READY_EVENT, ({ payload }) => { - if (payload.label === label) { - settle(); - } - }); - timeoutId = window.setTimeout(() => { - logger.warn({ - domain: "window.lifecycle", - event: "child_ready_timeout", - message: "Child window did not signal ready before timeout", - data: { label }, - }); - settle(); - }, CHILD_WINDOW_READY_TIMEOUT_MS); - } catch (error) { + const waiter: ChildWindowLifecycleWaiter = { + token, + promise, + resolve: () => settle(false), + cancel: () => settle(false), + fail: () => settle(true), + failed: () => failed, + }; + childWindowShellWaiters.set(label, waiter); + + timeoutId = window.setTimeout(() => { logger.warn({ domain: "window.lifecycle", - event: "child_ready_listener_failed", - message: "Failed to listen for child window ready event", + event: "child_ready_timeout", + message: "Child window did not signal shell ready before timeout", data: { label }, - error, }); - settle(); - } + settle(true); + }, CHILD_WINDOW_READY_TIMEOUT_MS); - return { promise, cancel: settle }; + return waiter; } -async function revealChildWindow(win: WebviewWindow, opts: ChildWindowOptions, isModal: boolean) { - await win.setTitle(opts.title).catch(() => {}); - await win.setAlwaysOnTop(needsAlwaysOnTop(opts.label)).catch(() => {}); - attachChildWindowDestroyedHandler(opts.label, win); - await ensureChildWindowVisible(win, opts); - await win.show().catch(() => {}); +async function revealChildWindow( + win: WebviewWindow, + opts: ChildWindowOptions, + isModal: boolean, + isNewWindow = false, + onShown?: () => void, +) { + // The Rust builder already sets the title, always-on-top state, and position for a new window. + // Repeating those IPC calls would delay show(), especially during the first macOS open. + if (!isNewWindow) { + await win.setTitle(opts.title).catch(() => {}); + await win.setAlwaysOnTop(needsAlwaysOnTop(opts.label)).catch(() => {}); + } + await attachChildWindowDestroyedHandler(opts.label, win); + if (!isNewWindow) { + await ensureChildWindowVisible(win, opts); + } + // Keep the child hidden until the ready handshake, then restore interactivity before showing. + await win.setFocusable(true).catch(() => {}); + await win.show(); + onShown?.(); await win.setFocus().catch(() => {}); emit("child-window-opened", { label: opts.label }); if (isModal) { @@ -487,20 +669,53 @@ async function revealChildWindow(win: WebviewWindow, opts: ChildWindowOptions, i } async function openChildWindowInternal(opts: ChildWindowOptions) { + const startedAt = performance.now(); + const logTiming = (data: Record) => { + logger.info({ + domain: "window.lifecycle", + event: "child_window_open_timing", + message: "Child window open timing", + data: { + label: opts.label, + total_ms: Math.round(performance.now() - startedAt), + ...data, + }, + }); + }; const kind = childWindowKind(opts); const isModal = kind === "modal"; const existing = await WebviewWindow.getByLabel(opts.label); if (existing) { - return revealChildWindow(existing, opts, isModal); + const existingToken = childWindowTokens.get(opts.label); + const existingFailed = + existingToken !== undefined && + (childWindowCommands.isFailed(opts.label, existingToken) || + failedChildWindowClosures.get(opts.label) === existingToken); + if (existingFailed) { + await closeFailedChildWindow(opts.label, existingToken); + } else { + let shownMs: number | undefined; + const revealed = await revealChildWindow(existing, opts, isModal, false, () => { + shownMs = Math.round(performance.now() - startedAt); + }); + logTiming({ existing: true, shown_ms: shownMs }); + return revealed; + } } - const readyWaiter = await createChildWindowReadyWaiter(opts.label); + const readyToken = createChildWindowReadyToken(); + const lifecycleWaiter = await createChildWindowLifecycleWaiter( + opts.label, + readyToken, + childWindowCommandForUrl(opts.url), + ); + const listenerReadyMs = Math.round(performance.now() - startedAt); try { await invoke("open_child_window", { options: { label: opts.label, title: opts.title, - url: opts.url, + url: appendChildWindowReadyToken(opts.url, readyToken), kind, parentLabel: opts.parentLabel ?? ownerMainWindowLabel, width: opts.width ?? 720, @@ -510,17 +725,39 @@ async function openChildWindowInternal(opts: ChildWindowOptions) { stateKey: opts.stateKey, }, }); + const invokeMs = Math.round(performance.now() - startedAt); const win = await WebviewWindow.getByLabel(opts.label); if (!win) { throw new Error(`Failed to create child window: ${opts.label}`); } + const handleMs = Math.round(performance.now() - startedAt); - attachChildWindowDestroyedHandler(opts.label, win); - await readyWaiter.promise; - return revealChildWindow(win, opts, isModal); + const destroyedListenerPromise = attachChildWindowDestroyedHandler(opts.label, win); + await Promise.all([lifecycleWaiter.promise, destroyedListenerPromise]); + if (lifecycleWaiter.failed()) { + throw new Error(`Child window did not finish rendering: ${opts.label}`); + } + const readyMs = Math.round(performance.now() - startedAt); + let shownMs: number | undefined; + const revealed = await revealChildWindow(win, opts, isModal, true, () => { + shownMs = Math.round(performance.now() - startedAt); + }); + logTiming({ + existing: false, + listener_ready_ms: listenerReadyMs, + invoke_ms: invokeMs, + handle_ms: handleMs, + ready_ms: readyMs, + shown_ms: shownMs, + }); + return revealed; } catch (error) { - readyWaiter.cancel(); + lifecycleWaiter.cancel(); + clearChildWindowLifecycle(opts.label, readyToken); + // Destroy a failed first-open window promptly so it cannot remain as a background orphan. + const orphan = await WebviewWindow.getByLabel(opts.label).catch(() => null); + await orphan?.close().catch(() => {}); throw error; } } @@ -561,11 +798,12 @@ export function openChildWindow(opts: ChildWindowOptions): Promise { - void win.show().catch(() => {}); - void win.setFocus().catch(() => {}); - emit("settings-open-tab", payload); - }, 120); + dispatchChildWindowCommand(label, CHILD_WINDOW_COMMANDS.settingsOpenTab, payload); } return win; } @@ -632,10 +865,7 @@ export function openNewSessionWithTarget( }); } -export function openQuickCommand( - editJson?: string, - options?: { categoryId?: string | null }, -) { +export function openQuickCommand(editJson?: string, options?: { categoryId?: string | null }) { const params = new URLSearchParams({ window: "quick-command", owner: ownerMainWindowLabel, @@ -735,12 +965,7 @@ export function openRemoteFileEditor(data: RemoteFileEditorWindowData) { stateKey: "file-editor", }).then((win) => { const payload = { targetLabel: label, data }; - emit("remote-file-editor-open", payload); - window.setTimeout(() => { - void win.show().catch(() => {}); - void win.setFocus().catch(() => {}); - emit("remote-file-editor-open", payload); - }, 120); + dispatchChildWindowCommand(label, CHILD_WINDOW_COMMANDS.remoteFileEditorOpen, payload); return win; }); } @@ -769,12 +994,7 @@ export function openFilePreview(data: FilePreviewWindowData) { stateKey: "file-preview", }).then((win) => { const payload = { targetLabel: label, data }; - emit("file-preview-open", payload); - window.setTimeout(() => { - void win.show().catch(() => {}); - void win.setFocus().catch(() => {}); - emit("file-preview-open", payload); - }, 120); + dispatchChildWindowCommand(label, CHILD_WINDOW_COMMANDS.filePreviewOpen, payload); return win; }); } diff --git a/src/lib/workspaceTabs.test.ts b/src/lib/workspaceTabs.test.ts index 520591953..91d0bb636 100644 --- a/src/lib/workspaceTabs.test.ts +++ b/src/lib/workspaceTabs.test.ts @@ -24,7 +24,12 @@ describe("workspaceTabs file documents", () => { connectionId: "ssh-1", backend: "remote", path, - file: { content: `content:${path}`, size: 12, mtime: 42 }, + file: { + content: `content:${path}`, + size: 12, + mtime: 42, + contentHash: `hash:${path}`, + }, }); it("finds an already open file by backend, session and exact path", () => { diff --git a/src/lib/xtermPerformance.ts b/src/lib/xtermPerformance.ts index dd7e4997b..8081dfdb7 100644 --- a/src/lib/xtermPerformance.ts +++ b/src/lib/xtermPerformance.ts @@ -24,6 +24,8 @@ export const XTERM_PERFORMANCE_CONFIG = { strainedBacklogBytes: 128 * 1024, /** Backlog threshold for using microtask low-latency writes on normal shell output. */ lowLatencyFlushBacklogBytes: 64 * 1024, + /** Main-thread time budget for one continuous foreground drain turn. */ + maxForegroundDrainTurnMs: 10, /** Max UTF-8 bytes to write into xterm in a single call. */ writeChunkBytes: 32 * 1024, /** Max UTF-8 bytes to write into xterm during one hidden background drain. */ @@ -36,12 +38,6 @@ export const XTERM_PERFORMANCE_CONFIG = { alternateScreenMaxWriteFps: 20, /** Backlog threshold before alternate-screen foreground writes are throttled. */ alternateScreenThrottleBacklogBytes: 32 * 1024, - /** Queue cap while the terminal is visible. */ - visibleBacklogCapBytes: 1_000_000, - /** Queue cap while an alternate-screen TUI is repainting; older frames are stale. */ - alternateScreenBacklogCapBytes: 128 * 1024, - /** Queue cap while the terminal is hidden; backend flow control normally stops at 1 MiB. */ - hiddenBacklogCapBytes: 2_000_000, /** Recovery threshold after overload while visible. */ visibleRecoveryThresholdBytes: 200_000, /** Recovery threshold after overload while hidden. */ diff --git a/src/main.tsx b/src/main.tsx index 29caca36b..278a7af8f 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -7,9 +7,6 @@ import "@fontsource/inter/400.css"; import "@fontsource/inter/500.css"; import "@fontsource/inter/600.css"; import "@fontsource-variable/noto-sans-sc"; -import "./i18n"; -import ErrorBoundary from "./components/ErrorBoundary"; -import { Toaster } from "./components/ui/sonner"; import "./index.css"; import { applyThemeToDOM, @@ -17,6 +14,11 @@ import { THEME_SNAPSHOT_CACHE_KEY, ThemeProvider, } from "./context/ThemeContext"; +import { + scheduleChildWindowShellReady, + signalChildWindowLoadFailed, + signalChildWindowLoadStarted, +} from "./lib/childWindowLifecycle"; import { DEFAULT_THEME_ID, themes } from "./lib/themes"; import { installWebviewReloadGuard } from "./lib/webviewReloadGuard"; @@ -44,28 +46,94 @@ const params = new URLSearchParams(window.location.search); const windowType = params.get("window"); if (windowType) { + void signalChildWindowLoadStarted().catch(() => {}); // Child window: lightweight provider stack, no full App - const { ChildAppProvider } = await import("./context/ChildAppProvider"); - const { default: ChildWindowRouter } = await import("./ChildWindowRouter"); - - ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( - - - - - - - - - - , + // These entry points are independent and should load in parallel; serial awaits would add an + // unnecessary chunk round trip to every child-window open. + const childRoot = ReactDOM.createRoot(document.getElementById("root") as HTMLElement); + // Commit an inline-background loading shell before loading provider and page chunks. This lets + // the parent reveal a stable surface without reintroducing the macOS white or empty window. + childRoot.render( +
+ +
, ); + scheduleChildWindowShellReady(); + + try { + const [ + { ChildAppProvider }, + { default: ChildWindowRouter }, + { default: ErrorBoundary }, + { Toaster }, + ] = await Promise.all([ + import("./context/ChildAppProvider"), + import("./ChildWindowRouter"), + import("./components/ErrorBoundary"), + import("./components/ui/sonner"), + ]); + + childRoot.render( + + + + + + + + + + , + ); + } catch { + void signalChildWindowLoadFailed("bootstrap-import").catch(() => {}); + let errorTitle = "Something went wrong"; + let reloadLabel = "Reload"; + try { + const { default: i18n } = await import("./i18n"); + errorTitle = i18n.t("error.somethingWentWrong"); + reloadLabel = i18n.t("error.reloadApplication"); + } catch {} + childRoot.render( +
+
+

{errorTitle}

+ +
+
, + ); + } } else { // Main window: full app with all providers - const { getCurrentWindow } = await import("@tauri-apps/api/window"); - const { setOwnerMainWindowLabel } = await import("./lib/windowManager"); - const { AppProvider } = await import("./context/AppContext"); - const { default: App } = await import("./App"); + const [ + { getCurrentWindow }, + { setOwnerMainWindowLabel }, + { AppProvider }, + { default: App }, + { default: ErrorBoundary }, + { Toaster }, + ] = await Promise.all([ + import("@tauri-apps/api/window"), + import("./lib/windowManager"), + import("./context/AppProvider"), + import("./App"), + import("./components/ErrorBoundary"), + import("./components/ui/sonner"), + ]); setOwnerMainWindowLabel(getCurrentWindow().label); ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( diff --git a/src/pages/FilePreviewPage.tsx b/src/pages/FilePreviewPage.tsx index f5ab2d3b4..4f69f096f 100644 --- a/src/pages/FilePreviewPage.tsx +++ b/src/pages/FilePreviewPage.tsx @@ -1,4 +1,3 @@ -import { listen } from "@tauri-apps/api/event"; import { join, tempDir } from "@tauri-apps/api/path"; import { getCurrentWindow } from "@tauri-apps/api/window"; import { openPath } from "@tauri-apps/plugin-opener"; @@ -36,6 +35,8 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { useApp } from "@/context/AppContext"; +import { useChildWindowCommand } from "@/hooks/useChildWindowCommand"; +import { CHILD_WINDOW_COMMANDS } from "@/lib/childWindowProtocol"; import { getErrorMessage } from "@/lib/errors"; import { invoke } from "@/lib/invoke"; import { cn, formatSize, parseJsonSearchParam } from "@/lib/utils"; @@ -145,9 +146,9 @@ export default function FilePreviewPage() { const updateTabs = useCallback( (updater: (tabs: PreviewTab[]) => PreviewTab[]) => { - const next = updater(tabsRef.current); - tabsRef.current = next; - setTabs(next); + const next = updater(tabsRef.current); + tabsRef.current = next; + setTabs(next); }, [], ); @@ -178,27 +179,14 @@ export default function FilePreviewPage() { [activateTab, updateTabs], ); - useEffect(() => { - const currentWindow = getCurrentWindow(); - let unlisten: (() => void) | undefined; - - listen("file-preview-open", (event) => { - if ( - event.payload.targetLabel && - event.payload.targetLabel !== currentWindow.label - ) - return; - addOrFocusTab(event.payload.data); - }) - .then((dispose) => { - unlisten = dispose; - }) - .catch(() => {}); - - return () => { - unlisten?.(); - }; - }, [addOrFocusTab]); + useChildWindowCommand( + CHILD_WINDOW_COMMANDS.filePreviewOpen, + (payload) => { + const currentWindow = getCurrentWindow(); + if (payload.targetLabel && payload.targetLabel !== currentWindow.label) return; + addOrFocusTab(payload.data); + }, + ); useEffect(() => { const currentWindow = getCurrentWindow(); diff --git a/src/pages/RemoteFileEditorPage.tsx b/src/pages/RemoteFileEditorPage.tsx index 0f628ecce..8b4910bfa 100644 --- a/src/pages/RemoteFileEditorPage.tsx +++ b/src/pages/RemoteFileEditorPage.tsx @@ -1,7 +1,6 @@ import { closeSearchPanel } from "@codemirror/search"; import { EditorState } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; -import { listen } from "@tauri-apps/api/event"; import { join, tempDir } from "@tauri-apps/api/path"; import { getCurrentWindow } from "@tauri-apps/api/window"; import { openPath } from "@tauri-apps/plugin-opener"; @@ -36,6 +35,8 @@ import { } from "@/components/ui/dropdown-menu"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { useApp } from "@/context/AppContext"; +import { useChildWindowCommand } from "@/hooks/useChildWindowCommand"; +import { CHILD_WINDOW_COMMANDS } from "@/lib/childWindowProtocol"; import { type CursorPosition, codeMirrorFileViewExtensions, @@ -43,12 +44,11 @@ import { getDisplayLanguage, } from "@/lib/codeMirrorFileView"; import { getErrorMessage } from "@/lib/errors"; +import { MAX_EDITOR_FILE_BYTES } from "@/lib/fileEditorLimits"; import { invoke } from "@/lib/invoke"; import { cn, formatSize, parseJsonSearchParam } from "@/lib/utils"; import type { FileWindowTarget } from "@/lib/windowManager"; -const MAX_EDITOR_FILE_BYTES = 5 * 1024 * 1024; - type FileEditorBackendKind = "remote" | "local"; interface RemoteFileEditorData { @@ -80,6 +80,8 @@ interface EditorTab { content: string; baseSize: number; baseMtime: number; + baseMtimeNanos?: string; + baseContentHash: string; loading: boolean; saving: boolean; dirty: boolean; @@ -91,7 +93,9 @@ interface EditorTab { interface WriteRemoteFileTextResult { status: "saved" | "conflict"; mtime?: number; + mtimeNanos?: string; size?: number; + contentHash?: string; } function getEditorDataPath(data: Pick) { @@ -140,6 +144,8 @@ function createTab(data: RemoteFileEditorData): EditorTab { content: "", baseSize: data.size, baseMtime: data.mtime, + baseMtimeNanos: undefined, + baseContentHash: "", loading: true, saving: false, dirty: false, @@ -275,6 +281,8 @@ export default function RemoteFileEditorPage() { content: result.content, baseSize: result.size, baseMtime: result.mtime ?? current.mtime ?? 0, + baseMtimeNanos: result.mtimeNanos, + baseContentHash: result.contentHash, size: result.size, mtime: result.mtime ?? current.mtime ?? 0, loading: false, @@ -317,23 +325,14 @@ export default function RemoteFileEditorPage() { void loadFile(tabId(initialData)); }, [initialData, loadFile]); - useEffect(() => { - const currentWindow = getCurrentWindow(); - let unlisten: (() => void) | undefined; - - listen("remote-file-editor-open", (event) => { - if (event.payload.targetLabel && event.payload.targetLabel !== currentWindow.label) return; - addOrFocusTab(event.payload.data); - }) - .then((dispose) => { - unlisten = dispose; - }) - .catch(() => {}); - - return () => { - unlisten?.(); - }; - }, [addOrFocusTab]); + useChildWindowCommand( + CHILD_WINDOW_COMMANDS.remoteFileEditorOpen, + (payload) => { + const currentWindow = getCurrentWindow(); + if (payload.targetLabel && payload.targetLabel !== currentWindow.label) return; + addOrFocusTab(payload.data); + }, + ); useEffect(() => { const currentWindow = getCurrentWindow(); @@ -434,6 +433,8 @@ export default function RemoteFileEditorPage() { content: tab.content, expectedMtime: tab.baseMtime, expectedSize: tab.baseSize, + expectedMtimeNanos: tab.baseMtimeNanos, + expectedHash: tab.baseContentHash || undefined, force, }, ); @@ -444,7 +445,9 @@ export default function RemoteFileEditorPage() { updateTab(id, (current) => ({ ...current, baseMtime: result.mtime ?? current.baseMtime, + baseMtimeNanos: result.mtimeNanos ?? current.baseMtimeNanos, baseSize: result.size ?? new Blob([current.content]).size, + baseContentHash: result.contentHash ?? current.baseContentHash, mtime: result.mtime ?? current.mtime, size: result.size ?? current.size, dirty: false, diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 0e0ab98d0..7cfe9f295 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -1,4 +1,3 @@ -import { listen } from "@tauri-apps/api/event"; import { getCurrentWindow } from "@tauri-apps/api/window"; import { type ComponentType, @@ -57,7 +56,9 @@ import { Button } from "@/components/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { AppContext, useApp } from "@/context/AppContext"; import { SettingsDraftContext } from "@/context/SettingsDraftContext"; +import { useChildWindowCommand } from "@/hooks/useChildWindowCommand"; import { useSettingsDraftState } from "@/hooks/useSettingsDraftState"; +import { CHILD_WINDOW_COMMANDS } from "@/lib/childWindowProtocol"; import { type CloudSyncValidationCode, getCloudSyncValidationErrors } from "@/lib/cloudSync"; import { getErrorMessage } from "@/lib/errors"; import { invoke } from "@/lib/invoke"; @@ -133,19 +134,13 @@ export default function SettingsPage() { } }, [activeTab]); - useEffect(() => { - const unlisten = listen<{ tab: string; targetWindowLabel?: string | null }>( - "settings-open-tab", - ({ payload }) => { - if (payload.targetWindowLabel && payload.targetWindowLabel !== ownerWindowLabel) return; - setActiveTab(normalizeSettingsTab(payload.tab)); - }, - ); - - return () => { - unlisten.then((dispose) => dispose()); - }; - }, [ownerWindowLabel]); + useChildWindowCommand<{ tab: string; targetWindowLabel?: string | null }>( + CHILD_WINDOW_COMMANDS.settingsOpenTab, + (payload) => { + if (payload.targetWindowLabel && payload.targetWindowLabel !== ownerWindowLabel) return; + setActiveTab(normalizeSettingsTab(payload.tab)); + }, + ); type SettingsCategory = { id: string; @@ -457,6 +452,7 @@ export default function SettingsPage() { } + macOSDragOnly onClose={requestClose} /> diff --git a/src/types/global.d.ts b/src/types/global.d.ts index e3559e681..f08ef4764 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -133,6 +133,8 @@ export interface FileDocumentSnapshot { content: string; size: number; mtime: number; + mtimeNanos?: string; + contentHash: string; } /** Runtime-only editable document backed by an existing terminal session. */