refactor: improve child window loading experience and enhance file handling

- Removed the `signalChildWindowReady` function and replaced it with a loading shell in `ChildWindowRouter` to provide a better user experience during loading.
- Updated `FolderDialog` to prevent multiple submissions when pressing Enter and clicking Save simultaneously.
- Enhanced file handling in `FileDocumentEditor` and related components by adding `contentHash` and `mtimeNanos` properties for better file state management.
- Added tests for new functionality in `FolderDialog` and `FileDocumentEditor` to ensure reliability.
- Introduced `AlternateScreenStateTracker` and `Dec2026FrameGate` for improved terminal handling and performance metrics.
This commit is contained in:
Kang
2026-08-16 14:45:10 +08:00
59 changed files with 4989 additions and 1915 deletions
+3
View File
@@ -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"] }
+15
View File
@@ -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))
+109 -6
View File
@@ -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<Remo
}
let bytes = tokio::fs::read(&path).await?;
let file_hash = content_hash(&bytes);
let content = String::from_utf8(bytes)
.map_err(|_| AppError::Config("File is not valid UTF-8 text".to_string()))?;
Ok(RemoteTextFile {
@@ -276,6 +278,8 @@ async fn read_local_file_text_impl(path: &str, max_bytes: u64) -> AppResult<Remo
content,
size: metadata.len(),
mtime: modified_time_secs(&metadata),
mtime_nanos: modified_time_nanos(&metadata),
content_hash: file_hash,
})
}
@@ -310,6 +314,7 @@ async fn read_local_file_bytes_impl(path: &str, max_bytes: u64) -> AppResult<Rem
content_bytes: bytes,
size: metadata.len(),
mtime: modified_time_secs(&metadata),
mtime_nanos: modified_time_nanos(&metadata),
})
}
@@ -321,6 +326,8 @@ pub async fn write_local_file_text(
content: String,
expected_mtime: Option<u64>,
expected_size: Option<u64>,
expected_mtime_nanos: Option<String>,
expected_hash: Option<String>,
force: Option<bool>,
) -> AppResult<WriteRemoteTextResult> {
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<u64>,
expected_size: Option<u64>,
expected_mtime_nanos: Option<&str>,
expected_hash: Option<&str>,
force: bool,
) -> AppResult<WriteRemoteTextResult> {
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(&current_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<String> {
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
+2
View File
@@ -183,6 +183,7 @@ pub async fn write_remote_file_text(
content: String,
expected_mtime: Option<u64>,
expected_size: Option<u64>,
expected_hash: Option<String>,
force: Option<bool>,
) -> AppResult<sftp::WriteRemoteTextResult> {
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
+46 -1
View File
@@ -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<Mutex<Vec<TerminalOutputPayload>>>,
@@ -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::<TerminalOutputPayload>::new()));
let first_emit_at = Arc::new(Mutex::new(None::<Duration>));
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::<String>();
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();
+10 -2
View File
@@ -2230,13 +2230,21 @@ pub async fn write_remote_file_text(
content: &str,
expected_mtime: Option<u64>,
expected_size: Option<u64>,
expected_hash: Option<&str>,
force: bool,
) -> AppResult<WriteRemoteTextResult> {
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(
+30 -3
View File
@@ -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<u64>,
expected_size: Option<u64>,
expected_hash: Option<&str>,
force: bool,
) -> AppResult<WriteRemoteTextResult> {
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(&current_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(
+40 -2
View File
@@ -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<u64>,
expected_size: Option<u64>,
expected_hash: Option<&str>,
force: bool,
) -> AppResult<WriteRemoteTextResult> {
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(
+34 -1
View File
@@ -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<u64>,
expected_size: Option<u64>,
expected_hash: Option<&str>,
force: bool,
) -> AppResult<WriteRemoteTextResult> {
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(&current_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()),
))
}
+1
View File
@@ -59,6 +59,7 @@ pub(crate) trait RemoteFs: Send + Sync {
content: &str,
expected_mtime: Option<u64>,
expected_size: Option<u64>,
expected_hash: Option<&str>,
force: bool,
) -> AppResult<WriteRemoteTextResult>;
+32 -6
View File
@@ -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<String>,
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<u8>,
pub size: u64,
pub mtime: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub mtime_nanos: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
@@ -152,26 +161,40 @@ pub struct WriteRemoteTextResult {
pub status: String,
pub mtime: Option<u64>,
pub size: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mtime_nanos: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub content_hash: Option<String>,
}
impl WriteRemoteTextResult {
pub fn saved(mtime: u64, size: u64) -> Self {
pub fn saved(mtime: u64, size: u64, mtime_nanos: Option<String>, 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<String>) -> 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")
));
}
+4
View File
@@ -10,6 +10,10 @@
"visible": false,
"decorations": true,
"titleBarStyle": "Overlay",
"trafficLightPosition": {
"x": 12,
"y": 18
},
"hiddenTitle": true,
"create": false
}
+17 -14
View File
@@ -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<string, React.ComponentType> = {
"note-editor": NoteEditorPage,
};
function ChildWindowLoadingShell() {
return (
<div
className="flex h-screen w-full items-center justify-center bg-background"
aria-busy="true"
style={{ backgroundColor: "var(--df-bg, #0d1117)" }}
>
<span className="size-5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
);
}
function ReadyContent({ children }: { children: ReactNode }) {
useEffect(() => {
const timeoutId = window.setTimeout(() => {
void signalChildWindowReady();
}, 0);
return () => window.clearTimeout(timeoutId);
}, []);
return children;
return <div className="relative h-screen w-full bg-background">{children}</div>;
}
export default function ChildWindowRouter({ windowType }: { windowType: string }) {
@@ -112,10 +115,10 @@ export default function ChildWindowRouter({ windowType }: { windowType: string }
}
return (
<Suspense fallback={null}>
<ReadyContent>
<ReadyContent>
<Suspense fallback={<ChildWindowLoadingShell />}>
<Page />
</ReadyContent>
</Suspense>
</Suspense>
</ReadyContent>
);
}
+1 -1
View File
@@ -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" },
},
};
@@ -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(
<FolderDialog
open={open}
isEditing={false}
name="folder"
onNameChange={vi.fn()}
onSubmit={onSubmit}
onCancel={vi.fn()}
/>,
);
}
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);
});
});
@@ -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 (
<Dialog disablePointerDismissal open={open} onOpenChange={(v) => !v && onCancel()}>
<Dialog
disablePointerDismissal
open={open}
onOpenChange={(v) => !v && !submitInFlightRef.current && onCancel()}
>
<DialogContent showCloseButton={false} className="max-w-xs">
<DialogHeader>
<DialogTitle className="text-sm">
@@ -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
/>
</div>
<DialogFooter>
<Button variant="outline" size="sm" onClick={onCancel}>
<Button variant="outline" size="sm" onClick={onCancel} disabled={isSubmitting}>
{t("dialog.cancel")}
</Button>
<Button size="sm" onClick={onSubmit} disabled={!name.trim()}>
<Button size="sm" onClick={handleSubmit} disabled={isSubmitting || !name.trim()}>
{t("dialog.save")}
</Button>
</DialogFooter>
+15 -7
View File
@@ -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 (
<header
className="h-10 border-b flex items-center shrink-0 select-none"
style={{ backgroundColor: "var(--df-bg-panel)", borderColor: "var(--df-border)" }}
>
<div
className={`flex-1 min-w-0 h-full flex items-center gap-2 px-3${isMacOS ? " pl-[70px]" : ""}`}
data-tauri-drag-region
>
{icon ? <span className="text-primary pointer-events-none shrink-0">{icon}</span> : null}
<span className="text-sm font-medium truncate pointer-events-none">{title}</span>
</div>
{hideHeaderContent ? (
<div className="h-full min-w-0 flex-1" data-tauri-drag-region />
) : (
<div
className={`flex-1 min-w-0 h-full flex items-center gap-2 px-3${isMacOS ? " pl-[84px]" : ""}`}
data-tauri-drag-region
>
{icon ? <span className="text-primary pointer-events-none shrink-0">{icon}</span> : null}
<span className="text-sm font-medium truncate pointer-events-none">{title}</span>
</div>
)}
{!isMacOS && (
<div className="flex h-full shrink-0 items-center">
+1 -1
View File
@@ -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)" }}
>
<div className={`flex items-center gap-2 shrink-0${isMacOS ? " pl-[70px]" : ""}`}>
<div className={`flex items-center gap-2 shrink-0${isMacOS ? " pl-[84px]" : ""}`}>
{!isMacOS && (
<NyaTermLogo className="h-5 w-5 shrink-0" onDoubleClick={handleToggleMaximizeWindow} />
)}
@@ -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(<FileDocumentEditor pane={pane()} active />);
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,
});
});
});
@@ -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<EditorView | null>(null);
const suppressUpdateRef = useRef(false);
const savingRef = useRef(false);
const savePromiseRef = useRef<Promise<FileDocumentSaveResult> | 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<FileDocumentSaveResult> => {
if (savingRef.current) return "conflict";
savingRef.current = true;
setSaving(true);
setError("");
try {
const result = await invoke<WriteFileTextResult>(
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<FileDocumentSaveResult> => {
savingRef.current = true;
setSaving(true);
setError("");
try {
const result = await invoke<WriteFileTextResult>(
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);
@@ -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) {
@@ -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(
@@ -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 = {
+117 -49
View File
@@ -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<Dec2026FrameGate | null>(null);
const lineTimestampsRef = useRef<Map<number, number>>(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<PerformanceMode>("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<void>((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 &&
@@ -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);
});
});
@@ -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,
};
}
}
@@ -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<number, { at: number; callback: () => 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}`);
});
});
+707
View File
@@ -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<string, unknown>) => 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?.();
}
}
@@ -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<number, number>();
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);
});
});
@@ -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,
+21 -1
View File
@@ -103,6 +103,7 @@ export class TerminalOutputDrain<TWriteContext = unknown> {
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<TWriteContext>) {
@@ -223,6 +224,7 @@ export class TerminalOutputDrain<TWriteContext = unknown> {
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<TWriteContext = unknown> {
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<TWriteContext = unknown> {
this.foregroundFrame = this.timers.requestAnimationFrame(() => {
this.foregroundFrame = null;
this.foregroundTurnStartedAt = null;
this.flushForeground();
});
}
@@ -286,6 +290,7 @@ export class TerminalOutputDrain<TWriteContext = unknown> {
this.schedule();
return;
}
this.beginForegroundTurn();
this.flushOne(this.options.getWriteChunkBytes());
}
@@ -402,6 +407,7 @@ export class TerminalOutputDrain<TWriteContext = unknown> {
this.foregroundTimer = null;
}
this.microtaskPending = false;
this.foregroundTurnStartedAt = null;
}
private cancelBackground() {
@@ -421,4 +427,18 @@ export class TerminalOutputDrain<TWriteContext = unknown> {
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
);
}
}
@@ -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);
});
});
@@ -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
);
}
}
@@ -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,
});
});
});
@@ -8,7 +8,15 @@ export interface QueuedOutputChunk {
}
export interface OutputQueue {
chunks: QueuedOutputChunk[];
chunks: Array<QueuedOutputChunk | undefined>;
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[] = [];
@@ -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<ZmodemEventPayload, { type: "progress" }> | 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`,
+13
View File
@@ -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<typeof ContextMenuPrimitive.Root>) {
@@ -74,8 +75,19 @@ function ContextMenuSubContent({
function ContextMenuContent({
className,
onPointerUpCapture,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
const handlePointerUpCapture = (event: React.PointerEvent<HTMLDivElement>) => {
// 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 (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
@@ -85,6 +97,7 @@ function ContextMenuContent({
className,
)}
{...props}
onPointerUpCapture={handlePointerUpCapture}
/>
</ContextMenuPrimitive.Portal>
);
+15 -1564
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+9 -11
View File
@@ -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 (
<AppContext.Provider value={contextValue}>
{!appStateReady ? (
<div
className="flex h-screen w-full items-center justify-center bg-background"
aria-busy="true"
style={{ backgroundColor: "var(--df-bg, #0d1117)" }}
>
<span className="size-5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
) : null}
{showContent ? children : null}
{appStateReady && isLocked ? (
<LockScreen
+85
View File
@@ -0,0 +1,85 @@
import { render, waitFor } from "@testing-library/react";
import { StrictMode } from "react";
import { beforeEach, expect, it, vi } from "vitest";
import { CHILD_WINDOW_COMMANDS } from "@/lib/childWindowProtocol";
import { useChildWindowCommand } from "./useChildWindowCommand";
const mocks = vi.hoisted(() => ({
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<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((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(
<StrictMode>
<Probe />
</StrictMode>,
);
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(<Probe handler={firstHandler} />);
listener.resolve(vi.fn());
await waitFor(() => expect(mocks.signalReady).toHaveBeenCalledOnce());
view.rerender(<Probe handler={secondHandler} />);
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(<Probe />);
await waitFor(() => expect(mocks.signalFailed).toHaveBeenCalledWith("command-listener"));
expect(mocks.signalReady).not.toHaveBeenCalled();
});
+42
View File
@@ -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<T>(
event: ChildWindowCommandName,
handler: (payload: T) => void,
) {
const handlerRef = useRef(handler);
handlerRef.current = handler;
useEffect(() => {
let active = true;
let dispose: (() => void) | undefined;
void listen<T>(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]);
}
+21 -4
View File
@@ -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<Terminal | null>,
fitSchedulerRef: RefObject<TerminalFitScheduler | null>,
@@ -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();
+119
View File
@@ -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" } },
]);
});
});
+88
View File
@@ -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<string, ChildWindowCommandState>();
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);
}
}
+55
View File
@@ -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",
]);
});
+134
View File
@@ -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<void> | 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;
}
+29
View File
@@ -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;
};
+1
View File
@@ -0,0 +1 @@
export const MAX_EDITOR_FILE_BYTES = 5 * 1024 * 1024;
+1 -1
View File
@@ -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,
);
+251 -11
View File
@@ -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<void>;
hide: () => Promise<void>;
isVisible: () => Promise<boolean>;
once: (event: string, handler: () => void) => Promise<void>;
outerPosition: () => Promise<{ x: number; y: number }>;
outerSize: () => Promise<{ width: number; height: number }>;
requestUserAttention: () => Promise<void>;
setAlwaysOnTop: () => Promise<void>;
setEnabled: () => Promise<void>;
setFocus: () => Promise<void>;
setFocusable: () => Promise<void>;
setPosition: () => Promise<void>;
setTitle: () => Promise<void>;
show: () => Promise<void>;
};
const listeners = new Map<string, (event: { payload: unknown }) => void>();
const windows = new Map<string, MockWindow>();
const currentWindow = createMockWindow("main", windows);
function createMockWindow(label: string, registry: Map<string, MockWindow>): 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);
});
});
+302 -82
View File
@@ -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<string>();
const registeredDestroyedHandlers = new Map<string, string>();
const pendingChildWindowOpens = new Map<string, PendingChildWindowOpen>();
const childWindowCommands = new ChildWindowCommandQueue();
const childWindowTokens = new Map<string, string>();
const childWindowShellWaiters = new Map<string, ChildWindowLifecycleWaiter>();
const failedChildWindowClosures = new Map<string, string>();
let childWindowLifecycleListenerPromise: Promise<void> | 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<void>;
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<ChildWindowReadyWaiter> {
function emitChildWindowCommands(commands: ReturnType<ChildWindowCommandQueue["dispatch"]>) {
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<ChildWindowLifecyclePayload>(
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<ChildWindowLifecycleWaiter> {
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<void>((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<ChildWindowReadyPayload>(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<string, unknown>) => {
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<WebviewWindow
}
export async function openSettings(tab?: string) {
const label = scopedModalLabel("settings");
const url = tab
? `index.html?window=settings&owner=${encodeURIComponent(ownerMainWindowLabel)}&tab=${encodeURIComponent(tab)}`
: `index.html?window=settings&owner=${encodeURIComponent(ownerMainWindowLabel)}`;
const win = await openChildWindow({
label: scopedModalLabel("settings"),
label,
title: i18n.t("settings.title"),
url,
parentLabel: ownerMainWindowLabel,
@@ -575,12 +813,7 @@ export async function openSettings(tab?: string) {
});
if (tab) {
const payload = { tab, targetWindowLabel: ownerMainWindowLabel };
emit("settings-open-tab", payload);
window.setTimeout(() => {
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;
});
}
+6 -1
View File
@@ -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", () => {
+2 -6
View File
@@ -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. */
+89 -21
View File
@@ -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(
<React.StrictMode>
<ErrorBoundary>
<ChildAppProvider>
<ThemeProvider>
<ChildWindowRouter windowType={windowType} />
<Toaster />
</ThemeProvider>
</ChildAppProvider>
</ErrorBoundary>
</React.StrictMode>,
// 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(
<div
className="flex h-screen w-full items-center justify-center bg-background"
style={{ backgroundColor: "var(--df-bg, #0d1117)" }}
aria-busy="true"
>
<span className="size-5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>,
);
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(
<React.StrictMode>
<ErrorBoundary>
<ChildAppProvider>
<ThemeProvider>
<ChildWindowRouter windowType={windowType} />
<Toaster />
</ThemeProvider>
</ChildAppProvider>
</ErrorBoundary>
</React.StrictMode>,
);
} 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(
<div
className="flex h-screen w-full items-center justify-center bg-background p-8"
style={{ backgroundColor: "var(--df-bg, #0d1117)" }}
role="alert"
>
<div className="max-w-md text-center">
<p className="text-destructive">{errorTitle}</p>
<button
type="button"
className="mt-6 rounded-md bg-primary px-4 py-2 text-primary-foreground"
onClick={() => window.location.reload()}
>
{reloadLabel}
</button>
</div>
</div>,
);
}
} 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(
+13 -25
View File
@@ -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<FilePreviewOpenPayload>("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<FilePreviewOpenPayload>(
CHILD_WINDOW_COMMANDS.filePreviewOpen,
(payload) => {
const currentWindow = getCurrentWindow();
if (payload.targetLabel && payload.targetLabel !== currentWindow.label) return;
addOrFocusTab(payload.data);
},
);
useEffect(() => {
const currentWindow = getCurrentWindow();
+23 -20
View File
@@ -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<RemoteFileEditorData, "path" | "remotePath">) {
@@ -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<RemoteFileEditorOpenPayload>("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<RemoteFileEditorOpenPayload>(
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,
+10 -14
View File
@@ -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() {
<ChildWindowHeader
title={t("settings.title")}
icon={<MdSettings className="text-base" />}
macOSDragOnly
onClose={requestClose}
/>
+2
View File
@@ -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. */