mirror of
https://github.com/nyakang/nyaterm.git
synced 2026-09-22 16:01:31 +00:00
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:
@@ -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"] }
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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(¤t_bytes) != expected_hash
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
if (has_conflict || has_hash_conflict) && !force {
|
||||
return Ok(WriteRemoteTextResult::conflict(
|
||||
current_mtime,
|
||||
current_size,
|
||||
current_mtime_nanos,
|
||||
));
|
||||
}
|
||||
|
||||
tokio::fs::write(&path, content).await?;
|
||||
@@ -356,6 +385,8 @@ async fn write_local_file_text_impl(
|
||||
Ok(WriteRemoteTextResult::saved(
|
||||
modified_time_secs(&next_metadata),
|
||||
next_metadata.len(),
|
||||
modified_time_nanos(&next_metadata),
|
||||
content_hash(content.as_bytes()),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -433,6 +464,14 @@ fn modified_time_secs(metadata: &std::fs::Metadata) -> u64 {
|
||||
system_time_secs(metadata.modified())
|
||||
}
|
||||
|
||||
fn modified_time_nanos(metadata: &std::fs::Metadata) -> Option<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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(¤t_bytes) != expected_hash {
|
||||
return Ok(WriteRemoteTextResult::conflict(
|
||||
props.mtime,
|
||||
props.size,
|
||||
None,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let tmp = format!(
|
||||
@@ -947,7 +969,12 @@ impl RemoteFs for ScpEnhancedBackend {
|
||||
)));
|
||||
}
|
||||
let props = self.stat(path).await?;
|
||||
Ok(WriteRemoteTextResult::saved(props.mtime, props.size))
|
||||
Ok(WriteRemoteTextResult::saved(
|
||||
props.mtime,
|
||||
props.size,
|
||||
None,
|
||||
content_hash(content.as_bytes()),
|
||||
))
|
||||
}
|
||||
|
||||
async fn download_file(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(¤t_bytes) != expected_hash {
|
||||
let _ = sftp.close().await;
|
||||
return Ok(WriteRemoteTextResult::conflict(
|
||||
current_mtime,
|
||||
current_size,
|
||||
None,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
let original_permissions = original_attrs
|
||||
@@ -575,6 +606,8 @@ impl RemoteFs for SftpBackend {
|
||||
Ok(WriteRemoteTextResult::saved(
|
||||
u64::from(attrs.mtime.unwrap_or(0)),
|
||||
attrs.size.unwrap_or(content.len() as u64),
|
||||
None,
|
||||
content_hash(content.as_bytes()),
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -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")
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,10 @@
|
||||
"visible": false,
|
||||
"decorations": true,
|
||||
"titleBarStyle": "Overlay",
|
||||
"trafficLightPosition": {
|
||||
"x": 12,
|
||||
"y": 18
|
||||
},
|
||||
"hiddenTitle": true,
|
||||
"create": false
|
||||
}
|
||||
|
||||
+17
-14
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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}`);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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" } },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export const MAX_EDITOR_FILE_BYTES = 5 * 1024 * 1024;
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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}
|
||||
/>
|
||||
|
||||
|
||||
Vendored
+2
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user