From 242474311f02545695d35781687f3630d9197b20 Mon Sep 17 00:00:00 2001 From: Gajendra sahu Date: Mon, 7 Sep 2026 03:04:20 +0530 Subject: [PATCH 01/17] perf(terminal): idle-immediate PTY flush and bounded sniffers --- .github/workflows/ci.yml | 2 + CHANGELOG.md | 4 + docs/TERMINAL.md | 13 +- src-tauri/src/commands.rs | 7 + src-tauri/src/lib.rs | 2 + src-tauri/src/pty.rs | 130 ++++--- src-tauri/src/pty_output_flush.rs | 335 ++++++++++++++++++ src/lib/ghostSuggestions/promptCwdSniffer.ts | 5 +- src/lib/ghostSuggestions/secretInputDetect.ts | 5 +- src/lib/tauri-ipc.ts | 1 + src/lib/terminal/index.ts | 9 +- src/lib/terminal/instanceApi.ts | 2 + src/lib/terminal/terminalIoDebug.ts | 169 +++++++++ src/lib/terminal/terminalOutputFrame.ts | 27 ++ src/lib/terminal/terminalOutputStream.ts | 55 ++- src/lib/terminal/terminalReloadTeardown.ts | 2 + src/lib/terminal/terminalSnifferBytes.ts | 8 + tests/ghostSuggestionsHelpers.test.mjs | 41 +++ tests/runAllAgentTests.mjs | 1 + tests/terminalOutputStream.test.mjs | 32 +- 20 files changed, 752 insertions(+), 98 deletions(-) create mode 100644 src-tauri/src/pty_output_flush.rs create mode 100644 src/lib/terminal/terminalIoDebug.ts create mode 100644 src/lib/terminal/terminalOutputFrame.ts create mode 100644 src/lib/terminal/terminalSnifferBytes.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 56e00e10..8a1bbe77 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -74,6 +74,8 @@ jobs: cargo test local_only cargo test commands::private_key_inspection_tests cargo test ssh::tests -- --test-threads=1 + cargo test pty_output_flush + cargo test pty::tests - name: Run ignored SSH security tests on Linux if: matrix.platform == 'ubuntu-22.04' diff --git a/CHANGELOG.md b/CHANGELOG.md index 53f4aefd..8863e2a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to Zync are documented in this file. The format is based on ## [Unreleased] +### Changed +- **Terminal output flush:** First PTY bytes after quiet go to the terminal immediately (typing echo no longer waits up to 8 ms). Busy output merges for a 12 ms burst, or sooner at 128 KiB. The live Channel frame is still generation + raw bytes. Debug: `localStorage.zyncTerminalIoDebug = '1'`. +- **Terminal sniffers:** Secret/cwd helpers scan at most the last 4 KiB of large PTY frames so `cat` does not regex the whole dump. The terminal still receives every byte. + ### Added - **Smooth splits**: New panes grow in (about 280ms) instead of jumping to 50/50 — keyboard split, split icons, drag-to-dock, and Open in split / Open here. A quiet accent veil marks the incoming pane. Drag-to-split preview eases between edges. Divider drag stays immediate (no laggy flex transition). `prefers-reduced-motion` skips the intro. PTY resize waits until the intro ends, same as a divider drag. ([2d3b7c8]) - **Scroll to resize a split**: Hover the seam and use the mouse wheel or trackpad to move it. Drag and arrow keys still work. PTY resize waits until scrolling settles, same as a divider drag. Ctrl/Cmd+wheel is left for zoom. ([2d3b7c8]) diff --git a/docs/TERMINAL.md b/docs/TERMINAL.md index 6c73ddd2..2706fc87 100644 --- a/docs/TERMINAL.md +++ b/docs/TERMINAL.md @@ -211,11 +211,12 @@ Public surface exported from `index.ts`. Key modules: | File | Responsibility | |------|------| -| `src-tauri/src/pty.rs` | PtyManager: local spawn/read/write/resize/close; remote SSH reader; output batching (8ms / 4KB); explicit `child.kill()` on close | -| `src-tauri/src/commands.rs` | `terminal_create` (accepts output `Channel`), `terminal_write`, `terminal_resize`, `terminal_has_active_processes`, close variants | +| `src-tauri/src/pty.rs` | PtyManager: local spawn/read/write/resize/close; remote SSH reader; output flush via `pty_output_flush`; explicit `child.kill()` on close | +| `src-tauri/src/pty_output_flush.rs` | Idle-immediate + fixed 12 ms burst epoch + 128 KiB flush **threshold** (not max frame size). Process-wide flush-reason counters. | +| `src-tauri/src/commands.rs` | `terminal_create` (accepts output `Channel`), `terminal_write`, `terminal_resize`, `terminal_has_active_processes`, `terminal_flush_stats` (process-wide flush-reason snapshot; not on the output Channel), close variants | | `src-tauri/src/ghost/*` | Ghost suggestion persistence, parser, ranking, Tauri commands | -**Output batching (remote & local):** `REMOTE_OUTPUT_BATCH_MS` / `OUTPUT_FLUSH_THRESHOLD` coalesce before sending to frontend channel. +**Output flush (remote & local):** `OUTPUT_BURST_EPOCH_MS` (12 ms **fixed epoch**, not a debounce) and `OUTPUT_BURST_FLUSH_THRESHOLD` (128 KiB — flush at least this soon while bursting, not a max frame size). First bytes after idle send immediately; later packets in the epoch accumulate; close/EOF/`output_rx` None always drain the tail. Frame layout is unchanged: `u32` LE generation + raw bytes. **Resize (remote):** SSH resize channel drains to latest cols/rows (trailing coalesce). @@ -230,6 +231,7 @@ Public surface exported from `index.ts`. Key modules: - `terminal:resize` — cols/rows from unified resize scheduler - `terminal:close` / `terminal:close_by_connection` — programmatic teardown (**no** `terminal-exit`) - `terminal_has_active_processes` — local sysinfo child-tree probe +- `terminal:flush-stats` / `terminal_flush_stats` — process-wide flush-reason counters (`idle_first` / `timer` / `threshold` / `close`). Debug only. Not on the PTY output Channel. ### Events (listen) @@ -310,7 +312,9 @@ xterm.onData [ u32 generation (LE) ][ raw PTY bytes... ] ``` -Decoded in `terminalOutputStream.ts` → `term.write()` after generation check. +Decoded in `terminalOutputStream.ts` → generation check → sniffers → `term.write()` of the **full** frame. + +Sniffers (secret prompt / prompt cwd) are reading aids. Frames ≤ 8 KiB are scanned in full. Larger frames scan only the last 4 KiB (`selectSnifferBytes`). A prompt is detected iff it appears in a ≤8 KiB frame or in that tail. `term.write` is never sliced (byte-exact xterm). OSC 7 cwd is parsed inside xterm, not these sniffers. **Legacy:** `terminal-output-*` events removed. `terminalOutputPayload.ts` retains base64/array decode for older dev builds. @@ -536,6 +540,7 @@ src/index.css # .terminal-container, xterm 6 viewport overrides ``` src-tauri/src/pty.rs +src-tauri/src/pty_output_flush.rs src-tauri/src/commands.rs src-tauri/src/ghost/ ``` diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index c016b558..143ecb9c 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -3058,6 +3058,13 @@ pub async fn terminal_has_active_processes( Ok(state.pty_manager.has_active_child_processes(&term_id).await) } +/// Process-wide PTY flush-reason totals. Not per-session. Does not change the +/// output Channel frame layout. +#[tauri::command] +pub fn terminal_flush_stats() -> crate::pty_output_flush::FlushReasonCounts { + crate::pty_output_flush::flush_reason_snapshot() +} + // Helper to get SFTP session - reconnects automatically if session is dead. // Zero overhead for healthy connections; only re-establishes when needed. async fn get_sftp_or_reconnect( diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d57dabaf..63f46438 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -7,6 +7,7 @@ mod ghost; mod identity_migration; pub mod plugins; mod pty; +mod pty_output_flush; mod session; mod shell_icons; mod snippets; @@ -154,6 +155,7 @@ pub fn run() { commands::terminal_create, commands::terminal_close, commands::terminal_has_active_processes, + commands::terminal_flush_stats, commands::connections_get, commands::connections_save, commands::connections_export_to_file, diff --git a/src-tauri/src/pty.rs b/src-tauri/src/pty.rs index 7ce6c62a..b6985ab4 100644 --- a/src-tauri/src/pty.rs +++ b/src-tauri/src/pty.rs @@ -5,19 +5,17 @@ use russh::{Channel, ChannelMsg}; use serde::Serialize; use std::collections::HashMap; use std::io::{Read, Write}; -use std::mem; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc as std_mpsc; use std::sync::Arc; use tauri::ipc::{Channel as IpcChannel, InvokeResponseBody}; use tauri::{AppHandle, Emitter}; use tokio::sync::{mpsc, Mutex}; -use tokio::time::{Duration, Instant}; +use tokio::time::Instant; -/// Maximum time to hold PTY output before emitting a combined frontend event. -const OUTPUT_BATCH_MS: u64 = 8; -/// Flush buffered PTY output immediately once it reaches this many bytes. -const OUTPUT_FLUSH_THRESHOLD: usize = 4096; +use crate::pty_output_flush::{ + encode_output_frame, record_flush_reason, FlushInstruction, FlushReason, OutputFlushPolicy, +}; enum LocalReaderEvent { Data(Vec), @@ -243,29 +241,45 @@ struct TerminalLifecycleEvent { exit_code: Option, } -/// Flushes buffered PTY output through the streaming IPC channel. +/// Flushes PTY bytes through the streaming IPC channel. /// /// Frames are `generation` (u32 LE) + raw PTY bytes so the frontend can ignore -/// stale chunks after suspend/restart races. -fn flush_pending_output( - output_channel: &IpcChannel, - generation: u32, - pending_output: &mut Vec, -) { - if pending_output.is_empty() { +/// stale chunks after suspend/restart races. Layout must not change. +fn flush_output_frame(output_channel: &IpcChannel, generation: u32, bytes: Vec) { + if bytes.is_empty() { return; } - let output = mem::take(pending_output); - let mut frame = Vec::with_capacity(4 + output.len()); - frame.extend_from_slice(&generation.to_le_bytes()); - frame.extend_from_slice(&output); - + let frame = encode_output_frame(generation, &bytes); if let Err(e) = output_channel.send(InvokeResponseBody::Raw(frame)) { eprintln!("[PTY] Failed to send output on channel: {}", e); } } +fn apply_flush_instruction( + output_channel: &IpcChannel, + generation: u32, + instruction: FlushInstruction, +) { + if let FlushInstruction::Flush { + bytes, + reason, + rearm_burst: _, + } = instruction { + record_flush_reason(reason); + flush_output_frame(output_channel, generation, bytes); + } +} + +fn flush_policy_tail(output_channel: &IpcChannel, generation: u32, policy: &mut OutputFlushPolicy) { + let tail = policy.take_tail_on_close(); + if tail.is_empty() { + return; + } + record_flush_reason(FlushReason::Close); + flush_output_frame(output_channel, generation, tail); +} + fn process_tree_has_children(root_pid: u32) -> bool { use sysinfo::{Pid, ProcessesToUpdate, System}; let mut system = System::new(); @@ -324,20 +338,17 @@ fn remote_wait_action(msg: Option<&ChannelMsg>) -> RemoteWaitAction { fn buffer_remote_wait_output( msg: &ChannelMsg, - pending_output: &mut Vec, - flush_deadline: &mut Option, + policy: &mut OutputFlushPolicy, output_channel: &IpcChannel, generation: u32, ) { match msg { ChannelMsg::Data { data } | ChannelMsg::ExtendedData { data, .. } => { - pending_output.extend_from_slice(data.as_ref()); - if pending_output.len() >= OUTPUT_FLUSH_THRESHOLD { - flush_pending_output(output_channel, generation, pending_output); - *flush_deadline = None; - } else if flush_deadline.is_none() { - *flush_deadline = Some(Instant::now() + Duration::from_millis(OUTPUT_BATCH_MS)); - } + apply_flush_instruction( + output_channel, + generation, + policy.on_bytes(data.as_ref(), Instant::now()), + ); } _ => {} } @@ -673,25 +684,22 @@ impl PtyManager { let term_id_for_exit = term_id.clone(); let reader_handle = tokio::spawn(async move { - let mut pending_output = Vec::new(); - let mut flush_deadline: Option = None; + let mut policy = OutputFlushPolicy::new(); loop { + let deadline = policy.deadline(); tokio::select! { event = output_rx.recv() => { match event { Some(LocalReaderEvent::Data(chunk)) => { - pending_output.extend_from_slice(&chunk); - - if pending_output.len() >= OUTPUT_FLUSH_THRESHOLD { - flush_pending_output(&output_channel_clone, generation, &mut pending_output); - flush_deadline = None; - } else if flush_deadline.is_none() { - flush_deadline = Some(Instant::now() + Duration::from_millis(OUTPUT_BATCH_MS)); - } + apply_flush_instruction( + &output_channel_clone, + generation, + policy.on_bytes(&chunk, Instant::now()), + ); } Some(LocalReaderEvent::Finished { exit_code }) => { - flush_pending_output(&output_channel_clone, generation, &mut pending_output); + flush_policy_tail(&output_channel_clone, generation, &mut policy); if !exit_emitted_clone.swap(true, Ordering::SeqCst) { emit_terminal_exit( &app_handle_clone, @@ -712,17 +720,23 @@ impl PtyManager { } break; } - None => break, + None => { + flush_policy_tail(&output_channel_clone, generation, &mut policy); + break; + } } } _ = async { - if let Some(deadline) = flush_deadline { - tokio::time::sleep_until(deadline).await; + if let Some(d) = deadline { + tokio::time::sleep_until(d).await; } - }, if flush_deadline.is_some() => { - flush_pending_output(&output_channel_clone, generation, &mut pending_output); - flush_deadline = None; + }, if deadline.is_some() => { + apply_flush_instruction( + &output_channel_clone, + generation, + policy.on_timer(Instant::now()), + ); } } } @@ -897,12 +911,12 @@ impl PtyManager { // output/exit events can never arrive before the frontend has seen ready. let task_handle = tokio::task::spawn(async move { let app_handle = app_handle_clone; - let mut pending_output = Vec::new(); - let mut flush_deadline: Option = None; + let mut policy = OutputFlushPolicy::new(); let drop_transport; let exit_code; loop { + let deadline = policy.deadline(); tokio::select! { msg = channel.wait() => { match remote_wait_action(msg.as_ref()) { @@ -910,21 +924,20 @@ impl PtyManager { if let Some(ref msg) = msg { buffer_remote_wait_output( msg, - &mut pending_output, - &mut flush_deadline, + &mut policy, &output_channel_clone, generation, ); } } RemoteWaitAction::PaneExit { exit_code: code } => { - flush_pending_output(&output_channel_clone, generation, &mut pending_output); + flush_policy_tail(&output_channel_clone, generation, &mut policy); drop_transport = false; exit_code = Some(code); break; } RemoteWaitAction::TransportDrop => { - flush_pending_output(&output_channel_clone, generation, &mut pending_output); + flush_policy_tail(&output_channel_clone, generation, &mut policy); exit_code = Some(None); drop_transport = true; break; @@ -935,12 +948,15 @@ impl PtyManager { } _ = async { - if let Some(deadline) = flush_deadline { - tokio::time::sleep_until(deadline).await; + if let Some(d) = deadline { + tokio::time::sleep_until(d).await; } - }, if flush_deadline.is_some() => { - flush_pending_output(&output_channel_clone, generation, &mut pending_output); - flush_deadline = None; + }, if deadline.is_some() => { + apply_flush_instruction( + &output_channel_clone, + generation, + policy.on_timer(Instant::now()), + ); } Some(input) = rx.recv() => { @@ -964,7 +980,7 @@ impl PtyManager { } } - flush_pending_output(&output_channel_clone, generation, &mut pending_output); + flush_policy_tail(&output_channel_clone, generation, &mut policy); let _ = channel.close().await; let mut sessions = sessions_for_exit.lock().await; diff --git a/src-tauri/src/pty_output_flush.rs b/src-tauri/src/pty_output_flush.rs new file mode 100644 index 00000000..3550c4b8 --- /dev/null +++ b/src-tauri/src/pty_output_flush.rs @@ -0,0 +1,335 @@ +//! PTY output flush policy: idle-immediate send, then a fixed burst epoch. +//! +//! Channel I/O stays in `pty.rs`. This module only decides *when* bytes should +//! leave and encodes the generation-prefixed frame. The 12 ms timer is a +//! **fixed epoch** from the idle-first send (or from a threshold re-arm). Later +//! packets in the same epoch do **not** reset it. +//! +//! `OUTPUT_BURST_FLUSH_THRESHOLD` is "flush at least this soon while bursting", +//! not a maximum frame size. An idle-first chunk may exceed the threshold. + +use std::sync::atomic::{AtomicU64, Ordering}; +use tokio::time::{Duration, Instant}; + +/// Fixed burst epoch after an idle-first send or a threshold re-arm. +pub const OUTPUT_BURST_EPOCH_MS: u64 = 12; +/// While bursting, flush pending at least this soon. Not a max frame size. +pub const OUTPUT_BURST_FLUSH_THRESHOLD: usize = 128 * 1024; + +static FLUSH_IDLE_FIRST: AtomicU64 = AtomicU64::new(0); +static FLUSH_TIMER: AtomicU64 = AtomicU64::new(0); +static FLUSH_THRESHOLD: AtomicU64 = AtomicU64::new(0); +static FLUSH_CLOSE: AtomicU64 = AtomicU64::new(0); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FlushReason { + IdleFirst, + Timer, + Threshold, + Close, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FlushReasonCounts { + pub idle_first: u64, + pub timer: u64, + pub threshold: u64, + pub close: u64, +} + +pub fn record_flush_reason(reason: FlushReason) { + let counter = match reason { + FlushReason::IdleFirst => &FLUSH_IDLE_FIRST, + FlushReason::Timer => &FLUSH_TIMER, + FlushReason::Threshold => &FLUSH_THRESHOLD, + FlushReason::Close => &FLUSH_CLOSE, + }; + counter.fetch_add(1, Ordering::Relaxed); +} + +pub fn flush_reason_snapshot() -> FlushReasonCounts { + FlushReasonCounts { + idle_first: FLUSH_IDLE_FIRST.load(Ordering::Relaxed), + timer: FLUSH_TIMER.load(Ordering::Relaxed), + threshold: FLUSH_THRESHOLD.load(Ordering::Relaxed), + close: FLUSH_CLOSE.load(Ordering::Relaxed), + } +} + +#[cfg(test)] +pub fn reset_flush_reason_counts() { + FLUSH_IDLE_FIRST.store(0, Ordering::Relaxed); + FLUSH_TIMER.store(0, Ordering::Relaxed); + FLUSH_THRESHOLD.store(0, Ordering::Relaxed); + FLUSH_CLOSE.store(0, Ordering::Relaxed); +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum FlushMode { + Idle, + Bursting, +} + +pub struct OutputFlushPolicy { + pending: Vec, + mode: FlushMode, + burst_deadline: Option, +} + +pub enum FlushInstruction { + None, + /// Caller must send `bytes` on the Channel before the next on_bytes/on_timer. + Flush { + bytes: Vec, + /// True when the policy stayed Bursting (idle-first or threshold). + /// Callers send `bytes` and ignore this; tests assert epoch re-arm. + #[allow(dead_code)] + rearm_burst: bool, + reason: FlushReason, + }, +} + +impl Default for OutputFlushPolicy { + fn default() -> Self { + Self::new() + } +} + +impl OutputFlushPolicy { + pub fn new() -> Self { + Self { + pending: Vec::new(), + mode: FlushMode::Idle, + burst_deadline: None, + } + } + + pub fn deadline(&self) -> Option { + self.burst_deadline + } + + pub fn on_bytes(&mut self, chunk: &[u8], now: Instant) -> FlushInstruction { + if chunk.is_empty() { + return FlushInstruction::None; + } + + match self.mode { + FlushMode::Idle => { + self.mode = FlushMode::Bursting; + self.burst_deadline = Some(now + Duration::from_millis(OUTPUT_BURST_EPOCH_MS)); + FlushInstruction::Flush { + bytes: chunk.to_vec(), + rearm_burst: true, + reason: FlushReason::IdleFirst, + } + } + FlushMode::Bursting => { + self.pending.extend_from_slice(chunk); + if self.pending.len() >= OUTPUT_BURST_FLUSH_THRESHOLD { + self.burst_deadline = Some(now + Duration::from_millis(OUTPUT_BURST_EPOCH_MS)); + FlushInstruction::Flush { + bytes: std::mem::take(&mut self.pending), + rearm_burst: true, + reason: FlushReason::Threshold, + } + } else { + FlushInstruction::None + } + } + } + } + + pub fn on_timer(&mut self, _now: Instant) -> FlushInstruction { + if self.mode != FlushMode::Bursting || self.burst_deadline.is_none() { + return FlushInstruction::None; + } + + if self.pending.is_empty() { + self.mode = FlushMode::Idle; + self.burst_deadline = None; + return FlushInstruction::None; + } + + self.mode = FlushMode::Idle; + self.burst_deadline = None; + FlushInstruction::Flush { + bytes: std::mem::take(&mut self.pending), + rearm_burst: false, + reason: FlushReason::Timer, + } + } + + /// EOF / exit / close / mpsc closed: drain, Idle, deadline None. + pub fn take_tail_on_close(&mut self) -> Vec { + self.mode = FlushMode::Idle; + self.burst_deadline = None; + std::mem::take(&mut self.pending) + } +} + +pub fn encode_output_frame(generation: u32, payload: &[u8]) -> Vec { + let mut frame = Vec::with_capacity(4 + payload.len()); + frame.extend_from_slice(&generation.to_le_bytes()); + frame.extend_from_slice(payload); + frame +} + +#[cfg(test)] +mod tests { + use super::*; + + fn t0() -> Instant { + Instant::now() + } + + fn epoch() -> Duration { + Duration::from_millis(OUTPUT_BURST_EPOCH_MS) + } + + fn flush_bytes(instr: FlushInstruction) -> (Vec, bool, FlushReason) { + match instr { + FlushInstruction::Flush { + bytes, + rearm_burst, + reason, + } => (bytes, rearm_burst, reason), + FlushInstruction::None => panic!("expected Flush"), + } + } + + #[test] + fn idle_first_chunk_flushes_immediately() { + let mut p = OutputFlushPolicy::new(); + let now = t0(); + let (bytes, rearm, reason) = flush_bytes(p.on_bytes(b"a", now)); + assert_eq!(bytes, b"a"); + assert!(rearm); + assert_eq!(reason, FlushReason::IdleFirst); + assert_eq!(p.deadline(), Some(now + epoch())); + } + + #[test] + fn burst_merge_then_timer_flushes_tail() { + let mut p = OutputFlushPolicy::new(); + let now = t0(); + let _ = p.on_bytes(b"lead", now); + assert!(matches!(p.on_bytes(b"one", now), FlushInstruction::None)); + assert!(matches!(p.on_bytes(b"two", now), FlushInstruction::None)); + let (bytes, rearm, reason) = flush_bytes(p.on_timer(now + epoch())); + assert_eq!(bytes, b"onetwo"); + assert!(!rearm); + assert_eq!(reason, FlushReason::Timer); + assert!(p.deadline().is_none()); + } + + #[test] + fn epoch_does_not_reset_on_append() { + let mut p = OutputFlushPolicy::new(); + let now = t0(); + let _ = p.on_bytes(b"lead", now); + let deadline = p.deadline().expect("epoch armed"); + assert!(matches!( + p.on_bytes(b"x", now + Duration::from_millis(3)), + FlushInstruction::None + )); + assert!(matches!( + p.on_bytes(b"y", now + Duration::from_millis(6)), + FlushInstruction::None + )); + assert_eq!(p.deadline(), Some(deadline), "append must not move the epoch"); + let (bytes, rearm, _) = flush_bytes(p.on_timer(deadline)); + assert_eq!(bytes, b"xy"); + assert!(!rearm); + } + + #[test] + fn threshold_flush_stays_bursting_and_rearms_epoch() { + let mut p = OutputFlushPolicy::new(); + let now = t0(); + let _ = p.on_bytes(b"lead", now); + let first_deadline = p.deadline(); + let chunk = vec![b'z'; OUTPUT_BURST_FLUSH_THRESHOLD]; + let later = now + Duration::from_millis(1); + let (bytes, rearm, reason) = flush_bytes(p.on_bytes(&chunk, later)); + assert!(bytes.len() >= OUTPUT_BURST_FLUSH_THRESHOLD); + assert!(rearm); + assert_eq!(reason, FlushReason::Threshold); + let new_deadline = p.deadline().expect("re-armed"); + assert_eq!(new_deadline, later + epoch()); + assert_ne!(Some(new_deadline), first_deadline); + } + + #[test] + fn empty_epoch_returns_to_idle() { + let mut p = OutputFlushPolicy::new(); + let now = t0(); + let _ = p.on_bytes(b"lead", now); + assert!(matches!(p.on_timer(now + epoch()), FlushInstruction::None)); + assert!(p.deadline().is_none()); + let (bytes, rearm, reason) = flush_bytes(p.on_bytes(b"x", now + Duration::from_secs(1))); + assert_eq!(bytes, b"x"); + assert!(rearm); + assert_eq!(reason, FlushReason::IdleFirst); + } + + #[test] + fn take_tail_on_close_drains_pending() { + let mut p = OutputFlushPolicy::new(); + let now = t0(); + let _ = p.on_bytes(b"lead", now); + let _ = p.on_bytes(b"tail", now); + let tail = p.take_tail_on_close(); + assert_eq!(tail, b"tail"); + assert!(p.deadline().is_none()); + assert!(p.take_tail_on_close().is_empty()); + } + + #[test] + fn empty_on_bytes_is_none() { + let mut p = OutputFlushPolicy::new(); + assert!(matches!(p.on_bytes(b"", t0()), FlushInstruction::None)); + assert!(p.deadline().is_none()); + } + + #[test] + fn on_timer_in_idle_is_none() { + let mut p = OutputFlushPolicy::new(); + assert!(matches!(p.on_timer(t0()), FlushInstruction::None)); + } + + #[test] + fn encode_output_frame_is_u32_le_then_payload() { + let frame = encode_output_frame(0x0102_0304, b"AB"); + assert_eq!(frame, [0x04, 0x03, 0x02, 0x01, b'A', b'B']); + assert_eq!(frame.len(), 4 + 2); + } + + #[test] + fn idle_first_chunk_larger_than_threshold_is_one_frame() { + let mut p = OutputFlushPolicy::new(); + let now = t0(); + let chunk = vec![b'q'; OUTPUT_BURST_FLUSH_THRESHOLD + 8]; + let (bytes, rearm, reason) = flush_bytes(p.on_bytes(&chunk, now)); + assert_eq!(bytes.len(), OUTPUT_BURST_FLUSH_THRESHOLD + 8); + assert!(rearm); + assert_eq!(reason, FlushReason::IdleFirst); + } + + #[test] + fn flush_reason_atomics_count_each_kind() { + reset_flush_reason_counts(); + record_flush_reason(FlushReason::IdleFirst); + record_flush_reason(FlushReason::Timer); + record_flush_reason(FlushReason::Threshold); + record_flush_reason(FlushReason::Close); + record_flush_reason(FlushReason::Close); + let snap = flush_reason_snapshot(); + assert_eq!(snap.idle_first, 1); + assert_eq!(snap.timer, 1); + assert_eq!(snap.threshold, 1); + assert_eq!(snap.close, 2); + reset_flush_reason_counts(); + assert_eq!(flush_reason_snapshot(), FlushReasonCounts::default()); + } +} diff --git a/src/lib/ghostSuggestions/promptCwdSniffer.ts b/src/lib/ghostSuggestions/promptCwdSniffer.ts index ea2c4c2f..a916c2e2 100644 --- a/src/lib/ghostSuggestions/promptCwdSniffer.ts +++ b/src/lib/ghostSuggestions/promptCwdSniffer.ts @@ -73,16 +73,19 @@ export function extractCwdFromPromptOutput(text: string): string | null { const sniffBuffers = new Map(); const sniffDecoders = new Map(); +export type CwdSnifferFeedOptions = { resetDecoder?: boolean }; + /** Feed PTY output bytes; invokes onCwd when a prompt path is recognized. */ export function feedPromptCwdSniffer( termId: string, data: Uint8Array, onCwd: (path: string) => void, + options?: CwdSnifferFeedOptions, ): void { if (!data.length) return; let decoder = sniffDecoders.get(termId); - if (!decoder) { + if (options?.resetDecoder || !decoder) { decoder = new TextDecoder('utf-8', { fatal: false }); sniffDecoders.set(termId, decoder); } diff --git a/src/lib/ghostSuggestions/secretInputDetect.ts b/src/lib/ghostSuggestions/secretInputDetect.ts index b9aa7340..6e233aa3 100644 --- a/src/lib/ghostSuggestions/secretInputDetect.ts +++ b/src/lib/ghostSuggestions/secretInputDetect.ts @@ -38,16 +38,19 @@ export function detectSecretPromptInOutput(text: string): boolean { return SECRET_PROMPT_PATTERNS.some((pattern) => pattern.test(tail)); } +export type SnifferFeedOptions = { resetDecoder?: boolean }; + /** Feed PTY output; invokes onSecretPrompt when a hidden-input prompt is recognized. */ export function feedSecretInputSniffer( termId: string, data: Uint8Array, onSecretPrompt: () => void, + options?: SnifferFeedOptions, ): void { if (!data.length) return; let decoder = sniffDecoders.get(termId); - if (!decoder) { + if (options?.resetDecoder || !decoder) { decoder = new TextDecoder('utf-8', { fatal: false }); sniffDecoders.set(termId, decoder); } diff --git a/src/lib/tauri-ipc.ts b/src/lib/tauri-ipc.ts index 88b6f1d4..ceb9752d 100644 --- a/src/lib/tauri-ipc.ts +++ b/src/lib/tauri-ipc.ts @@ -151,6 +151,7 @@ const ipcRenderer = { 'terminal:create': 'terminal_create', 'terminal:close': 'terminal_close', 'terminal:has-active-processes': 'terminal_has_active_processes', + 'terminal:flush-stats': 'terminal_flush_stats', 'connections:get': 'connections_get', 'connections:save': 'connections_save', 'connections:exportToFile': 'connections_export_to_file', diff --git a/src/lib/terminal/index.ts b/src/lib/terminal/index.ts index f531ef2b..c0b9dfaa 100644 --- a/src/lib/terminal/index.ts +++ b/src/lib/terminal/index.ts @@ -160,11 +160,16 @@ export { type LegacyTerminalOutputData, type TerminalOutputData, } from './terminalOutputPayload.js'; +export { attachTerminalOutputChannel } from './terminalOutputStream.js'; export { - attachTerminalOutputChannel, decodeTerminalOutputChannelFrame, type TerminalOutputChannelFrame, -} from './terminalOutputStream.js'; +} from './terminalOutputFrame.js'; +export { + selectSnifferBytes, + SNIFF_FULL_MAX_BYTES, + SNIFF_TAIL_BYTES, +} from './terminalSnifferBytes.js'; export { disposeTerminalOutputChannel, registerTerminalReloadTeardown, diff --git a/src/lib/terminal/instanceApi.ts b/src/lib/terminal/instanceApi.ts index c76e0dd8..a5acc46d 100644 --- a/src/lib/terminal/instanceApi.ts +++ b/src/lib/terminal/instanceApi.ts @@ -5,6 +5,7 @@ import { disposeTerminalLigatures } from './ligatures.js'; import { clearTerminalPendingInput, terminalCache } from './terminalCache.js'; import { clearTerminalInputQueue } from './inputQueue.js'; import { silenceTerminalOutputChannel } from './terminalReloadTeardown.js'; +import { clearTerminalIoDebug } from './terminalIoDebug.js'; export function getTerminalRecentLines(termId: string, lineCount = 20): string | null { if (!termId) { return null; @@ -47,6 +48,7 @@ export function destroyTerminalInstance(termId: string): void { silenceTerminalOutputChannel(cached.outputChannel); cached.outputChannel = undefined; + clearTerminalIoDebug(termId); try { cached.term.dispose(); diff --git a/src/lib/terminal/terminalIoDebug.ts b/src/lib/terminal/terminalIoDebug.ts new file mode 100644 index 00000000..3d0ac7d4 --- /dev/null +++ b/src/lib/terminal/terminalIoDebug.ts @@ -0,0 +1,169 @@ +/** + * Optional PTY I/O counters for throughput QA. + * Enable with localStorage.zyncTerminalIoDebug = '1'. Never logs payloads. + */ + +const FLAG = 'zyncTerminalIoDebug'; +const DUMP_MS = 1000; +const FLAG_POLL_MS = 1000; +const WRITES_PER_FRAME_CAP = 600; + +let cachedEnabled = false; +let lastFlagCheck = 0; + +export function isTerminalIoDebugEnabled(): boolean { + const now = Date.now(); + if (now - lastFlagCheck < FLAG_POLL_MS) { + return cachedEnabled; + } + lastFlagCheck = now; + try { + cachedEnabled = typeof localStorage !== 'undefined' && localStorage.getItem(FLAG) === '1'; + } catch { + cachedEnabled = false; + } + return cachedEnabled; +} + +interface SessionIoStats { + startedAt: number; + framesIn: number; + termWrites: number; + bytesIn: number; + termWriteTotalMs: number; + termWriteMaxMs: number; + maxPendingBytes: number; + writesThisFrame: number; + writesPerFrame: number[]; + frameRaf: number | null; + dumpTimer: ReturnType | null; +} + +const sessions = new Map(); + +function getSession(termId: string): SessionIoStats { + let stats = sessions.get(termId); + if (!stats) { + stats = { + startedAt: Date.now(), + framesIn: 0, + termWrites: 0, + bytesIn: 0, + termWriteTotalMs: 0, + termWriteMaxMs: 0, + maxPendingBytes: 0, + writesThisFrame: 0, + writesPerFrame: [], + frameRaf: null, + dumpTimer: null, + }; + sessions.set(termId, stats); + } + return stats; +} + +function percentile(sorted: number[], p: number): number { + if (sorted.length === 0) return 0; + const idx = Math.min(sorted.length - 1, Math.max(0, Math.ceil(p * sorted.length) - 1)); + return sorted[idx] ?? 0; +} + +function dumpSession(termId: string, stats: SessionIoStats): void { + const elapsedSec = Math.max(0.001, (Date.now() - stats.startedAt) / 1000); + const sorted = [...stats.writesPerFrame].sort((a, b) => a - b); + const median = percentile(sorted, 0.5); + const p95 = percentile(sorted, 0.95); + const writesPerSec = stats.termWrites / elapsedSec; + const framesPerSec = stats.framesIn / elapsedSec; + + const line = { + termId, + framesIn: stats.framesIn, + termWrites: stats.termWrites, + bytesIn: stats.bytesIn, + termWriteTotalMs: Number(stats.termWriteTotalMs.toFixed(2)), + termWriteMaxMs: Number(stats.termWriteMaxMs.toFixed(2)), + maxPendingBytes: stats.maxPendingBytes, + writesPerSec: Number(writesPerSec.toFixed(1)), + framesPerSec: Number(framesPerSec.toFixed(1)), + writesPerFrameMedian: median, + writesPerFrameP95: p95, + }; + + const ipc = (window as Window & { + ipcRenderer?: { invoke: (ch: string, ...args: unknown[]) => Promise }; + }).ipcRenderer; + if (!ipc?.invoke) { + console.debug('[zync-terminal-io]', line); + return; + } + void ipc.invoke('terminal:flush-stats').then( + (flushReasons) => { + console.debug('[zync-terminal-io]', { ...line, rustFlushReasonsProcessWide: flushReasons }); + }, + () => { + console.debug('[zync-terminal-io]', line); + }, + ); +} + +function pumpVsync(termId: string, stats: SessionIoStats): void { + if (stats.frameRaf !== null || typeof requestAnimationFrame === 'undefined') return; + const tick = () => { + stats.writesPerFrame.push(stats.writesThisFrame); + if (stats.writesPerFrame.length > WRITES_PER_FRAME_CAP) { + stats.writesPerFrame.shift(); + } + stats.writesThisFrame = 0; + if (!sessions.has(termId) || !isTerminalIoDebugEnabled()) { + stats.frameRaf = null; + return; + } + stats.frameRaf = requestAnimationFrame(tick); + }; + stats.frameRaf = requestAnimationFrame(tick); +} + +function ensureDump(termId: string, stats: SessionIoStats): void { + if (stats.dumpTimer !== null) return; + stats.dumpTimer = setInterval(() => { + if (!isTerminalIoDebugEnabled()) return; + dumpSession(termId, stats); + }, DUMP_MS); + pumpVsync(termId, stats); +} + +export function recordChannelFrame(termId: string, payloadBytes: number): void { + if (!isTerminalIoDebugEnabled()) return; + const stats = getSession(termId); + stats.framesIn += 1; + stats.bytesIn += payloadBytes; + if (payloadBytes > stats.maxPendingBytes) stats.maxPendingBytes = payloadBytes; + ensureDump(termId, stats); +} + +export function recordTermWrite(termId: string, durationMs: number): void { + if (!isTerminalIoDebugEnabled()) return; + const stats = getSession(termId); + stats.termWrites += 1; + stats.termWriteTotalMs += durationMs; + if (durationMs > stats.termWriteMaxMs) stats.termWriteMaxMs = durationMs; + stats.writesThisFrame += 1; + ensureDump(termId, stats); +} + +export function clearTerminalIoDebug(termId: string): void { + const stats = sessions.get(termId); + if (!stats) return; + if (stats.dumpTimer !== null) clearInterval(stats.dumpTimer); + if (stats.frameRaf !== null && typeof cancelAnimationFrame !== 'undefined') { + cancelAnimationFrame(stats.frameRaf); + } + sessions.delete(termId); +} + +export function clearAllTerminalIoDebug(): void { + for (const termId of [...sessions.keys()]) { + clearTerminalIoDebug(termId); + } +} diff --git a/src/lib/terminal/terminalOutputFrame.ts b/src/lib/terminal/terminalOutputFrame.ts new file mode 100644 index 00000000..5f64188f --- /dev/null +++ b/src/lib/terminal/terminalOutputFrame.ts @@ -0,0 +1,27 @@ +export const GENERATION_HEADER_BYTES = 4; + +export interface TerminalOutputChannelFrame { + generation: number; + data: Uint8Array; +} + +/** Decodes a raw IPC channel frame: u32 LE generation + PTY bytes. */ +export function decodeTerminalOutputChannelFrame(buffer: ArrayBuffer): TerminalOutputChannelFrame { + if (buffer.byteLength < GENERATION_HEADER_BYTES) { + throw new RangeError('PTY output channel frame too short'); + } + const view = new DataView(buffer); + const generation = view.getUint32(0, true); + const data = new Uint8Array(buffer, GENERATION_HEADER_BYTES); + return { generation, data }; +} + +export function terminalOutputMessageToArrayBuffer(message: unknown): ArrayBuffer | null { + if (message instanceof ArrayBuffer) { + return message; + } + if (message instanceof Uint8Array) { + return message.buffer.slice(message.byteOffset, message.byteOffset + message.byteLength); + } + return null; +} diff --git a/src/lib/terminal/terminalOutputStream.ts b/src/lib/terminal/terminalOutputStream.ts index 042355f9..34577fde 100644 --- a/src/lib/terminal/terminalOutputStream.ts +++ b/src/lib/terminal/terminalOutputStream.ts @@ -6,8 +6,19 @@ import { useAppStore } from '../../store/useAppStore.js'; import { terminalCache } from './terminalCache.js'; import { touchTerminalActivity } from './terminalActivity.js'; import { silenceTerminalOutputChannel } from './terminalReloadTeardown.js'; +import { recordChannelFrame, recordTermWrite } from './terminalIoDebug.js'; +import { selectSnifferBytes, SNIFF_FULL_MAX_BYTES } from './terminalSnifferBytes.js'; +import { + decodeTerminalOutputChannelFrame, + GENERATION_HEADER_BYTES, + terminalOutputMessageToArrayBuffer, +} from './terminalOutputFrame.js'; -const GENERATION_HEADER_BYTES = 4; +export { selectSnifferBytes, SNIFF_FULL_MAX_BYTES, SNIFF_TAIL_BYTES } from './terminalSnifferBytes.js'; +export { + decodeTerminalOutputChannelFrame, + type TerminalOutputChannelFrame, +} from './terminalOutputFrame.js'; /** Cheap pre-filter before UTF-8 decode + prompt regex work on PTY output. */ function outputMayContainPrompt(data: Uint8Array): boolean { @@ -33,31 +44,7 @@ function createStubOutputChannel(): Channel { } as unknown as Channel; } -export interface TerminalOutputChannelFrame { - generation: number; - data: Uint8Array; -} - -/** Decodes a raw IPC channel frame: u32 LE generation + PTY bytes. */ -export function decodeTerminalOutputChannelFrame(buffer: ArrayBuffer): TerminalOutputChannelFrame { - if (buffer.byteLength < GENERATION_HEADER_BYTES) { - throw new RangeError('PTY output channel frame too short'); - } - const view = new DataView(buffer); - const generation = view.getUint32(0, true); - const data = new Uint8Array(buffer, GENERATION_HEADER_BYTES); - return { generation, data }; -} -function toArrayBuffer(message: unknown): ArrayBuffer | null { - if (message instanceof ArrayBuffer) { - return message; - } - if (message instanceof Uint8Array) { - return message.buffer.slice(message.byteOffset, message.byteOffset + message.byteLength); - } - return null; -} /** * Registers a Tauri output channel for the next terminal:create invoke. @@ -85,7 +72,7 @@ export function attachTerminalOutputChannel(termId: string, term: XTerm): Channe return; } - const payload = toArrayBuffer(message); + const payload = terminalOutputMessageToArrayBuffer(message); if (!payload || payload.byteLength < GENERATION_HEADER_BYTES) { return; } @@ -96,21 +83,25 @@ export function attachTerminalOutputChannel(termId: string, term: XTerm): Channe } touchTerminalActivity(termId); + recordChannelFrame(termId, data.byteLength); if (entry.connectionId) { const connectionId = entry.connectionId; - // Always run the bounded secret sniffer; chunk-boundary prefilters can miss prompts. - feedSecretInputSniffer(termId, data, () => { + const large = data.length > SNIFF_FULL_MAX_BYTES; + const sniff = selectSnifferBytes(data); + feedSecretInputSniffer(termId, sniff, () => { const live = terminalCache.get(termId); live?.ghostTracker?.enterSecretInputMode(); - }); - if (outputMayContainPrompt(data)) { - feedPromptCwdSniffer(termId, data, (path) => { + }, { resetDecoder: large }); + if (outputMayContainPrompt(sniff)) { + feedPromptCwdSniffer(termId, sniff, (path) => { entry.ghostTracker?.exitSecretInputMode(); useAppStore.getState().setTerminalCwd(connectionId, termId, path); - }); + }, { resetDecoder: large }); } } + const writeStarted = performance.now(); term.write(data); + recordTermWrite(termId, performance.now() - writeStarted); }); cached.outputChannel = channel; diff --git a/src/lib/terminal/terminalReloadTeardown.ts b/src/lib/terminal/terminalReloadTeardown.ts index 5bd81083..9636b953 100644 --- a/src/lib/terminal/terminalReloadTeardown.ts +++ b/src/lib/terminal/terminalReloadTeardown.ts @@ -1,5 +1,6 @@ import type { Channel } from '@tauri-apps/api/core'; import { terminalCache } from './terminalCache.js'; +import { clearAllTerminalIoDebug } from './terminalIoDebug.js'; function isTauriRuntime(): boolean { return typeof window !== 'undefined' @@ -48,6 +49,7 @@ export function teardownTerminalsBeforeWebviewReload(): void { revokeTerminalOutputChannel(cached.outputChannel); cached.outputChannel = undefined; } + clearAllTerminalIoDebug(); } /** Wire dev HMR and page-unload teardown once at app startup. */ diff --git a/src/lib/terminal/terminalSnifferBytes.ts b/src/lib/terminal/terminalSnifferBytes.ts new file mode 100644 index 00000000..b0016b80 --- /dev/null +++ b/src/lib/terminal/terminalSnifferBytes.ts @@ -0,0 +1,8 @@ +export const SNIFF_FULL_MAX_BYTES = 8192; +export const SNIFF_TAIL_BYTES = 4096; + +/** Bytes to feed sniffers. Large frames: bounded tail only. */ +export function selectSnifferBytes(data: Uint8Array): Uint8Array { + if (data.length <= SNIFF_FULL_MAX_BYTES) return data; + return data.subarray(data.length - SNIFF_TAIL_BYTES); +} diff --git a/tests/ghostSuggestionsHelpers.test.mjs b/tests/ghostSuggestionsHelpers.test.mjs index a1d70418..132b6fcb 100644 --- a/tests/ghostSuggestionsHelpers.test.mjs +++ b/tests/ghostSuggestionsHelpers.test.mjs @@ -520,6 +520,47 @@ await runTest('feedSecretInputSniffer fires once per prompt until new output', ( } }); +await runTest('secret sniffer fires on Password in last 4 KiB of a large buffer', () => { + let calls = 0; + const termId = 'secret-sniffer-tail'; + const encode = (value) => new TextEncoder().encode(value); + const prefix = 'x'.repeat(5000); + try { + feedSecretInputSniffer(termId, encode(`${prefix}\nPassword:\n`), () => { calls += 1; }, { resetDecoder: true }); + assert.equal(calls, 1); + } finally { + clearSecretInputSniffer(termId); + } +}); + +await runTest('secret sniffer does not fire when Password is only in skipped prefix', () => { + let calls = 0; + const termId = 'secret-sniffer-prefix'; + const encode = (value) => new TextEncoder().encode(value); + const full = encode(`Password:\n${'x'.repeat(9000)}`); + const tail = full.subarray(full.length - 4096); + try { + feedSecretInputSniffer(termId, tail, () => { calls += 1; }, { resetDecoder: true }); + assert.equal(calls, 0); + } finally { + clearSecretInputSniffer(termId); + } +}); + +await runTest('resetDecoder does not wipe rolling string across small then tail feed', () => { + let calls = 0; + const termId = 'secret-sniffer-reset-keep-buffer'; + const encode = (value) => new TextEncoder().encode(value); + try { + feedSecretInputSniffer(termId, encode('Pass'), () => { calls += 1; }); + assert.equal(calls, 0); + feedSecretInputSniffer(termId, encode('word:\n'), () => { calls += 1; }, { resetDecoder: true }); + assert.equal(calls, 1); + } finally { + clearSecretInputSniffer(termId); + } +}); + await runTest('detectSecretPromptInOutput recognizes sudo and SSH password prompts', () => { assert.equal(detectSecretPromptInOutput('[sudo] password for gajen: '), true); assert.equal(detectSecretPromptInOutput("user@host's password: "), true); diff --git a/tests/runAllAgentTests.mjs b/tests/runAllAgentTests.mjs index 36d8913b..402de5e6 100644 --- a/tests/runAllAgentTests.mjs +++ b/tests/runAllAgentTests.mjs @@ -57,6 +57,7 @@ const tests = [ 'tests/connectionService.test.mjs', 'tests/connectionTabService.test.mjs', 'tests/ghostSuggestionsHelpers.test.mjs', + 'tests/terminalOutputStream.test.mjs', 'tests/providerCatalog.test.mjs', 'tests/quickConnectParsing.test.mjs', 'tests/quickConnectSubcomponents.test.mjs', diff --git a/tests/terminalOutputStream.test.mjs b/tests/terminalOutputStream.test.mjs index 41310c6f..307551e3 100644 --- a/tests/terminalOutputStream.test.mjs +++ b/tests/terminalOutputStream.test.mjs @@ -1,5 +1,10 @@ import assert from 'node:assert/strict'; -import { decodeTerminalOutputChannelFrame } from '../.tmp-agent-tests/src/lib/terminal/terminalOutputStream.js'; +import { decodeTerminalOutputChannelFrame } from '../.tmp-agent-tests/src/lib/terminal/terminalOutputFrame.js'; +import { + selectSnifferBytes, + SNIFF_FULL_MAX_BYTES, + SNIFF_TAIL_BYTES, +} from '../.tmp-agent-tests/src/lib/terminal/terminalSnifferBytes.js'; function runTest(name, fn) { try { @@ -36,4 +41,29 @@ runTest('decodeTerminalOutputChannelFrame handles empty PTY payload', () => { assert.equal(decoded.data.length, 0); }); +runTest('selectSnifferBytes returns the same view when length <= 8192', () => { + const data = new Uint8Array(SNIFF_FULL_MAX_BYTES); + data[0] = 1; + data[data.length - 1] = 9; + const out = selectSnifferBytes(data); + assert.equal(out.length, SNIFF_FULL_MAX_BYTES); + assert.equal(out, data); +}); + +runTest('selectSnifferBytes returns last 4096 bytes when length is 8193', () => { + const data = new Uint8Array(SNIFF_FULL_MAX_BYTES + 1); + data[0] = 7; + data[data.length - 1] = 42; + const out = selectSnifferBytes(data); + assert.equal(out.length, SNIFF_TAIL_BYTES); + assert.equal(out[out.length - 1], 42); + assert.notEqual(out[0], 7); +}); + +runTest('selectSnifferBytes empty and exact 4096 boundaries', () => { + assert.equal(selectSnifferBytes(new Uint8Array(0)).length, 0); + const exactTail = new Uint8Array(SNIFF_TAIL_BYTES); + assert.equal(selectSnifferBytes(exactTail), exactTail); +}); + console.log('Terminal output stream tests passed.'); \ No newline at end of file From c266a769c4235fb7e4790fe995166b1c21cd6b51 Mon Sep 17 00:00:00 2001 From: Gajendra sahu Date: Mon, 7 Sep 2026 03:06:42 +0530 Subject: [PATCH 02/17] perf(files): extract FileGrid sort and layout helpers --- src/components/file-manager/FileGrid.tsx | 34 +++------- src/components/file-manager/fileGridLayout.ts | 62 +++++++++++++++++++ tests/fileGridLayout.test.mjs | 51 +++++++++++++++ tests/runAllAgentTests.mjs | 1 + tsconfig.agent-tests.json | 2 + 5 files changed, 123 insertions(+), 27 deletions(-) create mode 100644 src/components/file-manager/fileGridLayout.ts create mode 100644 tests/fileGridLayout.test.mjs diff --git a/src/components/file-manager/FileGrid.tsx b/src/components/file-manager/FileGrid.tsx index c635ab3d..450e84c0 100644 --- a/src/components/file-manager/FileGrid.tsx +++ b/src/components/file-manager/FileGrid.tsx @@ -15,6 +15,7 @@ import { motion, AnimatePresence } from 'framer-motion'; import { forwardRef } from 'react'; import { buildDragData, startInternalDrag, validateAndBuildMoves } from './dragDropUtils'; import { Tooltip } from '../ui/Tooltip'; +import { sortFileEntries, type FileSortColumn, type FileSortDirection } from './fileGridLayout'; // Extended Icon Selector with Colors const FileIcon = memo(function FileIcon({ file, size }: { file: FileEntry; size: number }) { @@ -358,8 +359,8 @@ interface FileGridProps { onMove?: (moves: { source: string; target: string; sourceConnectionId?: string }[]) => void; } -type SortColumn = 'name' | 'size' | 'type' | 'modified'; -type SortDirection = 'asc' | 'desc'; +type SortColumn = FileSortColumn; +type SortDirection = FileSortDirection; export function FileGrid({ files, @@ -397,31 +398,10 @@ export function FileGrid({ } }; - const sortedFiles = useMemo(() => { - return [...files].sort((a, b) => { - if (a.type === 'd' && b.type !== 'd') return -1; - if (a.type !== 'd' && b.type === 'd') return 1; - - let comparison = 0; - switch (sortColumn) { - case 'name': - comparison = a.name.localeCompare(b.name); - break; - case 'size': - comparison = a.size - b.size; - break; - case 'type': - const extA = a.name.split('.').pop()?.toLowerCase() || ''; - const extB = b.name.split('.').pop()?.toLowerCase() || ''; - comparison = extA.localeCompare(extB); - break; - case 'modified': - comparison = a.lastModified - b.lastModified; - break; - } - return sortDirection === 'asc' ? comparison : -comparison; - }); - }, [files, sortColumn, sortDirection]); + const sortedFiles = useMemo( + () => sortFileEntries(files, sortColumn, sortDirection), + [files, sortColumn, sortDirection], + ); diff --git a/src/components/file-manager/fileGridLayout.ts b/src/components/file-manager/fileGridLayout.ts new file mode 100644 index 00000000..ba98bd91 --- /dev/null +++ b/src/components/file-manager/fileGridLayout.ts @@ -0,0 +1,62 @@ +import type { FileEntry } from './types'; + +export type FileSortColumn = 'name' | 'size' | 'type' | 'modified'; +export type FileSortDirection = 'asc' | 'desc'; + +/** List-row height from FileListItem (`py-2` + 20px icon + border). Compact does not change list rows. */ +export const FILE_LIST_ROW_HEIGHT = 40; + +export interface FileGridMetrics { + columnCount: number; + columnWidth: number; + rowHeight: number; + gap: number; +} + +function fileExtension(name: string): string { + const dot = name.lastIndexOf('.'); + if (dot <= 0 || dot === name.length - 1) return ''; + return name.slice(dot + 1).toLowerCase(); +} + +export function sortFileEntries( + files: FileEntry[], + column: FileSortColumn, + direction: FileSortDirection, +): FileEntry[] { + return [...files].sort((a, b) => { + if (a.type === 'd' && b.type !== 'd') return -1; + if (a.type !== 'd' && b.type === 'd') return 1; + + let comparison = 0; + switch (column) { + case 'name': + comparison = a.name.localeCompare(b.name); + break; + case 'size': + comparison = a.size - b.size; + break; + case 'type': + comparison = fileExtension(a.name).localeCompare(fileExtension(b.name)); + break; + case 'modified': + comparison = a.lastModified - b.lastModified; + break; + } + return direction === 'asc' ? comparison : -comparison; + }); +} + +export function computeFileGridMetrics(containerWidth: number, compactMode: boolean): FileGridMetrics { + const minTrack = compactMode ? 100 : 120; + const gap = compactMode ? 8 : 16; + const rowHeight = compactMode ? 120 : 140; + const width = Math.max(0, containerWidth); + const columnCount = Math.max(1, Math.floor((width + gap) / (minTrack + gap))); + const columnWidth = (width - gap * (columnCount - 1)) / columnCount; + return { columnCount, columnWidth, rowHeight, gap }; +} + +export function fileGridKeyboardIndex(row: number, col: number, columnCount: number): number { + return row * Math.max(1, columnCount) + col; +} diff --git a/tests/fileGridLayout.test.mjs b/tests/fileGridLayout.test.mjs new file mode 100644 index 00000000..e506d638 --- /dev/null +++ b/tests/fileGridLayout.test.mjs @@ -0,0 +1,51 @@ +import assert from 'node:assert/strict'; +import { + computeFileGridMetrics, + fileGridKeyboardIndex, + sortFileEntries, +} from '../.tmp-agent-tests/src/components/file-manager/fileGridLayout.js'; + +function runTest(name, fn) { + try { + fn(); + console.log(` ok ${name}`); + } catch (error) { + console.error(` fail ${name}`); + throw error; + } +} + +function entry(name, type = '-', size = 0, lastModified = 0) { + return { name, type, size, lastModified, permissions: '', path: `/${name}` }; +} + +runTest('sortFileEntries puts directories first then name', () => { + const sorted = sortFileEntries( + [entry('b.txt'), entry('a', 'd'), entry('c.txt')], + 'name', + 'asc', + ); + assert.deepEqual(sorted.map((f) => f.name), ['a', 'b.txt', 'c.txt']); +}); + +runTest('sortFileEntries reverses non-dir comparison on desc', () => { + const sorted = sortFileEntries( + [entry('b.txt', '-', 2), entry('a.txt', '-', 1)], + 'size', + 'desc', + ); + assert.deepEqual(sorted.map((f) => f.name), ['b.txt', 'a.txt']); +}); + +runTest('computeFileGridMetrics compact column count', () => { + const m = computeFileGridMetrics(332, true); + assert.equal(m.gap, 8); + assert.equal(m.columnCount, 3); + assert.ok(m.columnWidth > 100); +}); + +runTest('fileGridKeyboardIndex is row-major', () => { + assert.equal(fileGridKeyboardIndex(1, 2, 4), 6); +}); + +console.log('fileGridLayout tests passed.'); diff --git a/tests/runAllAgentTests.mjs b/tests/runAllAgentTests.mjs index 402de5e6..c3848c2f 100644 --- a/tests/runAllAgentTests.mjs +++ b/tests/runAllAgentTests.mjs @@ -58,6 +58,7 @@ const tests = [ 'tests/connectionTabService.test.mjs', 'tests/ghostSuggestionsHelpers.test.mjs', 'tests/terminalOutputStream.test.mjs', + 'tests/fileGridLayout.test.mjs', 'tests/providerCatalog.test.mjs', 'tests/quickConnectParsing.test.mjs', 'tests/quickConnectSubcomponents.test.mjs', diff --git a/tsconfig.agent-tests.json b/tsconfig.agent-tests.json index 1c2ea751..888a569f 100644 --- a/tsconfig.agent-tests.json +++ b/tsconfig.agent-tests.json @@ -113,6 +113,8 @@ "src/lib/paneLayout/persist.ts", "src/lib/paneLayout/groups.ts", "src/lib/paneLayout/index.ts", + "src/components/file-manager/types.ts", + "src/components/file-manager/fileGridLayout.ts", "src/components/layout/featureMeta.ts", "src/lib/shells/types.ts", "src/components/layout/workspaceOpen/types.ts", From 5750e3661f2fbd8ebad4cfd393d641199728f85b Mon Sep 17 00:00:00 2001 From: Gajendra sahu Date: Mon, 7 Sep 2026 03:37:57 +0530 Subject: [PATCH 03/17] perf(files): virtualize File Manager list view --- CHANGELOG.md | 1 + src/components/FileManager.tsx | 54 ++-- src/components/file-manager/FileGrid.tsx | 255 +++++++++++------- src/components/file-manager/fileGridLayout.ts | 3 + tests/fileGridLayout.test.mjs | 6 + 5 files changed, 202 insertions(+), 117 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8863e2a4..0a5de8cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to Zync are documented in this file. The format is based on ### Changed - **Terminal output flush:** First PTY bytes after quiet go to the terminal immediately (typing echo no longer waits up to 8 ms). Busy output merges for a 12 ms burst, or sooner at 128 KiB. The live Channel frame is still generation + raw bytes. Debug: `localStorage.zyncTerminalIoDebug = '1'`. - **Terminal sniffers:** Secret/cwd helpers scan at most the last 4 KiB of large PTY frames so `cat` does not regex the whole dump. The terminal still receives every byte. +- **File list windowing:** The file manager list view only paints visible rows. Sort lives on FileManager so keyboard order matches the list. Grid view is unchanged for now. ### Added - **Smooth splits**: New panes grow in (about 280ms) instead of jumping to 50/50 — keyboard split, split icons, drag-to-dock, and Open in split / Open here. A quiet accent veil marks the incoming pane. Drag-to-split preview eases between edges. Divider drag stays immediate (no laggy flex transition). `prefers-reduced-motion` skips the intro. PTY resize waits until the intro ends, same as a divider drag. ([2d3b7c8]) diff --git a/src/components/FileManager.tsx b/src/components/FileManager.tsx index bb49d77a..db254fa3 100644 --- a/src/components/FileManager.tsx +++ b/src/components/FileManager.tsx @@ -23,6 +23,7 @@ import { isMatch } from '../lib/keyboard'; import { FileEditor } from './FileEditor'; import { CopyToServerModal } from './file-manager/CopyToServerModal'; import { FileGrid } from './file-manager/FileGrid'; +import { sortFileEntries, type FileSortColumn, type FileSortDirection } from './file-manager/fileGridLayout'; import { getCurrentDragSource } from '../lib/dragDrop'; import { FileToolbar } from './file-manager/FileToolbar'; import type { FileEntry } from './file-manager/types'; @@ -178,6 +179,8 @@ export const FileManager = memo(function FileManager({ const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid'); const [selectedFiles, setSelectedFiles] = useState([]); const [focusedFile, setFocusedFile] = useState(null); + const [sortColumn, setSortColumn] = useState('name'); + const [sortDirection, setSortDirection] = useState('asc'); const [searchTerm, setSearchTerm] = useState(''); const [isSearchOpen, setIsSearchOpen] = useState(false); const [isEditingPath, setIsEditingPath] = useState(false); @@ -865,6 +868,20 @@ export const FileManager = memo(function FileManager({ if (!settings.fileManager.showHiddenFiles && f.name.startsWith('.')) return false; return f.name.toLowerCase().includes(searchTerm.toLowerCase()); }); + const paintedFiles = useMemo( + () => sortFileEntries(filteredFiles, sortColumn, sortDirection), + [filteredFiles, sortColumn, sortDirection], + ); + const handleSort = useCallback((column: FileSortColumn) => { + setSortColumn((current) => { + if (current === column) { + setSortDirection((dir) => (dir === 'asc' ? 'desc' : 'asc')); + return current; + } + setSortDirection('asc'); + return column; + }); + }, []); // --- Action Handlers (Create, Rename, Upload, Delete, Download) --- @@ -1371,16 +1388,12 @@ export const FileManager = memo(function FileManager({ } } - const filteredFiles = files.filter((f) => - f.name.toLowerCase().includes(searchTerm.toLowerCase()) - ); - const bindings = settings.keybindings || {}; // Select All if (isMatch(e, bindings.fmSelectAll || 'Mod+A')) { e.preventDefault(); - setSelectedFiles(filteredFiles.map((f) => f.name)); + setSelectedFiles(paintedFiles.map((f) => f.name)); return; } @@ -1509,9 +1522,9 @@ export const FileManager = memo(function FileManager({ return; } - if (filteredFiles.length === 0) return; + if (paintedFiles.length === 0) return; - const currentIndex = focusedFile ? filteredFiles.findIndex((f) => f.name === focusedFile) : -1; + const currentIndex = focusedFile ? paintedFiles.findIndex((f) => f.name === focusedFile) : -1; // Arrow Keys: Navigate // Note: These are standard navigation keys, not strictly "commands" @@ -1525,14 +1538,14 @@ export const FileManager = memo(function FileManager({ // We measure the DOM to find how many items fit in one row let gridCols = settings.compactMode ? 12 : 6; // Default fallback - if (filteredFiles.length > 0) { - const firstItem = document.getElementById(`file-item-${filteredFiles[0].name}`); + if (paintedFiles.length > 0) { + const firstItem = document.getElementById(`file-item-${paintedFiles[0].name}`); if (firstItem && firstItem.parentElement) { const baseTop = firstItem.offsetTop; let count = 0; // distinct scan to find row break - for (let i = 0; i < filteredFiles.length; i++) { - const el = document.getElementById(`file-item-${filteredFiles[i].name}`); + for (let i = 0; i < paintedFiles.length; i++) { + const el = document.getElementById(`file-item-${paintedFiles[i].name}`); if (el && Math.abs(el.offsetTop - baseTop) < 10) { count++; } else { @@ -1545,18 +1558,18 @@ export const FileManager = memo(function FileManager({ } } - if (e.key === 'ArrowDown') newIndex = Math.min(currentIndex + gridCols, filteredFiles.length - 1); + if (e.key === 'ArrowDown') newIndex = Math.min(currentIndex + gridCols, paintedFiles.length - 1); else if (e.key === 'ArrowUp') newIndex = Math.max(currentIndex - gridCols, 0); - else if (e.key === 'ArrowRight') newIndex = Math.min(currentIndex + 1, filteredFiles.length - 1); + else if (e.key === 'ArrowRight') newIndex = Math.min(currentIndex + 1, paintedFiles.length - 1); else if (e.key === 'ArrowLeft') newIndex = Math.max(currentIndex - 1, 0); } else { // List view: only up/down - if (e.key === 'ArrowDown') newIndex = Math.min(currentIndex + 1, filteredFiles.length - 1); + if (e.key === 'ArrowDown') newIndex = Math.min(currentIndex + 1, paintedFiles.length - 1); else if (e.key === 'ArrowUp') newIndex = Math.max(currentIndex - 1, 0); } if (newIndex === -1) newIndex = 0; - const newFocused = filteredFiles[newIndex]?.name; + const newFocused = paintedFiles[newIndex]?.name; setFocusedFile(newFocused); // Update selection if Shift is held @@ -1580,14 +1593,14 @@ export const FileManager = memo(function FileManager({ // Home if (e.key === 'Home') { e.preventDefault(); - if (filteredFiles.length > 0) setFocusedFile(filteredFiles[0].name); + if (paintedFiles.length > 0) setFocusedFile(paintedFiles[0].name); return; } // End if (e.key === 'End') { e.preventDefault(); - if (filteredFiles.length > 0) setFocusedFile(filteredFiles[filteredFiles.length - 1].name); + if (paintedFiles.length > 0) setFocusedFile(paintedFiles[paintedFiles.length - 1].name); return; } }; @@ -1595,7 +1608,7 @@ export const FileManager = memo(function FileManager({ window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [ - activeConnectionId, searchTerm, isSearchOpen, files, settings, isNewFolderModalOpen, isNewFileModalOpen, isRenameModalOpen, + activeConnectionId, searchTerm, isSearchOpen, files, paintedFiles, settings, isNewFolderModalOpen, isNewFileModalOpen, isRenameModalOpen, editingFile, selectedFiles, focusedFile, handleNavigate, handleCopy, handlePaste, handleDelete, navigateBack, navigateForward, isCopyModalOpen, isPropertiesOpen, viewMode, isConnected, isFilesSurfaceActive, ]); @@ -1716,7 +1729,7 @@ export const FileManager = memo(function FileManager({ /> ) : ( )} diff --git a/src/components/file-manager/FileGrid.tsx b/src/components/file-manager/FileGrid.tsx index 450e84c0..8ac8c3c6 100644 --- a/src/components/file-manager/FileGrid.tsx +++ b/src/components/file-manager/FileGrid.tsx @@ -6,7 +6,7 @@ import type React from 'react'; import { cn, formatBytes, formatDate } from '../../lib/utils'; import type { FileEntry } from './types'; import { useAppStore } from '../../store/useAppStore'; -import { useState, useMemo, useEffect, memo } from 'react'; +import { useState, useMemo, useEffect, memo, type CSSProperties } from 'react'; import { ArrowDown, ArrowUp, ArrowUpDown } from 'lucide-react'; import { convertFileSrc } from '@tauri-apps/api/core'; @@ -15,7 +15,14 @@ import { motion, AnimatePresence } from 'framer-motion'; import { forwardRef } from 'react'; import { buildDragData, startInternalDrag, validateAndBuildMoves } from './dragDropUtils'; import { Tooltip } from '../ui/Tooltip'; -import { sortFileEntries, type FileSortColumn, type FileSortDirection } from './fileGridLayout'; +import { List, useListRef } from 'react-window'; +import { AutoSizer } from 'react-virtualized-auto-sizer'; +import { + FILE_LIST_COLUMNS, + FILE_LIST_ROW_HEIGHT, + type FileSortColumn, + type FileSortDirection, +} from './fileGridLayout'; // Extended Icon Selector with Colors const FileIcon = memo(function FileIcon({ file, size }: { file: FileEntry; size: number }) { @@ -218,8 +225,8 @@ const FileGridItem = memo(forwardRef { @@ -312,15 +316,17 @@ const FileListItem = memo(forwardRef - -
+
+
@@ -328,17 +334,17 @@ const FileListItem = memo(forwardRef
- - +
+
{isFolder ? '—' : formatBytes(file.size)} - - +
+
{isFolder ? 'Folder' : (file.name.split('.').pop()?.toUpperCase() || '—')} - - +
+
{formatDate(file.lastModified)} - - +
+
); })); @@ -357,10 +363,60 @@ interface FileGridProps { currentPath?: string; focusedFile?: string | null; onMove?: (moves: { source: string; target: string; sourceConnectionId?: string }[]) => void; + sortColumn: FileSortColumn; + sortDirection: FileSortDirection; + onSort: (column: FileSortColumn) => void; } -type SortColumn = FileSortColumn; -type SortDirection = FileSortDirection; +type FileListRowExtra = { + files: FileEntry[]; + selectedFiles: string[]; + focusedFile?: string | null; + connectionId?: string; + currentPath?: string; + onSelect: (name: string, multi: boolean) => void; + onNavigate: (name: string) => void; + onContextMenu: (e: React.MouseEvent, file?: FileEntry) => void; + onMove?: (moves: { source: string; target: string; sourceConnectionId?: string }[]) => void; +}; + +function FileListRow({ + index, + style, + ariaAttributes, + files, + selectedFiles, + focusedFile, + connectionId, + currentPath, + onSelect, + onNavigate, + onContextMenu, + onMove, +}: { + index: number; + style: CSSProperties; + ariaAttributes: { 'aria-posinset': number; 'aria-setsize': number; role: 'listitem' }; +} & FileListRowExtra) { + const file = files[index]; + if (!file) return null; + return ( +
+ +
+ ); +} export function FileGrid({ files, @@ -374,33 +430,50 @@ export function FileGrid({ currentPath, focusedFile, onMove, + sortColumn, + sortDirection, + onSort, }: FileGridProps) { const settings = useAppStore(state => state.settings); const compactMode = settings.compactMode; - const [sortColumn, setSortColumn] = useState('name'); - const [sortDirection, setSortDirection] = useState('asc'); + const listRef = useListRef(null); useEffect(() => { - if (focusedFile) { - const element = document.getElementById(`file-item-${focusedFile}`); - if (element) { - element.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + if (!focusedFile) return; + if (viewMode === 'list') { + const index = files.findIndex((f) => f.name === focusedFile); + if (index >= 0) { + listRef.current?.scrollToRow({ index, align: 'smart', behavior: 'smooth' }); } + return; } - }, [focusedFile]); - - const handleSort = (column: SortColumn) => { - if (sortColumn === column) { - setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc'); - } else { - setSortColumn(column); - setSortDirection('asc'); - } - }; + const element = document.getElementById(`file-item-${focusedFile}`); + element?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + }, [focusedFile, files, viewMode, listRef]); - const sortedFiles = useMemo( - () => sortFileEntries(files, sortColumn, sortDirection), - [files, sortColumn, sortDirection], + const listRowProps = useMemo( + () => ({ + files, + selectedFiles, + focusedFile, + connectionId, + currentPath, + onSelect, + onNavigate, + onContextMenu, + onMove, + }), + [ + files, + selectedFiles, + focusedFile, + connectionId, + currentPath, + onSelect, + onNavigate, + onContextMenu, + onMove, + ], ); @@ -409,7 +482,7 @@ export function FileGrid({ return ( // biome-ignore lint/a11y/noStaticElementInteractions:
onSelect('', false)} onContextMenu={(e) => { e.preventDefault(); @@ -463,7 +536,7 @@ export function FileGrid({
{files.length === 0 ? ( @@ -482,64 +555,50 @@ export function FileGrid({ })()} ) : viewMode === 'list' ? ( - - - - - - - - - - - - {sortedFiles.map((file) => ( - - ))} - - -
handleSort('name')}> -
- Name - {sortColumn === 'name' && (sortDirection === 'asc' ? : )} - {sortColumn !== 'name' && } -
-
handleSort('size')}> -
- Size - {sortColumn === 'size' && (sortDirection === 'asc' ? : )} - {sortColumn !== 'size' && } -
-
handleSort('type')}> -
- Type - {sortColumn === 'type' && (sortDirection === 'asc' ? : )} - {sortColumn !== 'type' && } -
-
handleSort('modified')}> -
- Modified - {sortColumn === 'modified' && (sortDirection === 'asc' ? : )} - {sortColumn !== 'modified' && } -
-
+
+
+ {(['name', 'size', 'type', 'modified'] as const).map((column) => ( + + ))} +
+
+ + height && width ? ( + + ) : null + } + /> +
+
) : (
- {sortedFiles.map((file) => ( + {files.map((file) => ( { assert.equal(fileGridKeyboardIndex(1, 2, 4), 6); }); +runTest('FILE_LIST_COLUMNS includes name flex track and size column', () => { + assert.equal(FILE_LIST_COLUMNS.includes('minmax(0, 1fr)'), true); + assert.equal(FILE_LIST_COLUMNS.includes('6rem'), true); +}); + console.log('fileGridLayout tests passed.'); From ccb38e4ba1cd8aa444ce95bc0b071613b7cb3c29 Mon Sep 17 00:00:00 2001 From: Gajendra sahu Date: Mon, 7 Sep 2026 03:47:25 +0530 Subject: [PATCH 04/17] fix(files): stabilize list virtualization sort, scroll, and header gutter --- src/components/FileManager.tsx | 28 +++++++++++++----------- src/components/file-manager/FileGrid.tsx | 23 +++++++++++-------- 2 files changed, 29 insertions(+), 22 deletions(-) diff --git a/src/components/FileManager.tsx b/src/components/FileManager.tsx index db254fa3..212aeaaa 100644 --- a/src/components/FileManager.tsx +++ b/src/components/FileManager.tsx @@ -864,24 +864,26 @@ export const FileManager = memo(function FileManager({ } }; - const filteredFiles = files.filter((f) => { - if (!settings.fileManager.showHiddenFiles && f.name.startsWith('.')) return false; - return f.name.toLowerCase().includes(searchTerm.toLowerCase()); - }); + const showHiddenFiles = settings.fileManager.showHiddenFiles; + const filteredFiles = useMemo( + () => files.filter((f) => { + if (!showHiddenFiles && f.name.startsWith('.')) return false; + return f.name.toLowerCase().includes(searchTerm.toLowerCase()); + }), + [files, showHiddenFiles, searchTerm], + ); const paintedFiles = useMemo( () => sortFileEntries(filteredFiles, sortColumn, sortDirection), [filteredFiles, sortColumn, sortDirection], ); const handleSort = useCallback((column: FileSortColumn) => { - setSortColumn((current) => { - if (current === column) { - setSortDirection((dir) => (dir === 'asc' ? 'desc' : 'asc')); - return current; - } - setSortDirection('asc'); - return column; - }); - }, []); + if (column === sortColumn) { + setSortDirection((dir) => (dir === 'asc' ? 'desc' : 'asc')); + return; + } + setSortColumn(column); + setSortDirection('asc'); + }, [sortColumn]); // --- Action Handlers (Create, Rename, Upload, Delete, Download) --- diff --git a/src/components/file-manager/FileGrid.tsx b/src/components/file-manager/FileGrid.tsx index 8ac8c3c6..8b3e311a 100644 --- a/src/components/file-manager/FileGrid.tsx +++ b/src/components/file-manager/FileGrid.tsx @@ -6,7 +6,7 @@ import type React from 'react'; import { cn, formatBytes, formatDate } from '../../lib/utils'; import type { FileEntry } from './types'; import { useAppStore } from '../../store/useAppStore'; -import { useState, useMemo, useEffect, memo, type CSSProperties } from 'react'; +import { useState, useMemo, useEffect, useCallback, memo, type CSSProperties } from 'react'; import { ArrowDown, ArrowUp, ArrowUpDown } from 'lucide-react'; import { convertFileSrc } from '@tauri-apps/api/core'; @@ -15,7 +15,7 @@ import { motion, AnimatePresence } from 'framer-motion'; import { forwardRef } from 'react'; import { buildDragData, startInternalDrag, validateAndBuildMoves } from './dragDropUtils'; import { Tooltip } from '../ui/Tooltip'; -import { List, useListRef } from 'react-window'; +import { getScrollbarSize, List, useListRef } from 'react-window'; import { AutoSizer } from 'react-virtualized-auto-sizer'; import { FILE_LIST_COLUMNS, @@ -438,18 +438,22 @@ export function FileGrid({ const compactMode = settings.compactMode; const listRef = useListRef(null); + const scrollFocusedListRow = useCallback(() => { + if (viewMode !== 'list' || !focusedFile) return; + const index = files.findIndex((f) => f.name === focusedFile); + if (index < 0) return; + listRef.current?.scrollToRow({ index, align: 'smart', behavior: 'auto' }); + }, [viewMode, focusedFile, files, listRef]); + useEffect(() => { if (!focusedFile) return; if (viewMode === 'list') { - const index = files.findIndex((f) => f.name === focusedFile); - if (index >= 0) { - listRef.current?.scrollToRow({ index, align: 'smart', behavior: 'smooth' }); - } + scrollFocusedListRow(); return; } const element = document.getElementById(`file-item-${focusedFile}`); - element?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); - }, [focusedFile, files, viewMode, listRef]); + element?.scrollIntoView({ behavior: 'auto', block: 'nearest' }); + }, [focusedFile, viewMode, scrollFocusedListRow]); const listRowProps = useMemo( () => ({ @@ -558,7 +562,7 @@ export function FileGrid({
{(['name', 'size', 'type', 'modified'] as const).map((column) => (
diff --git a/src/components/file-manager/FileGrid.tsx b/src/components/file-manager/FileGrid.tsx index 8b3e311a..43ffadfd 100644 --- a/src/components/file-manager/FileGrid.tsx +++ b/src/components/file-manager/FileGrid.tsx @@ -6,7 +6,7 @@ import type React from 'react'; import { cn, formatBytes, formatDate } from '../../lib/utils'; import type { FileEntry } from './types'; import { useAppStore } from '../../store/useAppStore'; -import { useState, useMemo, useEffect, useCallback, memo, type CSSProperties } from 'react'; +import { useState, useMemo, useEffect, useCallback, useRef, memo, type CSSProperties } from 'react'; import { ArrowDown, ArrowUp, ArrowUpDown } from 'lucide-react'; import { convertFileSrc } from '@tauri-apps/api/core'; @@ -15,11 +15,13 @@ import { motion, AnimatePresence } from 'framer-motion'; import { forwardRef } from 'react'; import { buildDragData, startInternalDrag, validateAndBuildMoves } from './dragDropUtils'; import { Tooltip } from '../ui/Tooltip'; -import { getScrollbarSize, List, useListRef } from 'react-window'; +import { getScrollbarSize, Grid, List, useGridRef, useListRef } from 'react-window'; import { AutoSizer } from 'react-virtualized-auto-sizer'; import { + computeFileGridMetrics, FILE_LIST_COLUMNS, FILE_LIST_ROW_HEIGHT, + fileGridSlotSize, type FileSortColumn, type FileSortDirection, } from './fileGridLayout'; @@ -91,12 +93,8 @@ const FileGridItem = memo(forwardRef { @@ -164,9 +162,9 @@ const FileGridItem = memo(forwardRef )}
- +
); })); @@ -366,6 +364,7 @@ interface FileGridProps { sortColumn: FileSortColumn; sortDirection: FileSortDirection; onSort: (column: FileSortColumn) => void; + onGridColumnCount?: (columnCount: number) => void; } type FileListRowExtra = { @@ -418,6 +417,67 @@ function FileListRow({ ); } +type FileGridCellExtra = FileListRowExtra & { + columnCount: number; + rowCount: number; + compactMode: boolean; + gap: number; +}; + +function FileGridCell({ + columnIndex, + rowIndex, + style, + ariaAttributes, + files, + columnCount, + rowCount, + compactMode, + gap, + selectedFiles, + focusedFile, + connectionId, + currentPath, + onSelect, + onNavigate, + onContextMenu, + onMove, +}: { + columnIndex: number; + rowIndex: number; + style: CSSProperties; + ariaAttributes: { 'aria-colindex': number; role: 'gridcell' }; +} & FileGridCellExtra) { + const index = rowIndex * columnCount + columnIndex; + const file = files[index]; + const cellStyle: CSSProperties = { + ...style, + paddingRight: columnIndex < columnCount - 1 ? gap : 0, + paddingBottom: rowIndex < rowCount - 1 ? gap : 0, + }; + if (!file) { + return
; + } + return ( +
+ +
+ ); +} + export function FileGrid({ files, selectedFiles, @@ -433,10 +493,21 @@ export function FileGrid({ sortColumn, sortDirection, onSort, + onGridColumnCount, }: FileGridProps) { const settings = useAppStore(state => state.settings); const compactMode = settings.compactMode; const listRef = useListRef(null); + const gridRef = useGridRef(null); + const gridColumnCountRef = useRef(1); + const lastReportedColumnCountRef = useRef(null); + + const reportColumnCount = useCallback((count: number) => { + gridColumnCountRef.current = count; + if (lastReportedColumnCountRef.current === count) return; + lastReportedColumnCountRef.current = count; + onGridColumnCount?.(count); + }, [onGridColumnCount]); const scrollFocusedListRow = useCallback(() => { if (viewMode !== 'list' || !focusedFile) return; @@ -445,15 +516,29 @@ export function FileGrid({ listRef.current?.scrollToRow({ index, align: 'smart', behavior: 'auto' }); }, [viewMode, focusedFile, files, listRef]); + const scrollFocusedGridCell = useCallback((columnCount: number) => { + if (viewMode !== 'grid' || !focusedFile || columnCount < 1) return; + const index = files.findIndex((f) => f.name === focusedFile); + if (index < 0) return; + gridRef.current?.scrollToCell({ + rowIndex: Math.floor(index / columnCount), + columnIndex: index % columnCount, + rowAlign: 'smart', + columnAlign: 'smart', + behavior: 'auto', + }); + }, [viewMode, focusedFile, files, gridRef]); + useEffect(() => { if (!focusedFile) return; if (viewMode === 'list') { scrollFocusedListRow(); return; } - const element = document.getElementById(`file-item-${focusedFile}`); - element?.scrollIntoView({ behavior: 'auto', block: 'nearest' }); - }, [focusedFile, viewMode, scrollFocusedListRow]); + if (viewMode === 'grid') { + scrollFocusedGridCell(gridColumnCountRef.current); + } + }, [focusedFile, viewMode, scrollFocusedListRow, scrollFocusedGridCell]); const listRowProps = useMemo( () => ({ @@ -600,39 +685,46 @@ export function FileGrid({
) : ( -
- - {files.map((file) => ( - - ))} - +
+ { + if (!height || !width) return null; + const metrics = computeFileGridMetrics(width, compactMode); + gridColumnCountRef.current = metrics.columnCount; + if (lastReportedColumnCountRef.current !== metrics.columnCount) { + queueMicrotask(() => reportColumnCount(metrics.columnCount)); + } + const rowCount = Math.max(1, Math.ceil(files.length / metrics.columnCount)); + return ( + + fileGridSlotSize(index, metrics.columnCount, metrics.columnWidth, metrics.gap) + } + rowCount={rowCount} + rowHeight={(index) => + fileGridSlotSize(index, rowCount, metrics.rowHeight, metrics.gap) + } + overscanCount={4} + style={{ height, width }} + onResize={() => { + reportColumnCount(metrics.columnCount); + scrollFocusedGridCell(metrics.columnCount); + }} + /> + ); + }} + />
)}
diff --git a/src/components/file-manager/fileGridLayout.ts b/src/components/file-manager/fileGridLayout.ts index 9186d575..14d3f26f 100644 --- a/src/components/file-manager/fileGridLayout.ts +++ b/src/components/file-manager/fileGridLayout.ts @@ -60,6 +60,12 @@ export function computeFileGridMetrics(containerWidth: number, compactMode: bool return { columnCount, columnWidth, rowHeight, gap }; } +/** react-window slot size: track plus inter-item gap, except the last row/column. */ +export function fileGridSlotSize(index: number, count: number, track: number, gap: number): number { + if (count <= 1 || index >= count - 1) return track; + return track + gap; +} + export function fileGridKeyboardIndex(row: number, col: number, columnCount: number): number { return row * Math.max(1, columnCount) + col; } diff --git a/tests/fileGridLayout.test.mjs b/tests/fileGridLayout.test.mjs index a52bd3b5..2d435bdd 100644 --- a/tests/fileGridLayout.test.mjs +++ b/tests/fileGridLayout.test.mjs @@ -3,6 +3,7 @@ import { computeFileGridMetrics, FILE_LIST_COLUMNS, fileGridKeyboardIndex, + fileGridSlotSize, sortFileEntries, } from '../.tmp-agent-tests/src/components/file-manager/fileGridLayout.js'; @@ -43,6 +44,14 @@ runTest('computeFileGridMetrics compact column count', () => { assert.equal(m.gap, 8); assert.equal(m.columnCount, 3); assert.ok(m.columnWidth > 100); + const filled = m.columnWidth * m.columnCount + m.gap * (m.columnCount - 1); + assert.ok(Math.abs(filled - 332) < 0.001); +}); + +runTest('fileGridSlotSize adds gap except on the last track', () => { + assert.equal(fileGridSlotSize(0, 3, 100, 8), 108); + assert.equal(fileGridSlotSize(2, 3, 100, 8), 100); + assert.equal(fileGridSlotSize(0, 1, 120, 16), 120); }); runTest('fileGridKeyboardIndex is row-major', () => { From ae6b9fa640b2bc312db3bf8d53ca2cdcb5479fc9 Mon Sep 17 00:00:00 2001 From: Gajendra sahu Date: Mon, 7 Sep 2026 04:11:17 +0530 Subject: [PATCH 06/17] fix(files): reset Files scroll on navigate and gutter the icon grid --- src/components/FileManager.tsx | 28 ++++----- src/components/file-manager/FileGrid.tsx | 61 +++++++++++++++---- src/components/file-manager/fileGridLayout.ts | 21 +++++++ tests/fileGridLayout.test.mjs | 19 ++++++ 4 files changed, 104 insertions(+), 25 deletions(-) diff --git a/src/components/FileManager.tsx b/src/components/FileManager.tsx index ea712684..394b9e20 100644 --- a/src/components/FileManager.tsx +++ b/src/components/FileManager.tsx @@ -182,6 +182,12 @@ export const FileManager = memo(function FileManager({ const [sortColumn, setSortColumn] = useState('name'); const [sortDirection, setSortDirection] = useState('asc'); const [gridColumnCount, setGridColumnCount] = useState(1); + const [selectionPath, setSelectionPath] = useState(currentPath); + if (selectionPath !== currentPath) { + setSelectionPath(currentPath); + setSelectedFiles([]); + setFocusedFile(null); + } const [searchTerm, setSearchTerm] = useState(''); const [isSearchOpen, setIsSearchOpen] = useState(false); const [isEditingPath, setIsEditingPath] = useState(false); @@ -438,7 +444,7 @@ export const FileManager = memo(function FileManager({ await executeFileOperations(ops); }, [activeConnectionId, clipboard, currentPath, executeFileOperations]); - const handleMoveFiles = async (moves: { source: string; target: string; sourceConnectionId?: string }[]) => { + const handleMoveFiles = useCallback(async (moves: { source: string; target: string; sourceConnectionId?: string }[]) => { if (!activeConnectionId || moves.length === 0) return; const ops = moves.map(m => ({ @@ -453,7 +459,7 @@ export const FileManager = memo(function FileManager({ const targetDir = firstTarget ? firstTarget.substring(0, firstTarget.lastIndexOf('/')) || '/' : undefined; await executeFileOperations(ops, targetDir); - }; + }, [activeConnectionId, executeFileOperations]); const resolveConflict = async (action: ConflictAction, applyToAll = false) => { if (!currentConflict || !activeConnectionId || isProcessing) return; @@ -835,7 +841,7 @@ export const FileManager = memo(function FileManager({ } }, [activeConnectionId, editingFile, currentPath, handleConnectionError, showToast]); - const handleSelect = (filename: string, multi: boolean) => { + const handleSelect = useCallback((filename: string, multi: boolean) => { if (!filename) { setSelectedFiles([]); return; @@ -847,23 +853,17 @@ export const FileManager = memo(function FileManager({ setSelectedFiles([filename]); setFocusedFile(filename); } - }; + }, []); - const handleContextMenu = (e: React.MouseEvent, file?: FileEntry) => { + const handleContextMenu = useCallback((e: React.MouseEvent, file?: FileEntry) => { e.preventDefault(); - e.stopPropagation(); // Just in case + e.stopPropagation(); setContextMenu({ x: e.clientX, y: e.clientY, file: file || null }); - // Only select if it's a file context menu if (file) { - if (!selectedFiles.includes(file.name)) { - setSelectedFiles([file.name]); - } - } else { - // Background context menu - maybe clear selection? - // setSelectedFiles([]); // Optional: clear selection on background right-click + setSelectedFiles((prev) => (prev.includes(file.name) ? prev : [file.name])); } - }; + }, []); const showHiddenFiles = settings.fileManager.showHiddenFiles; const filteredFiles = useMemo( diff --git a/src/components/file-manager/FileGrid.tsx b/src/components/file-manager/FileGrid.tsx index 43ffadfd..60b2ed98 100644 --- a/src/components/file-manager/FileGrid.tsx +++ b/src/components/file-manager/FileGrid.tsx @@ -18,7 +18,7 @@ import { Tooltip } from '../ui/Tooltip'; import { getScrollbarSize, Grid, List, useGridRef, useListRef } from 'react-window'; import { AutoSizer } from 'react-virtualized-auto-sizer'; import { - computeFileGridMetrics, + computeFileGridMetricsForViewport, FILE_LIST_COLUMNS, FILE_LIST_ROW_HEIGHT, fileGridSlotSize, @@ -501,6 +501,13 @@ export function FileGrid({ const gridRef = useGridRef(null); const gridColumnCountRef = useRef(1); const lastReportedColumnCountRef = useRef(null); + const gridLayoutRef = useRef({ + columnCount: 1, + columnWidth: 0, + rowHeight: 0, + gap: 0, + rowCount: 1, + }); const reportColumnCount = useCallback((count: number) => { gridColumnCountRef.current = count; @@ -509,6 +516,16 @@ export function FileGrid({ onGridColumnCount?.(count); }, [onGridColumnCount]); + const getGridColumnWidth = useCallback((index: number) => { + const layout = gridLayoutRef.current; + return fileGridSlotSize(index, layout.columnCount, layout.columnWidth, layout.gap); + }, []); + + const getGridRowHeight = useCallback((index: number) => { + const layout = gridLayoutRef.current; + return fileGridSlotSize(index, layout.rowCount, layout.rowHeight, layout.gap); + }, []); + const scrollFocusedListRow = useCallback(() => { if (viewMode !== 'list' || !focusedFile) return; const index = files.findIndex((f) => f.name === focusedFile); @@ -529,6 +546,16 @@ export function FileGrid({ }); }, [viewMode, focusedFile, files, gridRef]); + useEffect(() => { + const listEl = listRef.current?.element; + if (listEl) listEl.scrollTop = 0; + const gridEl = gridRef.current?.element; + if (gridEl) { + gridEl.scrollTop = 0; + gridEl.scrollLeft = 0; + } + }, [currentPath, listRef, gridRef]); + useEffect(() => { if (!focusedFile) return; if (viewMode === 'list') { @@ -538,7 +565,7 @@ export function FileGrid({ if (viewMode === 'grid') { scrollFocusedGridCell(gridColumnCountRef.current); } - }, [focusedFile, viewMode, scrollFocusedListRow, scrollFocusedGridCell]); + }, [focusedFile, viewMode, compactMode, scrollFocusedListRow, scrollFocusedGridCell]); const listRowProps = useMemo( () => ({ @@ -690,12 +717,28 @@ export function FileGrid({ style={{ height: '100%', width: '100%' }} renderProp={({ height, width }) => { if (!height || !width) return null; - const metrics = computeFileGridMetrics(width, compactMode); + const metrics = computeFileGridMetricsForViewport( + width, + height, + files.length, + compactMode, + getScrollbarSize(), + ); + const rowCount = Math.max(1, Math.ceil(files.length / metrics.columnCount)); + gridLayoutRef.current = { + columnCount: metrics.columnCount, + columnWidth: metrics.columnWidth, + rowHeight: metrics.rowHeight, + gap: metrics.gap, + rowCount, + }; gridColumnCountRef.current = metrics.columnCount; if (lastReportedColumnCountRef.current !== metrics.columnCount) { - queueMicrotask(() => reportColumnCount(metrics.columnCount)); + queueMicrotask(() => { + reportColumnCount(metrics.columnCount); + scrollFocusedGridCell(metrics.columnCount); + }); } - const rowCount = Math.max(1, Math.ceil(files.length / metrics.columnCount)); return ( - fileGridSlotSize(index, metrics.columnCount, metrics.columnWidth, metrics.gap) - } + columnWidth={getGridColumnWidth} rowCount={rowCount} - rowHeight={(index) => - fileGridSlotSize(index, rowCount, metrics.rowHeight, metrics.gap) - } + rowHeight={getGridRowHeight} overscanCount={4} style={{ height, width }} onResize={() => { diff --git a/src/components/file-manager/fileGridLayout.ts b/src/components/file-manager/fileGridLayout.ts index 14d3f26f..8ca179af 100644 --- a/src/components/file-manager/fileGridLayout.ts +++ b/src/components/file-manager/fileGridLayout.ts @@ -66,6 +66,27 @@ export function fileGridSlotSize(index: number, count: number, track: number, ga return track + gap; } +export function fileGridContentHeight(fileCount: number, metrics: FileGridMetrics): number { + if (fileCount <= 0) return 0; + const rowCount = Math.max(1, Math.ceil(fileCount / metrics.columnCount)); + return rowCount * metrics.rowHeight + Math.max(0, rowCount - 1) * metrics.gap; +} + +/** Shrink the layout width when a classic vertical scrollbar will appear. */ +export function computeFileGridMetricsForViewport( + width: number, + height: number, + fileCount: number, + compactMode: boolean, + scrollbarSize: number, +): FileGridMetrics { + const full = computeFileGridMetrics(width, compactMode); + if (fileCount <= 0 || scrollbarSize <= 0 || fileGridContentHeight(fileCount, full) <= height) { + return full; + } + return computeFileGridMetrics(Math.max(0, width - scrollbarSize), compactMode); +} + export function fileGridKeyboardIndex(row: number, col: number, columnCount: number): number { return row * Math.max(1, columnCount) + col; } diff --git a/tests/fileGridLayout.test.mjs b/tests/fileGridLayout.test.mjs index 2d435bdd..950c2270 100644 --- a/tests/fileGridLayout.test.mjs +++ b/tests/fileGridLayout.test.mjs @@ -1,7 +1,9 @@ import assert from 'node:assert/strict'; import { computeFileGridMetrics, + computeFileGridMetricsForViewport, FILE_LIST_COLUMNS, + fileGridContentHeight, fileGridKeyboardIndex, fileGridSlotSize, sortFileEntries, @@ -54,6 +56,23 @@ runTest('fileGridSlotSize adds gap except on the last track', () => { assert.equal(fileGridSlotSize(0, 1, 120, 16), 120); }); +runTest('computeFileGridMetricsForViewport keeps full width when content fits', () => { + const full = computeFileGridMetrics(500, false); + const fitted = computeFileGridMetricsForViewport(500, 400, 2, false, 17); + assert.equal(fitted.columnCount, full.columnCount); + assert.equal(fitted.columnWidth, full.columnWidth); + assert.ok(fileGridContentHeight(2, fitted) <= 400); +}); + +runTest('computeFileGridMetricsForViewport subtracts scrollbar when rows overflow', () => { + const full = computeFileGridMetrics(500, false); + const overflowed = computeFileGridMetricsForViewport(500, 200, 40, false, 17); + const guttered = computeFileGridMetrics(500 - 17, false); + assert.ok(fileGridContentHeight(40, full) > 200); + assert.equal(overflowed.columnCount, guttered.columnCount); + assert.equal(overflowed.columnWidth, guttered.columnWidth); +}); + runTest('fileGridKeyboardIndex is row-major', () => { assert.equal(fileGridKeyboardIndex(1, 2, 4), 6); }); From ecdaefcb689a5fbe56681c3a593e6246a9eed85c Mon Sep 17 00:00:00 2001 From: Gajendra sahu Date: Mon, 7 Sep 2026 04:23:21 +0530 Subject: [PATCH 07/17] fix(files): rebuild icon-grid slots when cell size changes --- src/components/file-manager/FileGrid.tsx | 32 ++++++++---------------- 1 file changed, 10 insertions(+), 22 deletions(-) diff --git a/src/components/file-manager/FileGrid.tsx b/src/components/file-manager/FileGrid.tsx index 60b2ed98..cd69e9d6 100644 --- a/src/components/file-manager/FileGrid.tsx +++ b/src/components/file-manager/FileGrid.tsx @@ -6,7 +6,7 @@ import type React from 'react'; import { cn, formatBytes, formatDate } from '../../lib/utils'; import type { FileEntry } from './types'; import { useAppStore } from '../../store/useAppStore'; -import { useState, useMemo, useEffect, useCallback, useRef, memo, type CSSProperties } from 'react'; +import { useState, useMemo, useEffect, useLayoutEffect, useCallback, useRef, memo, type CSSProperties } from 'react'; import { ArrowDown, ArrowUp, ArrowUpDown } from 'lucide-react'; import { convertFileSrc } from '@tauri-apps/api/core'; @@ -420,6 +420,8 @@ function FileListRow({ type FileGridCellExtra = FileListRowExtra & { columnCount: number; rowCount: number; + columnWidth: number; + rowHeight: number; compactMode: boolean; gap: number; }; @@ -501,13 +503,6 @@ export function FileGrid({ const gridRef = useGridRef(null); const gridColumnCountRef = useRef(1); const lastReportedColumnCountRef = useRef(null); - const gridLayoutRef = useRef({ - columnCount: 1, - columnWidth: 0, - rowHeight: 0, - gap: 0, - rowCount: 1, - }); const reportColumnCount = useCallback((count: number) => { gridColumnCountRef.current = count; @@ -516,14 +511,12 @@ export function FileGrid({ onGridColumnCount?.(count); }, [onGridColumnCount]); - const getGridColumnWidth = useCallback((index: number) => { - const layout = gridLayoutRef.current; - return fileGridSlotSize(index, layout.columnCount, layout.columnWidth, layout.gap); + const getGridColumnWidth = useCallback((index: number, cellProps: FileGridCellExtra) => { + return fileGridSlotSize(index, cellProps.columnCount, cellProps.columnWidth, cellProps.gap); }, []); - const getGridRowHeight = useCallback((index: number) => { - const layout = gridLayoutRef.current; - return fileGridSlotSize(index, layout.rowCount, layout.rowHeight, layout.gap); + const getGridRowHeight = useCallback((index: number, cellProps: FileGridCellExtra) => { + return fileGridSlotSize(index, cellProps.rowCount, cellProps.rowHeight, cellProps.gap); }, []); const scrollFocusedListRow = useCallback(() => { @@ -546,7 +539,7 @@ export function FileGrid({ }); }, [viewMode, focusedFile, files, gridRef]); - useEffect(() => { + useLayoutEffect(() => { const listEl = listRef.current?.element; if (listEl) listEl.scrollTop = 0; const gridEl = gridRef.current?.element; @@ -725,13 +718,6 @@ export function FileGrid({ getScrollbarSize(), ); const rowCount = Math.max(1, Math.ceil(files.length / metrics.columnCount)); - gridLayoutRef.current = { - columnCount: metrics.columnCount, - columnWidth: metrics.columnWidth, - rowHeight: metrics.rowHeight, - gap: metrics.gap, - rowCount, - }; gridColumnCountRef.current = metrics.columnCount; if (lastReportedColumnCountRef.current !== metrics.columnCount) { queueMicrotask(() => { @@ -747,6 +733,8 @@ export function FileGrid({ ...listRowProps, columnCount: metrics.columnCount, rowCount, + columnWidth: metrics.columnWidth, + rowHeight: metrics.rowHeight, compactMode, gap: metrics.gap, }} From d62a980ceda19ad0a5d8fa2b85cd77fad0fe93fa Mon Sep 17 00:00:00 2001 From: Gajendra sahu Date: Mon, 7 Sep 2026 04:29:20 +0530 Subject: [PATCH 08/17] build: enable thin LTO for release Rust --- CONTRIBUTING.md | 1 + src-tauri/Cargo.toml | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7c6fe772..d0073d9e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -42,6 +42,7 @@ Thank you for your interest in contributing to Zync. This document provides guid npm run type-check npm run tauri build ``` + Release Rust uses thin LTO (`src-tauri/Cargo.toml` `[profile.release]`). `tauri dev` is unchanged. 4. Commit with clear, descriptive messages: ```bash diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index a9e57c32..18e197ce 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -63,3 +63,9 @@ tauri-plugin-single-instance = { version = "2", default-features = false } [target.'cfg(target_os = "windows")'.dependencies] winreg = "0.56" + +# Thin LTO for the PTY/SSH path without fat-LTO link times. codegen-units 8 is +# tighter than Cargo's default 16 and cheaper than 1 on Windows/macOS release CI. +[profile.release] +lto = "thin" +codegen-units = 8 From e5c22546165b04fb6f72fa01b7e4c4aeca2640f6 Mon Sep 17 00:00:00 2001 From: Gajendra sahu Date: Tue, 8 Sep 2026 18:50:00 +0530 Subject: [PATCH 09/17] fix(files): snappier File Manager, skip placeholder /, unclip icon grid --- CHANGELOG.md | 5 +- src/components/FileManager.tsx | 65 +++++-- src/components/file-manager/FileGrid.tsx | 180 +++++++----------- src/components/file-manager/FileToolbar.tsx | 2 +- src/components/layout/tabDock/index.ts | 1 + src/components/layout/tabDock/openHere.ts | 1 + .../layout/tabDock/openHerePaths.ts | 10 + src/components/ui/DynamicIcon.tsx | 2 +- tests/openHerePaths.test.mjs | 10 +- 9 files changed, 145 insertions(+), 131 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0175b2a4..252563bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ All notable changes to Zync are documented in this file. The format is based on ### Changed - **Terminal output flush:** First PTY bytes after quiet go to the terminal immediately (typing echo no longer waits up to 8 ms). Busy output merges for a 12 ms burst, or sooner at 128 KiB. The live Channel frame is still generation + raw bytes. Debug: `localStorage.zyncTerminalIoDebug = '1'`. - **Terminal sniffers:** Secret/cwd helpers scan at most the last 4 KiB of large PTY frames so `cat` does not regex the whole dump. The terminal still receives every byte. -- **File list and grid windowing:** File Manager list and icon grid only paint visible cells. Sort lives on FileManager so keyboard order matches the painted order. Grid arrows use the live column count instead of scanning the DOM. +- **File list and grid windowing:** File Manager list and icon grid only paint visible cells. Sort lives on FileManager so keyboard order matches the painted order. Grid arrows use the live column count instead of scanning the DOM. Folder loads no longer grayscale/scale the listing; recycled cells skip Radix tooltips and icon fade-in so scroll and clicks stay immediate. Icon-grid tiles use a fixed row height so the first row stays on screen and selected folders are normal-sized cards. ### Added - **Smooth splits**: New panes grow in (about 280ms) instead of jumping to 50/50 — keyboard split, split icons, drag-to-dock, and Open in split / Open here. A quiet accent veil marks the incoming pane. Drag-to-split preview eases between edges. Divider drag stays immediate (no laggy flex transition). `prefers-reduced-motion` skips the intro. PTY resize waits until the intro ends, same as a divider drag. ([2d3b7c8]) @@ -18,7 +18,8 @@ All notable changes to Zync are documented in this file. The format is based on - **Open here in split**: Terminal **Open File Manager Here** is one row with **In a new tab** / **Left** / **Right** / **Bottom**. Files **Open Terminal Here** and **Follow with Terminal** use the same submenu. Split docks beside the current pane (not a stray new tab). ([2d3b7c8]) ### Fixed -- **Files opening at `/`**: File Manager no longer treats `/` as home. Open File Manager Here uses the shell cwd (or a real `fs_cwd`), not the pre-connect placeholder. First open and reconnect do the same. On Windows, local home uses `%USERPROFILE%` instead of unset `HOME`. ([2d3b7c8]) +- **Files opening at `/`**: File Manager no longer treats `/` as home. Open File Manager Here uses the shell cwd (or a real `fs_cwd`), not the pre-connect placeholder. First open ignores a shell cwd of `/` and waits for a real home instead of listing root. Reconnect keeps `/` when that listing already has files (hash button). Switching hosts clears selection and the open editor. On Windows, local home uses `%USERPROFILE%` instead of unset `HOME`. ([2d3b7c8]) + - **Drag shell tab onto itself**: Dropping the current shell tab on its own split is a no-op again and restores Files / Dashboard / Snippets if the drag started from that overlay. ([2d3b7c8]) - **Open here after a tab switch**: Open File Manager Here / Open Terminal Here keep the tab that opened the menu, instead of applying to whichever tab is active after the await. ([2d3b7c8]) - **Files overlay vs Files pane**: The keep-alive overlay FileManager no longer steals focus or drives Follow-terminal while a Files pane is showing (and the reverse). ([2d3b7c8]) diff --git a/src/components/FileManager.tsx b/src/components/FileManager.tsx index 394b9e20..c1f91b2e 100644 --- a/src/components/FileManager.tsx +++ b/src/components/FileManager.tsx @@ -41,10 +41,12 @@ import { clearEditorOverlayOpen, markEditorOverlayOpen } from './editor/overlayS import { TerminalDisconnectedView } from './terminal/TerminalDisconnectedView'; import { isFeaturePaneFocused, layoutForTerm } from '../lib/paneLayout'; import type { AppStore } from '../store/useAppStore'; -import { canSplitBesideFiles, openHerePlacementItems, openTerminalHere, pickFilesOpenPath } from './layout/tabDock'; +import { canSplitBesideFiles, isUnresolvedFilesPath, openHerePlacementItems, openTerminalHere, pickFilesOpenPath } from './layout/tabDock'; export type FileManagerSurface = 'overlay' | 'pane'; +const EMPTY_FILES: FileEntry[] = []; + function isFileManagerActive( state: AppStore, connectionId: string | undefined, @@ -143,11 +145,17 @@ export const FileManager = memo(function FileManager({ }); const isFilesSurfaceActive = useAppStore((state) => isFileManagerActive(state, connectionId, surface)); - // Zustand Store Hooks - const filesMap = useAppStore(state => state.files); - const currentPathMap = useAppStore(state => state.currentPath); - const loadingMap = useAppStore(state => state.isLoading); - const errorMap = useAppStore(state => state.error); + // Zustand Store Hooks — subscribe to this connection only so other hosts do not re-paint Files. + const files = useAppStore(state => ( + activeConnectionId ? (state.files[activeConnectionId] ?? EMPTY_FILES) : EMPTY_FILES + )); + const currentPath = useAppStore(state => ( + activeConnectionId ? (state.currentPath[activeConnectionId] ?? '') : '' + )); + const loading = useAppStore(state => Boolean(activeConnectionId && state.isLoading[activeConnectionId])); + const currentError = useAppStore(state => ( + activeConnectionId ? (state.error[activeConnectionId] ?? null) : null + )); const loadFiles = useAppStore(state => state.loadFiles); const refreshFiles = useAppStore(state => state.refreshFiles); const createFolder = useAppStore(state => state.createFolder); @@ -164,17 +172,20 @@ export const FileManager = memo(function FileManager({ const updateFileManagerSettings = useAppStore(state => state.updateFileManagerSettings); // const downloadAction = useAppStore(state => state.downloadFiles); // Not implemented fully yet - // Derived State - const files = activeConnectionId ? (filesMap[activeConnectionId] || []) : []; - const currentPath = activeConnectionId ? (currentPathMap[activeConnectionId] || '') : ''; - const loading = activeConnectionId ? (loadingMap[activeConnectionId] || false) : false; - const currentError = activeConnectionId ? (errorMap[activeConnectionId] || null) : null; const activeHistoryIndex = useAppStore(state => ( activeConnectionId ? (state.historyIndex[activeConnectionId] || 0) : 0 )); const activeHistoryLength = useAppStore(state => ( activeConnectionId ? (state.history[activeConnectionId]?.length || 0) : 0 )); + const filesOpenHint = useAppStore((state) => { + if (!activeConnectionId) return ''; + const activeId = state.activeTerminalIds[activeConnectionId]; + const tabs = state.terminals[activeConnectionId] || []; + const term = tabs.find((tab) => tab.id === activeId) ?? tabs.find((tab) => tab.tabVisible !== false); + const home = state.connections.find((item) => item.id === activeConnectionId)?.homePath; + return `${term?.lastKnownCwd || ''}|${term?.initialPath || ''}|${home || ''}`; + }); const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid'); const [selectedFiles, setSelectedFiles] = useState([]); @@ -196,6 +207,15 @@ export const FileManager = memo(function FileManager({ const [editingFile, setEditingFile] = useState(null); const [editorContent, setEditorContent] = useState(''); const [editorProviderOverride, setEditorProviderOverride] = useState(null); + const [boundConnectionId, setBoundConnectionId] = useState(activeConnectionId); + if (boundConnectionId !== activeConnectionId) { + setBoundConnectionId(activeConnectionId); + setSelectedFiles([]); + setFocusedFile(null); + setEditingFile(null); + setEditorContent(''); + setEditorProviderOverride(null); + } // Modal States const [isNewFolderModalOpen, setIsNewFolderModalOpen] = useState(false); @@ -695,14 +715,18 @@ export const FileManager = memo(function FileManager({ initialPath: term?.initialPath, homePath: connection?.homePath, }); + // `/` from lastKnownCwd / homePath is the connect placeholder, not home. + const fromPick = isUnresolvedFilesPath(picked) ? '' : picked; + const listingIsPlaceholder = isUnresolvedFilesPath(currentPath) && files.length === 0; - if (!currentPath) { + if (listingIsPlaceholder) { try { - const cwd = picked || await window.ipcRenderer.invoke('fs_cwd', { + const cwd = fromPick || await window.ipcRenderer.invoke('fs_cwd', { connectionId: activeConnectionId, }); const path = typeof cwd === 'string' ? cwd.trim() : ''; - if (!path) return; + // Do not paint `/` as home. Retry when lastKnownCwd/homePath updates. + if (!path || isUnresolvedFilesPath(path)) return; loadFiles(activeConnectionId, path); const termId = ensureTerminal(activeConnectionId, path); @@ -718,12 +742,12 @@ export const FileManager = memo(function FileManager({ return; } console.error('Failed to get home dir:', error); - if (picked) loadFiles(activeConnectionId, picked); + if (fromPick) loadFiles(activeConnectionId, fromPick); } } else if (files.length === 0) { loadFiles(activeConnectionId, currentPath); } - }, [activeConnectionId, isConnected, currentPath, files.length, loadFiles, ensureTerminal]); + }, [activeConnectionId, isConnected, currentPath, files.length, filesOpenHint, loadFiles, ensureTerminal]); const handleReconnect = useCallback(async () => { if (!activeConnectionId || isLocal) return; @@ -731,11 +755,14 @@ export const FileManager = memo(function FileManager({ await connect(activeConnectionId); const reconnected = useAppStore.getState().connections.find((c) => c.id === activeConnectionId) as (Connection & { error?: string }) | undefined; if (reconnected?.status === 'connected') { - let nextPath = currentPath; + const listingIsPlaceholder = isUnresolvedFilesPath(currentPath) && files.length === 0; + let nextPath = listingIsPlaceholder ? '' : currentPath; if (!nextPath) { try { const cwd = await window.ipcRenderer.invoke('fs_cwd', { connectionId: activeConnectionId }); - if (typeof cwd === 'string' && cwd.trim()) nextPath = cwd.trim(); + if (typeof cwd === 'string' && cwd.trim() && !isUnresolvedFilesPath(cwd)) { + nextPath = cwd.trim(); + } } catch { // Keep empty; listing `/` as a silent fallback hides the real home. } @@ -748,7 +775,7 @@ export const FileManager = memo(function FileManager({ const message = error instanceof Error ? error.message : String(error); showToast('error', `Failed to reconnect: ${message}`); } - }, [activeConnectionId, connect, currentPath, isLocal, loadFiles, showToast]); + }, [activeConnectionId, connect, currentPath, files.length, isLocal, loadFiles, showToast]); useEffect(() => { if (activeConnectionId && isConnected) { diff --git a/src/components/file-manager/FileGrid.tsx b/src/components/file-manager/FileGrid.tsx index cd69e9d6..3f4d5e20 100644 --- a/src/components/file-manager/FileGrid.tsx +++ b/src/components/file-manager/FileGrid.tsx @@ -6,22 +6,18 @@ import type React from 'react'; import { cn, formatBytes, formatDate } from '../../lib/utils'; import type { FileEntry } from './types'; import { useAppStore } from '../../store/useAppStore'; -import { useState, useMemo, useEffect, useLayoutEffect, useCallback, useRef, memo, type CSSProperties } from 'react'; +import { useMemo, useEffect, useLayoutEffect, useCallback, useRef, memo, type CSSProperties } from 'react'; import { ArrowDown, ArrowUp, ArrowUpDown } from 'lucide-react'; -import { convertFileSrc } from '@tauri-apps/api/core'; - import { setCurrentDragSource } from '../../lib/dragDrop'; import { motion, AnimatePresence } from 'framer-motion'; import { forwardRef } from 'react'; import { buildDragData, startInternalDrag, validateAndBuildMoves } from './dragDropUtils'; -import { Tooltip } from '../ui/Tooltip'; import { getScrollbarSize, Grid, List, useGridRef, useListRef } from 'react-window'; import { AutoSizer } from 'react-virtualized-auto-sizer'; import { computeFileGridMetricsForViewport, FILE_LIST_COLUMNS, FILE_LIST_ROW_HEIGHT, - fileGridSlotSize, type FileSortColumn, type FileSortDirection, } from './fileGridLayout'; @@ -44,10 +40,10 @@ const FileIcon = memo(function FileIcon({ file, size }: { file: FileEntry; size: // Use the new DynamicIcon engine for files return ( - ); }); @@ -58,10 +54,10 @@ const FileGridItem = memo(forwardRef string[]; onSelect: (name: string, multi: boolean) => void; onNavigate: (name: string) => void; onContextMenu: (e: React.MouseEvent, file?: FileEntry) => void; @@ -71,26 +67,16 @@ const FileGridItem = memo(forwardRef { const isFolder = file.type === 'd'; - const [imageError, setImageError] = useState(false); - - const isImage = useMemo(() => { - return false; // DISABLED: Performance issues reported by user - }, [file.name, isFolder]); - - // Reset error state if file changes - useEffect(() => { - setImageError(false); - }, [file.path]); return (
{ if (!connectionId || !currentPath) return; + const selectedFiles = getSelectedFiles(); const dragData = buildDragData(file, isSelected, selectedFiles, connectionId, currentPath); const count = isSelected && selectedFiles.length > 0 ? selectedFiles.length : 1; startInternalDrag(e, dragData, isFolder, count); @@ -158,11 +145,10 @@ const FileGridItem = memo(forwardRef
- {isImage && !imageError && viewMode === 'grid' ? ( - {file.name} setImageError(true)} - loading="lazy" - /> - ) : ( - - )} +
-
- -
- {file.name} -
-
+
+
+ {file.name} +
{viewMode === 'grid' && !compactMode && ( -
+
{formatBytes(file.size)}
)} @@ -227,10 +199,10 @@ const FileGridItem = memo(forwardRef string[]; onSelect: (name: string, multi: boolean) => void; onNavigate: (name: string) => void; onContextMenu: (e: React.MouseEvent, file?: FileEntry) => void; @@ -238,10 +210,10 @@ const FileListItem = memo(forwardRef(({ file, isSelected, - selectedFiles, isFocused, connectionId, currentPath, + getSelectedFiles, onSelect, onNavigate, onContextMenu, @@ -257,6 +229,7 @@ const FileListItem = memo(forwardRef { if (!connectionId || !currentPath) return; + const selectedFiles = getSelectedFiles(); const dragData = buildDragData(file, isSelected, selectedFiles, connectionId, currentPath); const count = isSelected && selectedFiles.length > 0 ? selectedFiles.length : 1; startInternalDrag(e, dragData, isFolder, count); @@ -326,11 +299,12 @@ const FileListItem = memo(forwardRef
- - - {file.name} - - + + {file.name} +
@@ -369,10 +343,11 @@ interface FileGridProps { type FileListRowExtra = { files: FileEntry[]; - selectedFiles: string[]; + selectedSet: Set; focusedFile?: string | null; connectionId?: string; currentPath?: string; + getSelectedFiles: () => string[]; onSelect: (name: string, multi: boolean) => void; onNavigate: (name: string) => void; onContextMenu: (e: React.MouseEvent, file?: FileEntry) => void; @@ -384,10 +359,11 @@ function FileListRow({ style, ariaAttributes, files, - selectedFiles, + selectedSet, focusedFile, connectionId, currentPath, + getSelectedFiles, onSelect, onNavigate, onContextMenu, @@ -403,11 +379,11 @@ function FileListRow({
; + return
; } return ( -
+
state.settings); - const compactMode = settings.compactMode; + const compactMode = useAppStore(state => state.settings.compactMode); const listRef = useListRef(null); const gridRef = useGridRef(null); const gridColumnCountRef = useRef(1); const lastReportedColumnCountRef = useRef(null); + const selectedFilesRef = useRef(selectedFiles); + selectedFilesRef.current = selectedFiles; + const getSelectedFiles = useCallback(() => selectedFilesRef.current, []); + const selectedSet = useMemo(() => new Set(selectedFiles), [selectedFiles]); + const scrollbarSize = useMemo(() => getScrollbarSize(), []); const reportColumnCount = useCallback((count: number) => { gridColumnCountRef.current = count; @@ -511,14 +481,6 @@ export function FileGrid({ onGridColumnCount?.(count); }, [onGridColumnCount]); - const getGridColumnWidth = useCallback((index: number, cellProps: FileGridCellExtra) => { - return fileGridSlotSize(index, cellProps.columnCount, cellProps.columnWidth, cellProps.gap); - }, []); - - const getGridRowHeight = useCallback((index: number, cellProps: FileGridCellExtra) => { - return fileGridSlotSize(index, cellProps.rowCount, cellProps.rowHeight, cellProps.gap); - }, []); - const scrollFocusedListRow = useCallback(() => { if (viewMode !== 'list' || !focusedFile) return; const index = files.findIndex((f) => f.name === focusedFile); @@ -563,10 +525,11 @@ export function FileGrid({ const listRowProps = useMemo( () => ({ files, - selectedFiles, + selectedSet, focusedFile, connectionId, currentPath, + getSelectedFiles, onSelect, onNavigate, onContextMenu, @@ -574,10 +537,11 @@ export function FileGrid({ }), [ files, - selectedFiles, + selectedSet, focusedFile, connectionId, currentPath, + getSelectedFiles, onSelect, onNavigate, onContextMenu, @@ -645,8 +609,8 @@ export function FileGrid({
{files.length === 0 ? (
{(['name', 'size', 'type', 'modified'] as const).map((column) => ( @@ -695,7 +659,7 @@ export function FileGrid({ rowHeight={FILE_LIST_ROW_HEIGHT} rowComponent={FileListRow} rowProps={listRowProps} - overscanCount={8} + overscanCount={4} style={{ height, width }} onResize={() => scrollFocusedListRow()} /> @@ -715,14 +679,14 @@ export function FileGrid({ height, files.length, compactMode, - getScrollbarSize(), + scrollbarSize, ); const rowCount = Math.max(1, Math.ceil(files.length / metrics.columnCount)); gridColumnCountRef.current = metrics.columnCount; if (lastReportedColumnCountRef.current !== metrics.columnCount) { queueMicrotask(() => { reportColumnCount(metrics.columnCount); - scrollFocusedGridCell(metrics.columnCount); + if (focusedFile) scrollFocusedGridCell(metrics.columnCount); }); } return ( @@ -732,20 +696,22 @@ export function FileGrid({ cellProps={{ ...listRowProps, columnCount: metrics.columnCount, - rowCount, - columnWidth: metrics.columnWidth, - rowHeight: metrics.rowHeight, compactMode, - gap: metrics.gap, }} columnCount={metrics.columnCount} - columnWidth={getGridColumnWidth} + columnWidth={metrics.columnWidth} rowCount={rowCount} - rowHeight={getGridRowHeight} - overscanCount={4} + rowHeight={metrics.rowHeight} + overscanCount={2} style={{ height, width }} onResize={() => { reportColumnCount(metrics.columnCount); + const el = gridRef.current?.element; + if (!focusedFile && el) { + el.scrollTop = 0; + el.scrollLeft = 0; + return; + } scrollFocusedGridCell(metrics.columnCount); }} /> @@ -757,5 +723,5 @@ export function FileGrid({
); -} +}); diff --git a/src/components/file-manager/FileToolbar.tsx b/src/components/file-manager/FileToolbar.tsx index 6e37b0c6..56c9cdac 100644 --- a/src/components/file-manager/FileToolbar.tsx +++ b/src/components/file-manager/FileToolbar.tsx @@ -127,7 +127,7 @@ export function FileToolbar({ return (
{/* Minimalist Address Bar */} diff --git a/src/components/layout/tabDock/index.ts b/src/components/layout/tabDock/index.ts index 75ec0c7d..ebcf14f9 100644 --- a/src/components/layout/tabDock/index.ts +++ b/src/components/layout/tabDock/index.ts @@ -9,6 +9,7 @@ export { canSplitBesideFiles, filesAlreadyInSplit, directoryFromFileLocation, + isUnresolvedFilesPath, parentDirectory, pickFilesOpenPath, type OpenHereFile, diff --git a/src/components/layout/tabDock/openHere.ts b/src/components/layout/tabDock/openHere.ts index 861769d0..1f8f4dd1 100644 --- a/src/components/layout/tabDock/openHere.ts +++ b/src/components/layout/tabDock/openHere.ts @@ -11,6 +11,7 @@ import { directoryFromFileLocation, pickFilesOpenPath, type OpenHereFile } from export { directoryFromFileLocation, + isUnresolvedFilesPath, parentDirectory, pickFilesOpenPath, type OpenHereFile, diff --git a/src/components/layout/tabDock/openHerePaths.ts b/src/components/layout/tabDock/openHerePaths.ts index 17b9062b..8e815657 100644 --- a/src/components/layout/tabDock/openHerePaths.ts +++ b/src/components/layout/tabDock/openHerePaths.ts @@ -8,6 +8,16 @@ function trimmed(value: string | null | undefined): string { return (value || '').trim(); } +/** + * Empty or `/` — connect/SFTP placeholder, not a resolved Files home. + * A user who actually listed `/` (hash button) has entries loaded; callers + * should only treat `/` as unresolved when the listing is still empty. + */ +export function isUnresolvedFilesPath(path: string | null | undefined): boolean { + const value = trimmed(path); + return value.length === 0 || value === '/'; +} + /** * Prefer a live shell cwd. Skip connection.homePath `/` — connect() stores that * before SFTP cwd returns, so it is not a real home. diff --git a/src/components/ui/DynamicIcon.tsx b/src/components/ui/DynamicIcon.tsx index f8dbdf08..3f175442 100644 --- a/src/components/ui/DynamicIcon.tsx +++ b/src/components/ui/DynamicIcon.tsx @@ -163,7 +163,7 @@ export const DynamicIcon = memo(function DynamicIcon({ alt={type} style={{ width: size, height: size }} className={cn( - "shrink-0 select-none transition-opacity duration-200", + "shrink-0 select-none", isLoaded ? "opacity-100" : "opacity-0" )} draggable={false} diff --git a/tests/openHerePaths.test.mjs b/tests/openHerePaths.test.mjs index 804432d9..9a1f1051 100644 --- a/tests/openHerePaths.test.mjs +++ b/tests/openHerePaths.test.mjs @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import { directoryFromFileLocation, parentDirectory, pickFilesOpenPath } from '../.tmp-agent-tests/src/components/layout/tabDock/openHerePaths.js'; +import { directoryFromFileLocation, isUnresolvedFilesPath, parentDirectory, pickFilesOpenPath } from '../.tmp-agent-tests/src/components/layout/tabDock/openHerePaths.js'; function runTest(name, fn) { try { @@ -21,6 +21,14 @@ runTest('pickFilesOpenPath prefers shell cwd and ignores placeholder home /', () assert.equal(pickFilesOpenPath({}), ''); }); +runTest('isUnresolvedFilesPath treats empty and / as not a Files home', () => { + assert.equal(isUnresolvedFilesPath(''), true); + assert.equal(isUnresolvedFilesPath('/'), true); + assert.equal(isUnresolvedFilesPath(' / '), true); + assert.equal(isUnresolvedFilesPath('/home/appserver'), false); + assert.equal(isUnresolvedFilesPath('C:\\Users\\gajen'), false); +}); + runTest('directory uses the listed Files path for empty space', () => { assert.equal(directoryFromFileLocation('/home/appserver'), '/home/appserver'); assert.equal(directoryFromFileLocation(''), ''); From 40f398806574c6dad9a08bc437074546e6464b9a Mon Sep 17 00:00:00 2001 From: Gajendra sahu Date: Tue, 8 Sep 2026 19:31:18 +0530 Subject: [PATCH 10/17] fix(files): fill icon-grid columns to the pane width --- CHANGELOG.md | 1 + src/components/FileManager.tsx | 8 +- src/components/file-manager/FileGrid.tsx | 97 ++++++++----------- src/components/file-manager/fileGridLayout.ts | 2 +- tests/fileGridLayout.test.mjs | 3 +- 5 files changed, 52 insertions(+), 59 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 252563bf..11b48b1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ All notable changes to Zync are documented in this file. The format is based on ### Fixed - **Files opening at `/`**: File Manager no longer treats `/` as home. Open File Manager Here uses the shell cwd (or a real `fs_cwd`), not the pre-connect placeholder. First open ignores a shell cwd of `/` and waits for a real home instead of listing root. Reconnect keeps `/` when that listing already has files (hash button). Switching hosts clears selection and the open editor. On Windows, local home uses `%USERPROFILE%` instead of unset `HOME`. ([2d3b7c8]) +- **Icon grid right gap:** Grid columns fill the Files pane (no empty strip, no leftover 3-column layout in a wide window). Large folders re-render less while the shell cwd updates. - **Drag shell tab onto itself**: Dropping the current shell tab on its own split is a no-op again and restores Files / Dashboard / Snippets if the drag started from that overlay. ([2d3b7c8]) - **Open here after a tab switch**: Open File Manager Here / Open Terminal Here keep the tab that opened the menu, instead of applying to whichever tab is active after the await. ([2d3b7c8]) diff --git a/src/components/FileManager.tsx b/src/components/FileManager.tsx index c1f91b2e..cbd4239d 100644 --- a/src/components/FileManager.tsx +++ b/src/components/FileManager.tsx @@ -180,6 +180,10 @@ export const FileManager = memo(function FileManager({ )); const filesOpenHint = useAppStore((state) => { if (!activeConnectionId) return ''; + const path = state.currentPath[activeConnectionId] ?? ''; + const listing = state.files[activeConnectionId]; + const waiting = (!path || path === '/') && (!listing || listing.length === 0); + if (!waiting) return ''; const activeId = state.activeTerminalIds[activeConnectionId]; const tabs = state.terminals[activeConnectionId] || []; const term = tabs.find((tab) => tab.id === activeId) ?? tabs.find((tab) => tab.tabVisible !== false); @@ -1650,7 +1654,7 @@ export const FileManager = memo(function FileManager({
{/* biome-ignore lint/a11y/noStaticElementInteractions: interactive div */} -
setContextMenu(null)}> +
setContextMenu(null)}> {(isReconnectPending || currentError === 'DISCONNECTED' || (!isConnected && !isLocal)) ? (
@@ -43,7 +43,6 @@ const FileIcon = memo(function FileIcon({ file, size }: { file: FileEntry; size: ); }); @@ -427,7 +426,7 @@ function FileGridCell({ return
; } return ( -
+
(null); + const [gridViewportWidth, setGridViewportWidth] = useState(0); const selectedFilesRef = useRef(selectedFiles); selectedFilesRef.current = selectedFiles; const getSelectedFiles = useCallback(() => selectedFilesRef.current, []); const selectedSet = useMemo(() => new Set(selectedFiles), [selectedFiles]); - const scrollbarSize = useMemo(() => getScrollbarSize(), []); const reportColumnCount = useCallback((count: number) => { gridColumnCountRef.current = count; @@ -549,13 +548,23 @@ export const FileGrid = memo(function FileGrid({ ], ); + const gridMetrics = useMemo( + () => computeFileGridMetrics(gridViewportWidth, compactMode), + [gridViewportWidth, compactMode], + ); + const gridColumnWidth = Math.max( + 1, + gridViewportWidth > 0 ? gridViewportWidth / gridMetrics.columnCount : gridMetrics.columnWidth, + ); + const gridRowCount = Math.max(1, Math.ceil(files.length / gridMetrics.columnCount)); + return ( // biome-ignore lint/a11y/noStaticElementInteractions:
onSelect('', false)} onContextMenu={(e) => { e.preventDefault(); @@ -609,7 +618,7 @@ export const FileGrid = memo(function FileGrid({
{files.length === 0 ? ( @@ -669,53 +678,33 @@ export const FileGrid = memo(function FileGrid({
) : ( -
- { - if (!height || !width) return null; - const metrics = computeFileGridMetricsForViewport( - width, - height, - files.length, - compactMode, - scrollbarSize, - ); - const rowCount = Math.max(1, Math.ceil(files.length / metrics.columnCount)); - gridColumnCountRef.current = metrics.columnCount; - if (lastReportedColumnCountRef.current !== metrics.columnCount) { - queueMicrotask(() => { - reportColumnCount(metrics.columnCount); - if (focusedFile) scrollFocusedGridCell(metrics.columnCount); - }); +
+ { + setGridViewportWidth(width); + const next = computeFileGridMetrics(width, compactMode); + gridColumnCountRef.current = next.columnCount; + reportColumnCount(next.columnCount); + const el = gridRef.current?.element; + if (el) el.scrollLeft = 0; + if (!focusedFile) { + if (el) el.scrollTop = 0; + return; } - return ( - { - reportColumnCount(metrics.columnCount); - const el = gridRef.current?.element; - if (!focusedFile && el) { - el.scrollTop = 0; - el.scrollLeft = 0; - return; - } - scrollFocusedGridCell(metrics.columnCount); - }} - /> - ); + scrollFocusedGridCell(next.columnCount); }} />
diff --git a/src/components/file-manager/fileGridLayout.ts b/src/components/file-manager/fileGridLayout.ts index 8ca179af..c0afaa2a 100644 --- a/src/components/file-manager/fileGridLayout.ts +++ b/src/components/file-manager/fileGridLayout.ts @@ -56,7 +56,7 @@ export function computeFileGridMetrics(containerWidth: number, compactMode: bool const rowHeight = compactMode ? 120 : 140; const width = Math.max(0, containerWidth); const columnCount = Math.max(1, Math.floor((width + gap) / (minTrack + gap))); - const columnWidth = (width - gap * (columnCount - 1)) / columnCount; + const columnWidth = width / columnCount; return { columnCount, columnWidth, rowHeight, gap }; } diff --git a/tests/fileGridLayout.test.mjs b/tests/fileGridLayout.test.mjs index 950c2270..36010e02 100644 --- a/tests/fileGridLayout.test.mjs +++ b/tests/fileGridLayout.test.mjs @@ -46,8 +46,7 @@ runTest('computeFileGridMetrics compact column count', () => { assert.equal(m.gap, 8); assert.equal(m.columnCount, 3); assert.ok(m.columnWidth > 100); - const filled = m.columnWidth * m.columnCount + m.gap * (m.columnCount - 1); - assert.ok(Math.abs(filled - 332) < 0.001); + assert.ok(Math.abs(m.columnWidth * m.columnCount - 332) < 0.001); }); runTest('fileGridSlotSize adds gap except on the last track', () => { From e7334e63be5d22dfe6efd7bbb8aaf336bd3a26ce Mon Sep 17 00:00:00 2001 From: Gajendra sahu Date: Tue, 8 Sep 2026 21:12:07 +0530 Subject: [PATCH 11/17] fix(files): expand ~ before SFTP list instead of walking up to / --- CHANGELOG.md | 2 +- src/components/FileManager.tsx | 2 +- .../layout/tabDock/openHerePaths.ts | 14 ++++++++++++-- src/store/fileSystemSlice.ts | 19 ++++++++++++++++++- tests/openHerePaths.test.mjs | 10 +++++++++- 5 files changed, 41 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 11b48b1e..a113a5a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,7 @@ All notable changes to Zync are documented in this file. The format is based on - **Open here in split**: Terminal **Open File Manager Here** is one row with **In a new tab** / **Left** / **Right** / **Bottom**. Files **Open Terminal Here** and **Follow with Terminal** use the same submenu. Split docks beside the current pane (not a stray new tab). ([2d3b7c8]) ### Fixed -- **Files opening at `/`**: File Manager no longer treats `/` as home. Open File Manager Here uses the shell cwd (or a real `fs_cwd`), not the pre-connect placeholder. First open ignores a shell cwd of `/` and waits for a real home instead of listing root. Reconnect keeps `/` when that listing already has files (hash button). Switching hosts clears selection and the open editor. On Windows, local home uses `%USERPROFILE%` instead of unset `HOME`. ([2d3b7c8]) +- **Files opening at `/`**: File Manager no longer treats `/` as home. Open File Manager Here uses the shell cwd (or a real `fs_cwd`), not the pre-connect placeholder. First open ignores a shell cwd of `/` or `~` (SFTP cannot list a tilde) and expands to a real home instead of walking up to `/`. Reconnect keeps `/` when that listing already has files (hash button). Switching hosts clears selection and the open editor. On Windows, local home uses `%USERPROFILE%` instead of unset `HOME`. ([2d3b7c8]) - **Icon grid right gap:** Grid columns fill the Files pane (no empty strip, no leftover 3-column layout in a wide window). Large folders re-render less while the shell cwd updates. - **Drag shell tab onto itself**: Dropping the current shell tab on its own split is a no-op again and restores Files / Dashboard / Snippets if the drag started from that overlay. ([2d3b7c8]) diff --git a/src/components/FileManager.tsx b/src/components/FileManager.tsx index cbd4239d..b33edf4a 100644 --- a/src/components/FileManager.tsx +++ b/src/components/FileManager.tsx @@ -182,7 +182,7 @@ export const FileManager = memo(function FileManager({ if (!activeConnectionId) return ''; const path = state.currentPath[activeConnectionId] ?? ''; const listing = state.files[activeConnectionId]; - const waiting = (!path || path === '/') && (!listing || listing.length === 0); + const waiting = (!path || path === '/' || path === '~') && (!listing || listing.length === 0); if (!waiting) return ''; const activeId = state.activeTerminalIds[activeConnectionId]; const tabs = state.terminals[activeConnectionId] || []; diff --git a/src/components/layout/tabDock/openHerePaths.ts b/src/components/layout/tabDock/openHerePaths.ts index 8e815657..fe962318 100644 --- a/src/components/layout/tabDock/openHerePaths.ts +++ b/src/components/layout/tabDock/openHerePaths.ts @@ -15,7 +15,17 @@ function trimmed(value: string | null | undefined): string { */ export function isUnresolvedFilesPath(path: string | null | undefined): boolean { const value = trimmed(path); - return value.length === 0 || value === '/'; + return value.length === 0 || value === '/' || value === '~'; +} + +/** SFTP cannot list `~`; expand with a real home (`/home/user`). */ +export function expandTildeWithHome(path: string, home: string): string { + const value = trimmed(path); + const homePath = trimmed(home).replace(/\/+$/, ''); + if (!homePath || homePath === '/' || homePath === '~') return value; + if (value === '~') return homePath; + if (value.startsWith('~/')) return `${homePath}/${value.slice(2)}`; + return value; } /** @@ -28,7 +38,7 @@ export function pickFilesOpenPath(input: { homePath?: string | null; }): string { const cwd = trimmed(input.lastKnownCwd); - if (cwd) return cwd; + if (cwd && cwd !== '~') return cwd; const initial = trimmed(input.initialPath); if (initial) return initial; const home = trimmed(input.homePath); diff --git a/src/store/fileSystemSlice.ts b/src/store/fileSystemSlice.ts index 3a3b9553..ee51f09d 100644 --- a/src/store/fileSystemSlice.ts +++ b/src/store/fileSystemSlice.ts @@ -2,6 +2,7 @@ import { StateCreator } from 'zustand'; import type { AppStore } from './useAppStore'; import { notify } from '../features/notifications'; import type { FileEntry } from '../components/file-manager/types'; +import { expandTildeWithHome } from '../components/layout/tabDock/openHerePaths'; // @ts-ignore const ipc = window.ipcRenderer; @@ -87,8 +88,23 @@ export const createFileSystemSlice: StateCreator { const state = get(); - const targetPath = (path !== undefined ? path : state.currentPath[connectionId] || '').trim(); + let targetPath = (path !== undefined ? path : state.currentPath[connectionId] || '').trim(); if (!targetPath) return; + if (targetPath === '~' || targetPath.startsWith('~/')) { + const conn = get().connections.find((c) => c.id === connectionId); + let home = (conn?.homePath ?? '').trim(); + if (!home || home === '/' || home === '~') { + try { + const cwd = await ipc.invoke('fs_cwd', { connectionId }); + if (typeof cwd === 'string') home = cwd.trim(); + } catch { + home = ''; + } + } + const expanded = expandTildeWithHome(targetPath, home); + if (!expanded || expanded === '~' || expanded.startsWith('~/')) return; + targetPath = expanded; + } // History Logic if (!skipHistory && targetPath !== state.currentPath[connectionId]) { @@ -169,6 +185,7 @@ export const createFileSystemSlice: StateCreator { assert.equal(pickFilesOpenPath({ lastKnownCwd: '/home/appserver' }), '/home/appserver'); assert.equal(pickFilesOpenPath({ lastKnownCwd: '/' }), '/'); + assert.equal(pickFilesOpenPath({ lastKnownCwd: '~', homePath: '/home/appserver' }), '/home/appserver'); assert.equal(pickFilesOpenPath({ initialPath: '/opt/app' }), '/opt/app'); assert.equal(pickFilesOpenPath({ homePath: '/home/appserver' }), '/home/appserver'); assert.equal(pickFilesOpenPath({ homePath: '/' }), ''); @@ -24,11 +25,18 @@ runTest('pickFilesOpenPath prefers shell cwd and ignores placeholder home /', () runTest('isUnresolvedFilesPath treats empty and / as not a Files home', () => { assert.equal(isUnresolvedFilesPath(''), true); assert.equal(isUnresolvedFilesPath('/'), true); + assert.equal(isUnresolvedFilesPath('~'), true); assert.equal(isUnresolvedFilesPath(' / '), true); assert.equal(isUnresolvedFilesPath('/home/appserver'), false); assert.equal(isUnresolvedFilesPath('C:\\Users\\gajen'), false); }); +runTest('expandTildeWithHome maps ~ to a real home', () => { + assert.equal(expandTildeWithHome('~', '/home/appserver'), '/home/appserver'); + assert.equal(expandTildeWithHome('~/src', '/home/appserver'), '/home/appserver/src'); + assert.equal(expandTildeWithHome('~', '/'), '~'); +}); + runTest('directory uses the listed Files path for empty space', () => { assert.equal(directoryFromFileLocation('/home/appserver'), '/home/appserver'); assert.equal(directoryFromFileLocation(''), ''); From 1ed79d88d6937d741b398f30945b23f108fb522a Mon Sep 17 00:00:00 2001 From: Gajendra sahu Date: Tue, 8 Sep 2026 22:03:43 +0530 Subject: [PATCH 12/17] fix: CodeRabbit follow-up for sniffers, Files grid, and flush-stats Drop sniffer rolling text on truncated large frames, clear I/O debug timers when diagnostics are off, sync FileGrid columns on compactMode, size the list with native ResizeObserver, and document idleFirst. --- docs/TERMINAL.md | 2 +- src/components/file-manager/FileGrid.tsx | 34 +++++++++---------- src/components/file-manager/fileGridLayout.ts | 31 ----------------- src/lib/ghostSuggestions/promptCwdSniffer.ts | 5 ++- src/lib/ghostSuggestions/secretInputDetect.ts | 5 ++- src/lib/terminal/terminalIoDebug.ts | 5 ++- src/lib/terminal/terminalOutputStream.ts | 4 +-- tests/fileGridLayout.test.mjs | 31 ----------------- tests/ghostSuggestionsHelpers.test.mjs | 14 ++++++++ 9 files changed, 45 insertions(+), 86 deletions(-) diff --git a/docs/TERMINAL.md b/docs/TERMINAL.md index 2706fc87..84b31cae 100644 --- a/docs/TERMINAL.md +++ b/docs/TERMINAL.md @@ -231,7 +231,7 @@ Public surface exported from `index.ts`. Key modules: - `terminal:resize` — cols/rows from unified resize scheduler - `terminal:close` / `terminal:close_by_connection` — programmatic teardown (**no** `terminal-exit`) - `terminal_has_active_processes` — local sysinfo child-tree probe -- `terminal:flush-stats` / `terminal_flush_stats` — process-wide flush-reason counters (`idle_first` / `timer` / `threshold` / `close`). Debug only. Not on the PTY output Channel. +- `terminal:flush-stats` / `terminal_flush_stats` — process-wide flush-reason counters (`idleFirst` / `timer` / `threshold` / `close`). Debug only. Not on the PTY output Channel. ### Events (listen) diff --git a/src/components/file-manager/FileGrid.tsx b/src/components/file-manager/FileGrid.tsx index c1c420e4..ba586473 100644 --- a/src/components/file-manager/FileGrid.tsx +++ b/src/components/file-manager/FileGrid.tsx @@ -13,7 +13,6 @@ import { motion, AnimatePresence } from 'framer-motion'; import { forwardRef } from 'react'; import { buildDragData, startInternalDrag, validateAndBuildMoves } from './dragDropUtils'; import { getScrollbarSize, Grid, List, useGridRef, useListRef } from 'react-window'; -import { AutoSizer } from 'react-virtualized-auto-sizer'; import { computeFileGridMetrics, FILE_LIST_COLUMNS, @@ -469,8 +468,10 @@ export const FileGrid = memo(function FileGrid({ const lastReportedColumnCountRef = useRef(null); const [gridViewportWidth, setGridViewportWidth] = useState(0); const selectedFilesRef = useRef(selectedFiles); - selectedFilesRef.current = selectedFiles; const getSelectedFiles = useCallback(() => selectedFilesRef.current, []); + useLayoutEffect(() => { + selectedFilesRef.current = selectedFiles; + }, [selectedFiles]); const selectedSet = useMemo(() => new Set(selectedFiles), [selectedFiles]); const reportColumnCount = useCallback((count: number) => { @@ -558,6 +559,10 @@ export const FileGrid = memo(function FileGrid({ ); const gridRowCount = Math.max(1, Math.ceil(files.length / gridMetrics.columnCount)); + useEffect(() => { + reportColumnCount(gridMetrics.columnCount); + }, [gridMetrics.columnCount, reportColumnCount]); + @@ -657,23 +662,16 @@ export const FileGrid = memo(function FileGrid({ ))}
-
- + - height && width ? ( - scrollFocusedListRow()} - /> - ) : null - } + onResize={() => scrollFocusedListRow()} />
diff --git a/src/components/file-manager/fileGridLayout.ts b/src/components/file-manager/fileGridLayout.ts index c0afaa2a..05b5fff2 100644 --- a/src/components/file-manager/fileGridLayout.ts +++ b/src/components/file-manager/fileGridLayout.ts @@ -59,34 +59,3 @@ export function computeFileGridMetrics(containerWidth: number, compactMode: bool const columnWidth = width / columnCount; return { columnCount, columnWidth, rowHeight, gap }; } - -/** react-window slot size: track plus inter-item gap, except the last row/column. */ -export function fileGridSlotSize(index: number, count: number, track: number, gap: number): number { - if (count <= 1 || index >= count - 1) return track; - return track + gap; -} - -export function fileGridContentHeight(fileCount: number, metrics: FileGridMetrics): number { - if (fileCount <= 0) return 0; - const rowCount = Math.max(1, Math.ceil(fileCount / metrics.columnCount)); - return rowCount * metrics.rowHeight + Math.max(0, rowCount - 1) * metrics.gap; -} - -/** Shrink the layout width when a classic vertical scrollbar will appear. */ -export function computeFileGridMetricsForViewport( - width: number, - height: number, - fileCount: number, - compactMode: boolean, - scrollbarSize: number, -): FileGridMetrics { - const full = computeFileGridMetrics(width, compactMode); - if (fileCount <= 0 || scrollbarSize <= 0 || fileGridContentHeight(fileCount, full) <= height) { - return full; - } - return computeFileGridMetrics(Math.max(0, width - scrollbarSize), compactMode); -} - -export function fileGridKeyboardIndex(row: number, col: number, columnCount: number): number { - return row * Math.max(1, columnCount) + col; -} diff --git a/src/lib/ghostSuggestions/promptCwdSniffer.ts b/src/lib/ghostSuggestions/promptCwdSniffer.ts index a916c2e2..9e34a428 100644 --- a/src/lib/ghostSuggestions/promptCwdSniffer.ts +++ b/src/lib/ghostSuggestions/promptCwdSniffer.ts @@ -73,7 +73,7 @@ export function extractCwdFromPromptOutput(text: string): string | null { const sniffBuffers = new Map(); const sniffDecoders = new Map(); -export type CwdSnifferFeedOptions = { resetDecoder?: boolean }; +export type CwdSnifferFeedOptions = { resetDecoder?: boolean; resetBuffer?: boolean }; /** Feed PTY output bytes; invokes onCwd when a prompt path is recognized. */ export function feedPromptCwdSniffer( @@ -89,6 +89,9 @@ export function feedPromptCwdSniffer( decoder = new TextDecoder('utf-8', { fatal: false }); sniffDecoders.set(termId, decoder); } + if (options?.resetBuffer) { + sniffBuffers.delete(termId); + } const chunk = decoder.decode(data, { stream: true }); const prev = sniffBuffers.get(termId) ?? ''; diff --git a/src/lib/ghostSuggestions/secretInputDetect.ts b/src/lib/ghostSuggestions/secretInputDetect.ts index 6e233aa3..9d8e0146 100644 --- a/src/lib/ghostSuggestions/secretInputDetect.ts +++ b/src/lib/ghostSuggestions/secretInputDetect.ts @@ -38,7 +38,7 @@ export function detectSecretPromptInOutput(text: string): boolean { return SECRET_PROMPT_PATTERNS.some((pattern) => pattern.test(tail)); } -export type SnifferFeedOptions = { resetDecoder?: boolean }; +export type SnifferFeedOptions = { resetDecoder?: boolean; resetBuffer?: boolean }; /** Feed PTY output; invokes onSecretPrompt when a hidden-input prompt is recognized. */ export function feedSecretInputSniffer( @@ -54,6 +54,9 @@ export function feedSecretInputSniffer( decoder = new TextDecoder('utf-8', { fatal: false }); sniffDecoders.set(termId, decoder); } + if (options?.resetBuffer) { + sniffBuffers.delete(termId); + } const chunk = decoder.decode(data, { stream: true }); const prev = sniffBuffers.get(termId) ?? ''; diff --git a/src/lib/terminal/terminalIoDebug.ts b/src/lib/terminal/terminalIoDebug.ts index 3d0ac7d4..a888afd4 100644 --- a/src/lib/terminal/terminalIoDebug.ts +++ b/src/lib/terminal/terminalIoDebug.ts @@ -127,7 +127,10 @@ function pumpVsync(termId: string, stats: SessionIoStats): void { function ensureDump(termId: string, stats: SessionIoStats): void { if (stats.dumpTimer !== null) return; stats.dumpTimer = setInterval(() => { - if (!isTerminalIoDebugEnabled()) return; + if (!isTerminalIoDebugEnabled()) { + clearTerminalIoDebug(termId); + return; + } dumpSession(termId, stats); }, DUMP_MS); pumpVsync(termId, stats); diff --git a/src/lib/terminal/terminalOutputStream.ts b/src/lib/terminal/terminalOutputStream.ts index 34577fde..fa8b6c87 100644 --- a/src/lib/terminal/terminalOutputStream.ts +++ b/src/lib/terminal/terminalOutputStream.ts @@ -91,12 +91,12 @@ export function attachTerminalOutputChannel(termId: string, term: XTerm): Channe feedSecretInputSniffer(termId, sniff, () => { const live = terminalCache.get(termId); live?.ghostTracker?.enterSecretInputMode(); - }, { resetDecoder: large }); + }, { resetDecoder: large, resetBuffer: large }); if (outputMayContainPrompt(sniff)) { feedPromptCwdSniffer(termId, sniff, (path) => { entry.ghostTracker?.exitSecretInputMode(); useAppStore.getState().setTerminalCwd(connectionId, termId, path); - }, { resetDecoder: large }); + }, { resetDecoder: large, resetBuffer: large }); } } const writeStarted = performance.now(); diff --git a/tests/fileGridLayout.test.mjs b/tests/fileGridLayout.test.mjs index 36010e02..92bddb2a 100644 --- a/tests/fileGridLayout.test.mjs +++ b/tests/fileGridLayout.test.mjs @@ -1,11 +1,7 @@ import assert from 'node:assert/strict'; import { computeFileGridMetrics, - computeFileGridMetricsForViewport, FILE_LIST_COLUMNS, - fileGridContentHeight, - fileGridKeyboardIndex, - fileGridSlotSize, sortFileEntries, } from '../.tmp-agent-tests/src/components/file-manager/fileGridLayout.js'; @@ -49,33 +45,6 @@ runTest('computeFileGridMetrics compact column count', () => { assert.ok(Math.abs(m.columnWidth * m.columnCount - 332) < 0.001); }); -runTest('fileGridSlotSize adds gap except on the last track', () => { - assert.equal(fileGridSlotSize(0, 3, 100, 8), 108); - assert.equal(fileGridSlotSize(2, 3, 100, 8), 100); - assert.equal(fileGridSlotSize(0, 1, 120, 16), 120); -}); - -runTest('computeFileGridMetricsForViewport keeps full width when content fits', () => { - const full = computeFileGridMetrics(500, false); - const fitted = computeFileGridMetricsForViewport(500, 400, 2, false, 17); - assert.equal(fitted.columnCount, full.columnCount); - assert.equal(fitted.columnWidth, full.columnWidth); - assert.ok(fileGridContentHeight(2, fitted) <= 400); -}); - -runTest('computeFileGridMetricsForViewport subtracts scrollbar when rows overflow', () => { - const full = computeFileGridMetrics(500, false); - const overflowed = computeFileGridMetricsForViewport(500, 200, 40, false, 17); - const guttered = computeFileGridMetrics(500 - 17, false); - assert.ok(fileGridContentHeight(40, full) > 200); - assert.equal(overflowed.columnCount, guttered.columnCount); - assert.equal(overflowed.columnWidth, guttered.columnWidth); -}); - -runTest('fileGridKeyboardIndex is row-major', () => { - assert.equal(fileGridKeyboardIndex(1, 2, 4), 6); -}); - runTest('FILE_LIST_COLUMNS includes name flex track and size column', () => { assert.equal(FILE_LIST_COLUMNS.includes('minmax(0, 1fr)'), true); assert.equal(FILE_LIST_COLUMNS.includes('6rem'), true); diff --git a/tests/ghostSuggestionsHelpers.test.mjs b/tests/ghostSuggestionsHelpers.test.mjs index 132b6fcb..a153e764 100644 --- a/tests/ghostSuggestionsHelpers.test.mjs +++ b/tests/ghostSuggestionsHelpers.test.mjs @@ -561,6 +561,20 @@ await runTest('resetDecoder does not wipe rolling string across small then tail } }); +await runTest('resetBuffer drops rolling string on truncated large frames', () => { + let calls = 0; + const termId = 'secret-sniffer-reset-buffer'; + const encode = (value) => new TextEncoder().encode(value); + try { + feedSecretInputSniffer(termId, encode('Pass'), () => { calls += 1; }); + assert.equal(calls, 0); + feedSecretInputSniffer(termId, encode('word:\n'), () => { calls += 1; }, { resetDecoder: true, resetBuffer: true }); + assert.equal(calls, 0); + } finally { + clearSecretInputSniffer(termId); + } +}); + await runTest('detectSecretPromptInOutput recognizes sudo and SSH password prompts', () => { assert.equal(detectSecretPromptInOutput('[sudo] password for gajen: '), true); assert.equal(detectSecretPromptInOutput("user@host's password: "), true); From 84080c57d52224b5912828256467ff6f4b36d5b6 Mon Sep 17 00:00:00 2001 From: Gajendra sahu Date: Tue, 8 Sep 2026 23:11:56 +0530 Subject: [PATCH 13/17] feat(release-notes): render images, GIFs, and video in What's New Embed GitHub, CDN, and local media (including bare Demo URLs), add a lightbox and GFM extras, and keep HTML sanitized. Follow-up: reset media state on src change, keep alert markup, and detect encoded path traversal. --- package-lock.json | 172 +++- package.json | 3 + src/components/tabs/ReleaseNotesTab.tsx | 789 +++++++----------- .../releaseNotes/ReleaseNotesMarkdown.tsx | 363 ++++++++ .../tabs/releaseNotes/ReleaseNotesMedia.tsx | 198 +++++ src/lib/releaseNotes/alerts.ts | 32 + src/lib/releaseNotes/headings.ts | 69 ++ src/lib/releaseNotes/mediaUrls.ts | 234 ++++++ src/lib/releaseNotes/reactText.ts | 8 + src/lib/releaseNotes/sanitizeSchema.ts | 31 + src/lib/releaseNotes/urlTransform.ts | 34 + tests/releaseNotesMarkdown.test.mjs | 148 ++++ tests/runAllAgentTests.mjs | 1 + tsconfig.agent-tests.json | 3 + 14 files changed, 1588 insertions(+), 497 deletions(-) create mode 100644 src/components/tabs/releaseNotes/ReleaseNotesMarkdown.tsx create mode 100644 src/components/tabs/releaseNotes/ReleaseNotesMedia.tsx create mode 100644 src/lib/releaseNotes/alerts.ts create mode 100644 src/lib/releaseNotes/headings.ts create mode 100644 src/lib/releaseNotes/mediaUrls.ts create mode 100644 src/lib/releaseNotes/reactText.ts create mode 100644 src/lib/releaseNotes/sanitizeSchema.ts create mode 100644 src/lib/releaseNotes/urlTransform.ts create mode 100644 tests/releaseNotesMarkdown.test.mjs diff --git a/package-lock.json b/package-lock.json index f8191e53..075ba682 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "zync", - "version": "2.26.1", + "version": "2.29.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "zync", - "version": "2.26.1", + "version": "2.29.0", "dependencies": { "@codemirror/autocomplete": "^6.20.1", "@codemirror/lang-css": "^6.3.1", @@ -51,6 +51,8 @@ "react-virtualized-auto-sizer": "^2.0.2", "react-window": "^2.2.5", "recharts": "^3.7.0", + "rehype-raw": "^7.0.0", + "rehype-sanitize": "^6.0.0", "remark-gfm": "^4.0.1", "tailwind-merge": "^3.4.0", "zustand": "^5.0.10" @@ -3711,6 +3713,18 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-toolkit": { "version": "1.45.0", "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.0.tgz", @@ -3982,6 +3996,26 @@ "dev": true, "license": "ISC" }, + "node_modules/hast-util-from-parse5": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz", + "integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "devlop": "^1.0.0", + "hastscript": "^9.0.0", + "property-information": "^7.0.0", + "vfile": "^6.0.0", + "vfile-location": "^5.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-parse-selector": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", @@ -3995,6 +4029,46 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hast-util-raw": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/hast-util-raw/-/hast-util-raw-9.1.0.tgz", + "integrity": "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "hast-util-from-parse5": "^8.0.0", + "hast-util-to-parse5": "^8.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "parse5": "^7.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-sanitize": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/hast-util-sanitize/-/hast-util-sanitize-5.0.2.tgz", + "integrity": "sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "unist-util-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-to-jsx-runtime": { "version": "2.3.6", "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", @@ -4022,6 +4096,25 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hast-util-to-parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.1.tgz", + "integrity": "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-whitespace": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", @@ -4077,6 +4170,16 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/immer": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", @@ -5442,6 +5545,18 @@ "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", "license": "MIT" }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -5783,6 +5898,35 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/rehype-raw": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz", + "integrity": "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-raw": "^9.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-sanitize": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/rehype-sanitize/-/rehype-sanitize-6.0.0.tgz", + "integrity": "sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-sanitize": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/remark-gfm": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", @@ -6258,6 +6402,20 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/vfile-location": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz", + "integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/vfile-message": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", @@ -6375,6 +6533,16 @@ "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", "license": "MIT" }, + "node_modules/web-namespaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", + "integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/package.json b/package.json index 8eb6daaf..ea66824f 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "test:vault-focus": "npm run compile:agent-tests && node tests/vaultFocus.test.mjs", "test:unlock-modal-consistency": "node tests/unlockModalConsistency.test.mjs", "test:ghost-helpers": "npm run compile:agent-tests && node tests/ghostSuggestionsHelpers.test.mjs", + "test:release-notes-markdown": "npm run compile:agent-tests && node tests/releaseNotesMarkdown.test.mjs", "test:session-persistence": "npm run compile:agent-tests && node tests/sessionPersistence.test.mjs", "test:terminal-renderer-policy": "npm run compile:agent-tests && node tests/terminalRendererPolicy.test.mjs", "test:terminal-renderer": "npm run compile:agent-tests && node tests/runTerminalRendererTests.mjs", @@ -95,6 +96,8 @@ "react-virtualized-auto-sizer": "^2.0.2", "react-window": "^2.2.5", "recharts": "^3.7.0", + "rehype-raw": "^7.0.0", + "rehype-sanitize": "^6.0.0", "remark-gfm": "^4.0.1", "tailwind-merge": "^3.4.0", "zustand": "^5.0.10" diff --git a/src/components/tabs/ReleaseNotesTab.tsx b/src/components/tabs/ReleaseNotesTab.tsx index 232ebf5f..82a2f035 100644 --- a/src/components/tabs/ReleaseNotesTab.tsx +++ b/src/components/tabs/ReleaseNotesTab.tsx @@ -1,531 +1,330 @@ -import React, { ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { Gift, ExternalLink, ChevronDown, Check, Copy, Tag } from 'lucide-react'; -import ReactMarkdown from 'react-markdown'; -import remarkGfm from 'remark-gfm'; -import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; -import { oneDark, oneLight } from 'react-syntax-highlighter/dist/esm/styles/prism'; +import React, { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Gift, ExternalLink, ChevronDown, Tag } from 'lucide-react'; import { useAppStore } from '../../store/useAppStore'; +import { extractToc, headingKey, slugify } from '../../lib/releaseNotes/headings'; +import { getNodeText } from '../../lib/releaseNotes/reactText'; +import { ReleaseNotesMarkdown } from './releaseNotes/ReleaseNotesMarkdown'; interface GithubRelease { - tag_name: string; - name: string; - published_at: string; - body: string; - html_url: string; -} - -interface TocEntry { - id: string; - text: string; - level: 1 | 2 | 3; + tag_name: string; + name: string; + published_at: string; + body: string; + html_url: string; } const SECTION_BADGES: Record = { - added: { label: '+ Added', color: 'bg-emerald-500/15 text-emerald-400 border-emerald-500/30' }, - fixed: { label: '! Fixed', color: 'bg-red-500/15 text-red-400 border-red-500/30' }, - changed: { label: '* Changed', color: 'bg-blue-500/15 text-blue-400 border-blue-500/30' }, - security: { label: 'Security', color: 'bg-yellow-500/15 text-yellow-400 border-yellow-500/30' }, - enhancements: { label: 'Enhancements', color: 'bg-purple-500/15 text-purple-400 border-purple-500/30' }, - deprecated: { label: 'Deprecated', color: 'bg-orange-500/15 text-orange-400 border-orange-500/30' }, - removed: { label: 'Removed', color: 'bg-red-700/15 text-red-500 border-red-700/30' } + added: { label: '+ Added', color: 'bg-emerald-500/15 text-emerald-400 border-emerald-500/30' }, + fixed: { label: '! Fixed', color: 'bg-red-500/15 text-red-400 border-red-500/30' }, + changed: { label: '* Changed', color: 'bg-blue-500/15 text-blue-400 border-blue-500/30' }, + security: { label: 'Security', color: 'bg-yellow-500/15 text-yellow-400 border-yellow-500/30' }, + enhancements: { label: 'Enhancements', color: 'bg-purple-500/15 text-purple-400 border-purple-500/30' }, + deprecated: { label: 'Deprecated', color: 'bg-orange-500/15 text-orange-400 border-orange-500/30' }, + removed: { label: 'Removed', color: 'bg-red-700/15 text-red-500 border-red-700/30' }, }; -function normalizeHeadingText(text: string): string { - return text - .replace(/\[([^\]]+)\]\([^\)]+\)/g, '$1') - .replace(/`([^`]*)`/g, '$1') - .replace(/\*\*([^*]+)\*\*/g, '$1') - .replace(/\*([^*]+)\*/g, '$1') - .replace(/<[^>]+>/g, '') - .replace(/\s+/g, ' ') - .trim(); -} - -function headingKey(text: string): string { - return normalizeHeadingText(text).toLowerCase().replace(/[^a-z]/g, ''); -} - -function slugify(text: string, usedSlugs: Map): string { - const normalized = normalizeHeadingText(text) - .toLowerCase() - .replace(/[^a-z0-9\s-]/g, '') - .replace(/\s+/g, '-') - .replace(/-+/g, '-') - .replace(/^-|-$/g, ''); - const baseSlug = normalized || 'section'; - - if (usedSlugs.has(baseSlug)) { - const count = usedSlugs.get(baseSlug)! + 1; - usedSlugs.set(baseSlug, count); - return `${baseSlug}-${count}`; - } - - usedSlugs.set(baseSlug, 0); - return baseSlug; +function formatDate(iso: string): string { + if (!iso) return ''; + return new Date(iso).toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + }); } -function extractToc(markdown: string): TocEntry[] { - const lines = markdown.split('\n'); - const usedSlugs = new Map(); - const entries: TocEntry[] = []; - let inFence = false; +const ReleaseNotesTab: React.FC = () => { + const [releases, setReleases] = useState([]); + const [selected, setSelected] = useState(null); + const [isLoadingList, setIsLoadingList] = useState(true); + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const [appVersion, setAppVersion] = useState(''); + const [activeSection, setActiveSection] = useState(''); + + const contentRef = useRef(null); + const dropdownRef = useRef(null); + + const closeTab = useAppStore(state => state.closeTab); + const tabs = useAppStore(state => state.tabs); + const themeSetting = useAppStore(state => state.settings.theme); + const thisTab = tabs.find(t => t.type === 'release-notes'); + + const resolvedTheme = useMemo(() => { + if (themeSetting !== 'system') return themeSetting; + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; + }, [themeSetting]); + + const isLightTheme = resolvedTheme === 'light' || resolvedTheme === 'light-warm'; + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsDropdownOpen(false); + } + }; - for (const line of lines) { - if (/^\s*```/.test(line)) { - inFence = !inFence; - continue; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + useEffect(() => { + const controller = new AbortController(); + const signal = controller.signal; + let mounted = true; + + const run = async () => { + setIsLoadingList(true); + try { + const [ver, res] = await Promise.all([ + window.ipcRenderer.invoke('app:getVersion'), + fetch('https://api.github.com/repos/zync-sh/zync/releases?per_page=10', { signal }), + ]); + + if (!mounted) return; + + setAppVersion(ver); + if (!res.ok) throw new Error('GitHub API error'); + + const data: GithubRelease[] = await res.json(); + if (!mounted) return; + + setReleases(data); + const match = data.find(r => r.tag_name === `v${ver}`) || data[0] || null; + setSelected(match); + } catch (err: any) { + if (err.name === 'AbortError') return; + console.error('Failed to fetch releases:', err); + if (mounted) { + setSelected({ + tag_name: '', + name: 'Offline', + published_at: '', + body: 'Could not load release notes. Please check your internet connection or visit the [Releases page](https://github.com/zync-sh/zync/releases) directly.', + html_url: 'https://github.com/zync-sh/zync/releases', + }); } - if (inFence) continue; + } finally { + if (mounted) setIsLoadingList(false); + } + }; - const m = line.match(/^(#{1,3})\s+(.+)/); - if (!m) continue; + run(); - const level = m[1].length as 1 | 2 | 3; - const text = normalizeHeadingText(m[2]); - if (!text) continue; + return () => { + mounted = false; + controller.abort(); + }; + }, []); - entries.push({ - level, - text, - id: slugify(text, usedSlugs) - }); - } + const currentRelease = selected; + const markdownBody = currentRelease?.body || ''; - return entries; -} + const toc = useMemo(() => extractToc(markdownBody), [markdownBody]); -function formatDate(iso: string): string { - if (!iso) return ''; - return new Date(iso).toLocaleDateString('en-US', { - year: 'numeric', - month: 'long', - day: 'numeric' - }); -} + useEffect(() => { + setActiveSection(''); + }, [markdownBody]); -function getNodeText(node: ReactNode): string { - if (typeof node === 'string' || typeof node === 'number') return String(node); - if (Array.isArray(node)) return node.map(getNodeText).join(''); - if (React.isValidElement<{ children?: ReactNode }>(node)) return getNodeText(node.props.children); - return ''; -} + useEffect(() => { + if (!contentRef.current) return; -function CodeBlock({ language, children, isLightTheme }: { language?: string; children: string; isLightTheme: boolean }) { - const [copied, setCopied] = useState(false); - const timeoutRef = useRef(null); - - useEffect(() => { - return () => { - if (timeoutRef.current !== null) { - window.clearTimeout(timeoutRef.current); - } - }; - }, []); - - const copy = async () => { - try { - await navigator.clipboard.writeText(children); - setCopied(true); - if (timeoutRef.current !== null) { - window.clearTimeout(timeoutRef.current); - } - timeoutRef.current = window.setTimeout(() => setCopied(false), 2000); - } catch (err) { - console.error('Failed to copy code to clipboard:', err); + const headings = contentRef.current.querySelectorAll('h1, h2, h3'); + const observer = new IntersectionObserver( + entries => { + for (const e of entries) { + if (e.isIntersecting) { + setActiveSection(e.target.id); + break; + } } - }; + }, + { rootMargin: '-20px 0px -80% 0px' }, + ); - return ( -
-
- - {language || 'code'} - - -
+ headings.forEach(h => observer.observe(h)); + return () => observer.disconnect(); + }, [markdownBody, toc]); - 4} - lineNumberStyle={{ color: 'var(--color-app-muted)', minWidth: '2.5em', opacity: 0.6 }} - wrapLongLines - > - {children} - -
- ); -} + const scrollToSection = useCallback((id: string) => { + const target = + contentRef.current?.querySelector(`[id="${id}"]`) ?? + document.getElementById(id); -const ReleaseNotesTab: React.FC = () => { - const [releases, setReleases] = useState([]); - const [selected, setSelected] = useState(null); - const [isLoadingList, setIsLoadingList] = useState(true); - const [isDropdownOpen, setIsDropdownOpen] = useState(false); - const [appVersion, setAppVersion] = useState(''); - const [activeSection, setActiveSection] = useState(''); - - const contentRef = useRef(null); - const dropdownRef = useRef(null); - - const closeTab = useAppStore(state => state.closeTab); - const tabs = useAppStore(state => state.tabs); - const themeSetting = useAppStore(state => state.settings.theme); - const thisTab = tabs.find(t => t.type === 'release-notes'); - - const resolvedTheme = useMemo(() => { - if (themeSetting !== 'system') return themeSetting; - return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; - }, [themeSetting]); - - const isLightTheme = resolvedTheme === 'light' || resolvedTheme === 'light-warm'; - - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { - setIsDropdownOpen(false); - } - }; - - document.addEventListener('mousedown', handleClickOutside); - return () => document.removeEventListener('mousedown', handleClickOutside); - }, []); - - useEffect(() => { - const controller = new AbortController(); - const signal = controller.signal; - let mounted = true; - - const run = async () => { - setIsLoadingList(true); - try { - const [ver, res] = await Promise.all([ - window.ipcRenderer.invoke('app:getVersion'), - fetch('https://api.github.com/repos/zync-sh/zync/releases?per_page=10', { signal }) - ]); - - if (!mounted) return; - - setAppVersion(ver); - if (!res.ok) throw new Error('GitHub API error'); - - const data: GithubRelease[] = await res.json(); - if (!mounted) return; - - setReleases(data); - const match = data.find(r => r.tag_name === `v${ver}`) || data[0] || null; - setSelected(match); - } catch (err: any) { - if (err.name === 'AbortError') return; - console.error('Failed to fetch releases:', err); - if (mounted) { - setSelected({ - tag_name: '', - name: 'Offline', - published_at: '', - body: 'Could not load release notes. Please check your internet connection or visit the [Releases page](https://github.com/zync-sh/zync/releases) directly.', - html_url: 'https://github.com/zync-sh/zync/releases' - }); - } - } finally { - if (mounted) setIsLoadingList(false); - } - }; - - run(); - - return () => { - mounted = false; - controller.abort(); - }; - }, []); - - const currentRelease = selected; - const markdownBody = currentRelease?.body || ''; - - const toc = useMemo(() => extractToc(markdownBody), [markdownBody]); - - useEffect(() => { - setActiveSection(''); - }, [markdownBody]); - - useEffect(() => { - if (!contentRef.current) return; - - const headings = contentRef.current.querySelectorAll('h1, h2, h3'); - const observer = new IntersectionObserver( - entries => { - for (const e of entries) { - if (e.isIntersecting) { - setActiveSection(e.target.id); - break; - } - } - }, - { rootMargin: '-20px 0px -80% 0px' } - ); - - headings.forEach(h => observer.observe(h)); - return () => observer.disconnect(); - }, [markdownBody, toc]); - - const scrollToSection = useCallback((id: string) => { - const target = - contentRef.current?.querySelector(`[id="${id}"]`) ?? - document.getElementById(id); - - target?.scrollIntoView({ behavior: 'smooth', block: 'start' }); - }, []); - - let headingRenderIndex = 0; - const fallbackSlugMap = new Map(); - - const resolveHeadingId = (level: 1 | 2 | 3, text: string): string => { - while (headingRenderIndex < toc.length) { - const entry = toc[headingRenderIndex]; - headingRenderIndex += 1; - if (entry.level === level) { - return entry.id; - } - } + target?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }, []); - return slugify(text, fallbackSlugMap); - }; + let headingRenderIndex = 0; + const fallbackSlugMap = new Map(); - const renderHeading = (level: 1 | 2 | 3, children: ReactNode) => { - const text = getNodeText(children); - const id = resolveHeadingId(level, text); - const badge = SECTION_BADGES[headingKey(text)]; - - const inner = ( - - {badge ? ( - - {badge.label} - - ) : ( - {children} - )} - - # - - - ); - - if (level === 1) return

{inner}

; - if (level === 2) return

{inner}

; - return

{inner}

; - }; + const resolveHeadingId = (level: 1 | 2 | 3, text: string): string => { + while (headingRenderIndex < toc.length) { + const entry = toc[headingRenderIndex]; + headingRenderIndex += 1; + if (entry.level === level) { + return entry.id; + } + } - return ( -
-
-
-
-
- -
-
-

What's New in Zync

- {currentRelease?.published_at && ( -

{formatDate(currentRelease.published_at)}

- )} -
-
- -
-
- - - {isDropdownOpen && ( -
- {releases.map(r => ( - - ))} -
- )} -
- - {currentRelease?.html_url && ( - - - GitHub - - )} + return slugify(text, fallbackSlugMap); + }; + + const renderHeading = (level: 1 | 2 | 3, children: ReactNode) => { + const text = getNodeText(children); + const id = resolveHeadingId(level, text); + const badge = SECTION_BADGES[headingKey(text)]; + + const inner = ( + + {badge ? ( + + {badge.label} + + ) : ( + {children} + )} + + # + + + ); - {thisTab && ( - + if (level === 1) return

{inner}

; + if (level === 2) return

{inner}

; + return

{inner}

; + }; + + return ( +
+
+
+
+
+ +
+
+

What's New in Zync

+ {currentRelease?.published_at && ( +

{formatDate(currentRelease.published_at)}

+ )} +
+
+ +
+
+ + + {isDropdownOpen && ( +
+ {releases.map(r => ( +
+ + ))}
+ )}
-
- {toc.length > 1 && ( - - )} - -
- {isLoadingList ? ( -
-
- Fetching release notes... -
- ) : ( -
-
-
- Release - {currentRelease?.tag_name} -
-

{currentRelease?.name || currentRelease?.tag_name}

- {currentRelease?.published_at && ( -

{formatDate(currentRelease.published_at)}

- )} -
- -
- renderHeading(1, children), - h2: ({ children }) => renderHeading(2, children), - h3: ({ children }) => renderHeading(3, children), - code({ className, children }) { - const language = /language-([\w-]+)/.exec(className || '')?.[1]; - const codeContent = String(children).replace(/\n$/, ''); - const isBlock = Boolean(language) || codeContent.includes('\n'); - - return isBlock ? ( - - {codeContent} - - ) : ( - - {children} - - ); - }, - p: ({ children }) =>

{children}

, - ul: ({ children }) =>
    {children}
, - ol: ({ children }) =>
    {children}
, - li: ({ children }) =>
  • {children}
  • , - a: ({ href, children }) => { - if (!href) return {children}; - const internal = href.startsWith('#'); - return ( - - {children} - - ); - }, - blockquote: ({ children }) => ( -
    - {children} -
    - ), - hr: () =>
    , - table: ({ children }) => ( -
    - {children}
    -
    - ), - th: ({ children }) => ( - - {children} - - ), - td: ({ children }) => ( - - {children} - - ) - }} - > - {markdownBody} -
    -
    -
    - )} + {currentRelease?.html_url && ( + + + GitHub + + )} + + {thisTab && ( + + )} +
    +
    +
    + +
    + {toc.length > 1 && ( + + )} + +
    + {isLoadingList ? ( +
    +
    + Fetching release notes... +
    + ) : ( +
    +
    +
    + Release + {currentRelease?.tag_name}
    +

    {currentRelease?.name || currentRelease?.tag_name}

    + {currentRelease?.published_at && ( +

    {formatDate(currentRelease.published_at)}

    + )} +
    + +
    + +
    + )}
    - ); +
    +
    + ); }; -export default ReleaseNotesTab; \ No newline at end of file +export default ReleaseNotesTab; diff --git a/src/components/tabs/releaseNotes/ReleaseNotesMarkdown.tsx b/src/components/tabs/releaseNotes/ReleaseNotesMarkdown.tsx new file mode 100644 index 00000000..086ed04c --- /dev/null +++ b/src/components/tabs/releaseNotes/ReleaseNotesMarkdown.tsx @@ -0,0 +1,363 @@ +import { + Children, + cloneElement, + isValidElement, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from 'react'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import rehypeRaw from 'rehype-raw'; +import rehypeSanitize from 'rehype-sanitize'; +import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; +import { oneDark, oneLight } from 'react-syntax-highlighter/dist/esm/styles/prism'; +import { AlertOctagon, AlertTriangle, Check, Copy, Info, Lightbulb, Megaphone } from 'lucide-react'; +import { KeyboardKey } from '../../ui/KeyboardKey'; +import { matchAlertPrefix, stripAlertPrefixFromParts, type AlertKind } from '../../../lib/releaseNotes/alerts'; +import { rewriteMarkdownLocalMedia } from '../../../lib/releaseNotes/mediaUrls'; +import { getNodeText } from '../../../lib/releaseNotes/reactText'; +import { RELEASE_NOTES_SANITIZE_SCHEMA } from '../../../lib/releaseNotes/sanitizeSchema'; +import { rehypeRewriteLocalMedia, releaseNotesUrlTransform } from '../../../lib/releaseNotes/urlTransform'; +import { ReleaseNotesImage, ReleaseNotesVideo } from './ReleaseNotesMedia'; + +const ALERT_STYLES: Record = { + note: { + label: 'Note', + icon: Info, + className: 'border-blue-500/30 bg-blue-500/10 text-blue-400', + }, + tip: { + label: 'Tip', + icon: Lightbulb, + className: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-400', + }, + important: { + label: 'Important', + icon: Megaphone, + className: 'border-purple-500/30 bg-purple-500/10 text-purple-400', + }, + warning: { + label: 'Warning', + icon: AlertTriangle, + className: 'border-amber-500/30 bg-amber-500/10 text-amber-400', + }, + caution: { + label: 'Caution', + icon: AlertOctagon, + className: 'border-red-500/30 bg-red-500/10 text-red-400', + }, +}; + +function AlertBox({ kind, children }: { kind: AlertKind; children: ReactNode }) { + const meta = ALERT_STYLES[kind]; + const Icon = meta.icon; + return ( + + ); +} + +function CodeBlock({ + language, + children, + isLightTheme, +}: { + language?: string; + children: string; + isLightTheme: boolean; +}) { + const [copied, setCopied] = useState(false); + const timeoutRef = useRef(null); + + useEffect(() => { + return () => { + if (timeoutRef.current !== null) window.clearTimeout(timeoutRef.current); + }; + }, []); + + const copy = async () => { + try { + await navigator.clipboard.writeText(children); + setCopied(true); + if (timeoutRef.current !== null) window.clearTimeout(timeoutRef.current); + timeoutRef.current = window.setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error('Failed to copy code to clipboard:', err); + } + }; + + return ( +
    +
    + + {language || 'code'} + + +
    + 4} + lineNumberStyle={{ color: 'var(--color-app-muted)', minWidth: '2.5em', opacity: 0.6 }} + wrapLongLines + > + {children} + +
    + ); +} + +function ReleaseNotesMarkdownImage({ + src, + alt, + title, +}: { + src?: string | Blob; + alt?: string; + title?: string; +}) { + return ; +} + +function ReleaseNotesMarkdownVideo({ + src, + title, + loop, + poster, + children, +}: { + src?: string | Blob; + title?: string; + loop?: unknown; + poster?: string; + children?: ReactNode; +}) { + return ( + + {children} + + ); +} + +function isBareMediaParagraph(children: ReactNode): boolean { + const items = Children.toArray(children).filter((child) => { + if (typeof child === 'string') return child.trim() !== ''; + return true; + }); + if (items.length !== 1 || !isValidElement(items[0])) return false; + return items[0].type === ReleaseNotesMarkdownImage || items[0].type === ReleaseNotesMarkdownVideo; +} + +export function ReleaseNotesMarkdown({ + markdown, + isLightTheme, + renderHeading, +}: { + markdown: string; + isLightTheme: boolean; + renderHeading: (level: 1 | 2 | 3, children: ReactNode) => ReactNode; +}) { + const prepared = useMemo(() => rewriteMarkdownLocalMedia(markdown), [markdown]); + + return ( + renderHeading(1, children), + h2: ({ children }) => renderHeading(2, children), + h3: ({ children }) => renderHeading(3, children), + h4: ({ children }) => ( +

    {children}

    + ), + h5: ({ children }) => ( +
    {children}
    + ), + h6: ({ children }) => ( +
    + {children} +
    + ), + img: ReleaseNotesMarkdownImage, + video: ReleaseNotesMarkdownVideo, + code({ className, children }) { + const language = /language-([\w-]+)/.exec(className || '')?.[1]; + const codeContent = String(children).replace(/\n$/, ''); + const isBlock = Boolean(language) || codeContent.includes('\n'); + + return isBlock ? ( + + {codeContent} + + ) : ( + + {children} + + ); + }, + pre: ({ children }) => <>{children}, + p: ({ children }) => + isBareMediaParagraph(children) ? ( + <>{children} + ) : ( +

    {children}

    + ), + ul: ({ children, className }) => ( +
      + {children} +
    + ), + ol: ({ children }) => ( +
      {children}
    + ), + li: ({ children, className }) => ( +
  • + {children} +
  • + ), + input: (props) => + props.type === 'checkbox' ? ( + + ) : null, + a: ({ href, children }) => { + if (!href) return {children}; + const internal = href.startsWith('#'); + return ( + + {children} + + ); + }, + blockquote: ({ children }) => { + const items = Children.toArray(children); + const first = items[0]; + const alert = matchAlertPrefix(getNodeText(first)); + if (alert) { + const rest = [...items]; + if (isValidElement<{ children?: ReactNode }>(first)) { + const kept = stripAlertPrefixFromParts(Children.toArray(first.props.children)); + if (kept.length === 0) { + rest.shift(); + } else { + rest[0] = cloneElement(first, undefined, ...(kept as ReactNode[])); + } + } else { + rest.shift(); + } + return {rest}; + } + return ( +
    + {children} +
    + ); + }, + hr: () =>
    , + table: ({ children }) => ( +
    + {children}
    +
    + ), + th: ({ children }) => ( + + {children} + + ), + td: ({ children }) => ( + + {children} + + ), + del: ({ children }) => ( + {children} + ), + kbd: ({ children }) => { + const label = getNodeText(children).trim(); + return label ? {label} : null; + }, + mark: ({ children }) => ( + + {children} + + ), + details: ({ children, open }) => ( +
    + {children} +
    + ), + summary: ({ children }) => ( + + {children} + + ), + section: ({ children, className }) => ( +
    + {children} +
    + ), + }} + > + {prepared} +
    + ); +} diff --git a/src/components/tabs/releaseNotes/ReleaseNotesMedia.tsx b/src/components/tabs/releaseNotes/ReleaseNotesMedia.tsx new file mode 100644 index 00000000..abaccdea --- /dev/null +++ b/src/components/tabs/releaseNotes/ReleaseNotesMedia.tsx @@ -0,0 +1,198 @@ +import { useCallback, useEffect, useState, type ReactNode } from 'react'; +import { createPortal } from 'react-dom'; +import { convertFileSrc } from '@tauri-apps/api/core'; +import { X } from 'lucide-react'; +import { + classifyMediaUrl, + coerceHtmlBoolean, + isAllowedMediaUrl, + isGithubAttachmentUrl, + isLocalMediaPath, + toFilesystemPath, +} from '../../../lib/releaseNotes/mediaUrls'; + +function toWebviewSrc(src: string): string { + if (!isLocalMediaPath(src)) return src; + try { + return convertFileSrc(toFilesystemPath(src)); + } catch { + return src; + } +} + +function firstSourceSrc(children: ReactNode): string | undefined { + if (!Array.isArray(children) && !children) return undefined; + const items = Array.isArray(children) ? children : [children]; + for (const child of items) { + if (child && typeof child === 'object' && 'props' in child) { + const src = (child as { props?: { src?: string } }).props?.src; + if (src) return src; + } + } + return undefined; +} + +export function ReleaseNotesVideo({ + src, + title, + loop, + poster, + children, +}: { + src?: string; + title?: string; + loop?: unknown; + poster?: string; + children?: ReactNode; +}) { + const resolved = src || firstSourceSrc(children); + if (!resolved || !isAllowedMediaUrl(resolved)) { + return ( +

    + Video omitted (unsupported source). +

    + ); + } + + const caption = title?.trim(); + const displaySrc = toWebviewSrc(resolved); + const displayPoster = poster && isAllowedMediaUrl(poster) ? toWebviewSrc(poster) : undefined; + + return ( +
    + + {caption ? ( +
    + {caption} +
    + ) : null} +
    + ); +} + +function Lightbox({ src, alt, onClose }: { src: string; alt: string; onClose: () => void }) { + useEffect(() => { + const onKey = (event: globalThis.KeyboardEvent) => { + if (event.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [onClose]); + + return createPortal( +
    + + {alt} event.stopPropagation()} + /> +
    , + document.body, + ); +} + +export function ReleaseNotesImage({ + src, + alt, + title, +}: { + src?: string; + alt?: string; + title?: string; +}) { + const classifiedVideo = + classifyMediaUrl(src) === 'video' || title === 'video' || Boolean(title?.startsWith('zync-video')); + const [mediaSrc, setMediaSrc] = useState(src); + const [failed, setFailed] = useState(false); + const [asVideo, setAsVideo] = useState(classifiedVideo); + const [open, setOpen] = useState(false); + const close = useCallback(() => setOpen(false), []); + + if (src !== mediaSrc) { + setMediaSrc(src); + setFailed(false); + setAsVideo(classifiedVideo); + setOpen(false); + } + + if (!src || !isAllowedMediaUrl(src)) { + return alt ? {alt} : null; + } + + if (asVideo) { + return ; + } + + if (failed) { + if (!isLocalMediaPath(src) && (isGithubAttachmentUrl(src) || classifyMediaUrl(src) === 'unknown')) { + return ; + } + return ( +

    + Couldn’t load image{alt ? `: ${alt}` : ''}. +

    + ); + } + + const caption = (alt || '').trim(); + const displaySrc = toWebviewSrc(src); + + return ( +
    + + {caption ? ( +
    + {caption} +
    + ) : null} + {open ? : null} +
    + ); +} diff --git a/src/lib/releaseNotes/alerts.ts b/src/lib/releaseNotes/alerts.ts new file mode 100644 index 00000000..4d44a271 --- /dev/null +++ b/src/lib/releaseNotes/alerts.ts @@ -0,0 +1,32 @@ +export const ALERT_KINDS = ['note', 'tip', 'important', 'warning', 'caution'] as const; +export type AlertKind = (typeof ALERT_KINDS)[number]; + +const ALERT_RE = /^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]\s*/i; + +export function matchAlertPrefix(text: string): { kind: AlertKind; rest: string } | null { + const trimmed = text.trimStart(); + const match = trimmed.match(ALERT_RE); + if (!match) return null; + return { + kind: match[1].toLowerCase() as AlertKind, + rest: trimmed.slice(match[0].length).trimStart(), + }; +} + +/** Strip `[!NOTE]` from the first string part only; later parts keep their markup. */ +export function stripAlertPrefixFromParts(parts: unknown[]): unknown[] { + let stripped = false; + const out: unknown[] = []; + for (const part of parts) { + if (!stripped && typeof part === 'string') { + const alert = matchAlertPrefix(part); + if (alert) { + stripped = true; + if (alert.rest) out.push(alert.rest); + continue; + } + } + out.push(part); + } + return out; +} diff --git a/src/lib/releaseNotes/headings.ts b/src/lib/releaseNotes/headings.ts new file mode 100644 index 00000000..d8700d8e --- /dev/null +++ b/src/lib/releaseNotes/headings.ts @@ -0,0 +1,69 @@ +export interface TocEntry { + id: string; + text: string; + level: 1 | 2 | 3; +} + +export function normalizeHeadingText(text: string): string { + return text + .replace(/\[([^\]]+)\]\([^\)]+\)/g, '$1') + .replace(/`([^`]*)`/g, '$1') + .replace(/\*\*([^*]+)\*\*/g, '$1') + .replace(/\*([^*]+)\*/g, '$1') + .replace(/<[^>]+>/g, '') + .replace(/\s+/g, ' ') + .trim(); +} + +export function headingKey(text: string): string { + return normalizeHeadingText(text).toLowerCase().replace(/[^a-z]/g, ''); +} + +export function slugify(text: string, usedSlugs: Map): string { + const normalized = normalizeHeadingText(text) + .toLowerCase() + .replace(/[^a-z0-9\s-]/g, '') + .replace(/\s+/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, ''); + const baseSlug = normalized || 'section'; + + if (usedSlugs.has(baseSlug)) { + const count = usedSlugs.get(baseSlug)! + 1; + usedSlugs.set(baseSlug, count); + return `${baseSlug}-${count}`; + } + + usedSlugs.set(baseSlug, 0); + return baseSlug; +} + +export function extractToc(markdown: string): TocEntry[] { + const lines = markdown.split('\n'); + const usedSlugs = new Map(); + const entries: TocEntry[] = []; + let inFence = false; + + for (const line of lines) { + if (/^\s*```/.test(line)) { + inFence = !inFence; + continue; + } + if (inFence) continue; + + const m = line.match(/^(#{1,3})\s+(.+)/); + if (!m) continue; + + const level = m[1].length as 1 | 2 | 3; + const text = normalizeHeadingText(m[2]); + if (!text) continue; + + entries.push({ + level, + text, + id: slugify(text, usedSlugs), + }); + } + + return entries; +} diff --git a/src/lib/releaseNotes/mediaUrls.ts b/src/lib/releaseNotes/mediaUrls.ts new file mode 100644 index 00000000..20a8f499 --- /dev/null +++ b/src/lib/releaseNotes/mediaUrls.ts @@ -0,0 +1,234 @@ +const VIDEO_EXTENSIONS = new Set(['mp4', 'webm', 'ogg', 'ogv', 'mov', 'm4v']); +const IMAGE_EXTENSIONS = new Set([ + 'png', + 'jpg', + 'jpeg', + 'gif', + 'webp', + 'svg', + 'avif', + 'bmp', + 'ico', + 'apng', +]); + +const ALLOWED_HOSTS = new Set([ + 'github.com', + 'www.github.com', + 'raw.githubusercontent.com', + 'user-images.githubusercontent.com', + 'private-user-images.githubusercontent.com', + 'objects.githubusercontent.com', + 'media.githubusercontent.com', + 'camo.githubusercontent.com', + 'img.shields.io', +]); + +export type MediaKind = 'image' | 'video' | 'unknown'; + +function parseHttpUrl(raw: string | undefined | null): URL | null { + if (!raw) return null; + try { + const url = new URL(raw); + if (url.protocol !== 'https:') return null; + return url; + } catch { + return null; + } +} + +function hostAllowed(hostname: string): boolean { + const host = hostname.toLowerCase(); + if (ALLOWED_HOSTS.has(host)) return true; + return host.endsWith('.githubusercontent.com'); +} + +function pathExtension(pathname: string): string { + const last = pathname.split('/').pop() ?? ''; + const dot = last.lastIndexOf('.'); + if (dot <= 0 || dot === last.length - 1) return ''; + return last.slice(dot + 1).toLowerCase(); +} + +function extensionOf(raw: string): string { + let path = raw.trim().split('?')[0].split('#')[0]; + if (/^file:/i.test(path)) { + try { + path = new URL(path).pathname; + } catch { + /* keep */ + } + } + return pathExtension(path.replace(/\\/g, '/')); +} + +function hasMediaExtension(raw: string): boolean { + const ext = extensionOf(raw); + return IMAGE_EXTENSIONS.has(ext) || VIDEO_EXTENSIONS.has(ext); +} + +export function hasPathTraversal(raw: string): boolean { + if (raw.includes('\0')) return true; + const candidates = [raw]; + try { + candidates.push(decodeURIComponent(raw)); + } catch { + /* malformed percent-encoding */ + } + if (/^file:/i.test(raw)) { + try { + candidates.push(decodeURIComponent(new URL(raw).pathname)); + } catch { + /* ignore */ + } + } + return candidates.some((path) => { + const normalized = path.replace(/\\/g, '/'); + return normalized.split('/').includes('..') || /%2e%2e/i.test(normalized); + }); +} + +/** Absolute Windows/POSIX/`file:` paths with a media extension. */ +export function isLocalMediaPath(raw: string | undefined | null): boolean { + if (!raw) return false; + const trimmed = raw.trim(); + if (!hasMediaExtension(trimmed)) return false; + if (/^file:/i.test(trimmed)) { + try { + return new URL(trimmed).protocol === 'file:'; + } catch { + return false; + } + } + if (/^[a-zA-Z]:[\\/]/.test(trimmed)) return true; + if (trimmed.startsWith('\\\\')) return true; + if (trimmed.startsWith('/') && !trimmed.startsWith('//')) return true; + return false; +} + +export function toFileUrl(raw: string): string { + const trimmed = raw.trim(); + if (/^file:/i.test(trimmed)) return trimmed; + if (/^[a-zA-Z]:[\\/]/.test(trimmed)) { + return new URL(`file:///${trimmed.replace(/\\/g, '/')}`).href; + } + if (trimmed.startsWith('\\\\')) { + return new URL(`file:${trimmed.replace(/\\/g, '/')}`).href; + } + if (trimmed.startsWith('/')) { + return new URL(`file://${trimmed}`).href; + } + return trimmed; +} + +export function toFilesystemPath(raw: string): string { + const trimmed = raw.trim(); + if (!/^file:/i.test(trimmed)) return trimmed; + const url = new URL(trimmed); + let path = decodeURIComponent(url.pathname); + if (/^\/[a-zA-Z]:\//.test(path)) path = path.slice(1); + return path; +} + +/** Rewrite a Windows/POSIX path to `file:` so markdown/HTML sanitizers keep it. */ +export function rewriteLocalMediaSrc(src: string): string | null { + if (!isLocalMediaPath(src) || hasPathTraversal(src)) return null; + return toFileUrl(src); +} + +export function isAllowedMediaUrl(raw: string | undefined | null): boolean { + if (!raw) return false; + if (isLocalMediaPath(raw) && !hasPathTraversal(raw)) return true; + const url = parseHttpUrl(raw); + if (!url) return false; + if (hostAllowed(url.hostname)) return true; + // Release notes often hotlink CDNs (R2, etc.). Require a media extension. + return hasMediaExtension(raw); +} + +export function isGithubAttachmentUrl(raw: string | undefined | null): boolean { + const url = parseHttpUrl(raw); + if (!url || !hostAllowed(url.hostname)) return false; + const host = url.hostname.toLowerCase(); + if (host === 'github.com' || host === 'www.github.com') { + return url.pathname.startsWith('/user-attachments/assets/'); + } + return host === 'private-user-images.githubusercontent.com'; +} + +export function classifyMediaUrl(raw: string | undefined | null): MediaKind { + if (!isAllowedMediaUrl(raw) || !raw) return 'unknown'; + const ext = extensionOf(raw); + if (VIDEO_EXTENSIONS.has(ext)) return 'video'; + if (IMAGE_EXTENSIONS.has(ext)) return 'image'; + return 'unknown'; +} + +export function coerceHtmlBoolean(value: unknown): boolean { + if (value === true || value === '' || value === 'true' || value === 'loop') return true; + if (typeof value === 'string' && value.toLowerCase() === 'loop') return true; + return false; +} + +function unwrapAngle(raw: string): string { + const trimmed = raw.trim(); + if (trimmed.startsWith('<') && trimmed.endsWith('>')) return trimmed.slice(1, -1).trim(); + return trimmed; +} + +export function shouldEmbedAsMedia(raw: string): boolean { + const path = unwrapAngle(raw); + if (!isAllowedMediaUrl(path) || hasPathTraversal(path)) return false; + if (isLocalMediaPath(path) || isGithubAttachmentUrl(path)) return true; + const kind = classifyMediaUrl(path); + return kind === 'image' || kind === 'video'; +} + +function mapOutsideFences(markdown: string, fn: (chunk: string) => string): string { + return markdown + .split(/(```[\s\S]*?```|~~~[\s\S]*?~~~)/g) + .map((part) => (part.startsWith('```') || part.startsWith('~~~') ? part : fn(part))) + .join(''); +} + +function toMarkdownImageDest(path: string): string { + const local = rewriteLocalMediaSrc(path); + if (local) return `<${local}>`; + if (/[()\s]/.test(path)) return `<${path}>`; + return path; +} + +/** + * GitHub/WaveTerm often paste a bare path, CDN GIF URL, or `![](C:\…)`. + * Rewrite those to image markdown before the parser treats them as links/`C:` protocols. + */ +export function rewriteMarkdownLocalMedia(markdown: string): string { + return mapOutsideFences(markdown, (chunk) => { + const withImages = chunk.replace( + /!\[([^\]]*)\]\(\s*\n)]+)>?\s*\)/g, + (full, alt: string, dest: string) => { + const path = dest.trim(); + if (!isLocalMediaPath(path)) return full; + return `![${alt}](${toMarkdownImageDest(path)})`; + }, + ); + return withImages.replace(/^[ \t]*\S[^\n]*$/gm, (line) => { + const trimmed = line.trim(); + if (trimmed.startsWith('![') || /^<\/?[a-zA-Z]/.test(trimmed)) return line; + const linked = trimmed.match(/^\[([^\]]*)\]\(\s*\n)]+)>?\s*\)$/); + if (linked) { + const dest = linked[2].trim(); + const label = linked[1].trim(); + if (shouldEmbedAsMedia(dest) && (label === dest || label === '' || shouldEmbedAsMedia(label))) { + const indent = line.match(/^[ \t]*/)?.[0] ?? ''; + return `${indent}![](${toMarkdownImageDest(dest)})`; + } + return line; + } + const candidate = unwrapAngle(trimmed); + if (!shouldEmbedAsMedia(candidate)) return line; + const indent = line.match(/^[ \t]*/)?.[0] ?? ''; + return `${indent}![](${toMarkdownImageDest(candidate)})`; + }); + }); +} diff --git a/src/lib/releaseNotes/reactText.ts b/src/lib/releaseNotes/reactText.ts new file mode 100644 index 00000000..4ba52e73 --- /dev/null +++ b/src/lib/releaseNotes/reactText.ts @@ -0,0 +1,8 @@ +import { isValidElement, type ReactNode } from 'react'; + +export function getNodeText(node: ReactNode): string { + if (typeof node === 'string' || typeof node === 'number') return String(node); + if (Array.isArray(node)) return node.map(getNodeText).join(''); + if (isValidElement<{ children?: ReactNode }>(node)) return getNodeText(node.props.children); + return ''; +} diff --git a/src/lib/releaseNotes/sanitizeSchema.ts b/src/lib/releaseNotes/sanitizeSchema.ts new file mode 100644 index 00000000..8bcddb1b --- /dev/null +++ b/src/lib/releaseNotes/sanitizeSchema.ts @@ -0,0 +1,31 @@ +import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'; + +type SanitizeSchema = NonNullable[0]>; + +/** GitHub-style markdown HTML, plus `